From 9eb69f1cba3f758f866eeecb52a1f1258cbf23ff Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Thu, 13 Aug 2026 18:42:29 +0100 Subject: [PATCH 1/4] Resolve concrete types for STI rows, reads and parse-stage writes 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. --- src/deserializer.ts | 11 +- src/document_builder.ts | 27 +++-- src/registry.ts | 66 +++++++++-- src/relationships.ts | 17 +-- src/resource.ts | 36 ++++++ tests/fixtures/models.ts | 54 +++++++++ tests/unit/sti_documents.spec.ts | 157 ++++++++++++++++++++++++++ tests/unit/sti_registry.spec.ts | 174 +++++++++++++++++++++++++++++ tests/unit/sti_writes.spec.ts | 183 +++++++++++++++++++++++++++++++ 9 files changed, 689 insertions(+), 36 deletions(-) create mode 100644 tests/unit/sti_documents.spec.ts create mode 100644 tests/unit/sti_registry.spec.ts create mode 100644 tests/unit/sti_writes.spec.ts diff --git a/src/deserializer.ts b/src/deserializer.ts index 6c85c83..c1be103 100644 --- a/src/deserializer.ts +++ b/src/deserializer.ts @@ -187,7 +187,8 @@ function deserializeRelationships( ) } relation.boot() - const relatedType = registry.typeFor(relation.relatedModel()) + const acceptedTypes = registry.acceptedTypesFor(relation.relatedModel()) + const listed = () => acceptedTypes.map((type) => `"${type}"`).join(' or ') const linkage = value.data if (relation.type === 'belongsTo') { @@ -197,9 +198,9 @@ function deserializeRelationships( continue } const identifier = parseIdentifier(linkage, `${pointer}/data`) - if (identifier.type !== relatedType) { + if (!acceptedTypes.includes(identifier.type)) { throw conflict( - `Relationship "${name}" expects resources of type "${relatedType}"`, + `Relationship "${name}" expects resources of type ${listed()}`, `${pointer}/data/type` ) } @@ -214,9 +215,9 @@ function deserializeRelationships( } toMany[name] = linkage.map((entry, index) => { const identifier = parseIdentifier(entry, `${pointer}/data/${index}`) - if (identifier.type !== relatedType) { + if (!acceptedTypes.includes(identifier.type)) { throw conflict( - `Relationship "${name}" expects resources of type "${relatedType}"`, + `Relationship "${name}" expects resources of type ${listed()}`, `${pointer}/data/${index}/type` ) } diff --git a/src/document_builder.ts b/src/document_builder.ts index 70df8aa..f4e8868 100644 --- a/src/document_builder.ts +++ b/src/document_builder.ts @@ -111,7 +111,7 @@ export class DocumentBuilder { const Model = row.constructor as LucidModel const ResourceClass = this.#registry.resourceForRow(row) const definition = instantiateResource(ResourceClass, row, this.#ctx) - const type = this.#registry.typeFor(Model) + const type = this.#registry.typeForRow(row) const id = definition.id() const allowedFields = this.#params.fields[type] @@ -168,14 +168,20 @@ export class DocumentBuilder { relationship.data = this.#linkage(preloaded, subInclude) } else if (relation.type === 'belongsTo') { // Resource linkage for an unloaded belongsTo is derivable from the - // foreign key without touching the database. + // foreign key without touching the database. Not when the target is + // an STI base (its resource declares subtypes): a bare foreign key + // cannot name a concrete type, and guessing the base type creates a + // second identity for the same row. The member keeps its links and + // omits data; loading the relation gives concrete linkage. relation.boot() - const foreignKey = (relation as unknown as { foreignKey: string }).foreignKey - const value = getAttribute(row, foreignKey) - relationship.data = - typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint' - ? { type: this.#registry.typeFor(relation.relatedModel()), id: String(value) } - : null + if (!this.#registry.resourceFor(relation.relatedModel()).subtypes) { + const foreignKey = (relation as unknown as { foreignKey: string }).foreignKey + const value = getAttribute(row, foreignKey) + relationship.data = + typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint' + ? { type: this.#registry.typeFor(relation.relatedModel()), id: String(value) } + : null + } } const links = this.#links.relationshipLinks(context.type, context.id, serializedName) @@ -198,17 +204,16 @@ export class DocumentBuilder { const identify = (related: LucidRow): ResourceIdentifier => { if (subInclude) this.#visitIncluded(related, subInclude) - const RelatedModel = related.constructor as LucidModel const ResourceClass = this.#registry.resourceForRow(related) const definition = instantiateResource(ResourceClass, related, this.#ctx) - return { type: this.#registry.typeFor(RelatedModel), id: definition.id() } + return { type: this.#registry.typeForRow(related), id: definition.id() } } return Array.isArray(preloaded) ? preloaded.map(identify) : identify(preloaded) } #visitIncluded(row: LucidRow, include: IncludeTree) { - const type = this.#registry.typeFor(row.constructor as LucidModel) + const type = this.#registry.typeForRow(row) const ResourceClass = this.#registry.resourceForRow(row) const id = instantiateResource(ResourceClass, row, this.#ctx).id() const key = `${type}:${id}` diff --git a/src/registry.ts b/src/registry.ts index efbe3fe..94903e3 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -1,10 +1,9 @@ -import string from '@adonisjs/core/helpers/string' import type { LucidModel, LucidRow } from '@adonisjs/lucid/types/model' import { JsonApiResource } from './resource.ts' export type JsonApiResourceClass = Pick< typeof JsonApiResource, - 'type' | 'model' | 'exposeRelationships' | 'filters' + 'type' | 'model' | 'exposeRelationships' | 'filters' | 'subtypes' | 'resolveResource' | 'typeName' > & { /** * `never` parameters keep subclass constructors (which narrow the row and @@ -34,7 +33,7 @@ export function instantiateResource( */ export class JsonApiRegistry { #byModel = new Map() - #typeByModel = new Map() + #typeByResource = new Map() register(resources: JsonApiResourceClass[]) { for (const resource of resources) { @@ -52,13 +51,7 @@ export class JsonApiRegistry { * in type values per the spec's member-name character rules. */ typeFor(Model: LucidModel): string { - const cached = this.#typeByModel.get(Model) - if (cached) return cached - - const resource = this.#byModel.get(Model) - const type = resource?.type ?? string.dashCase(Model.table) - this.#typeByModel.set(Model, type) - return type + return this.#typeOf(this.resourceFor(Model)) } /** @@ -67,13 +60,62 @@ export class JsonApiRegistry { resourceFor(Model: LucidModel): JsonApiResourceClass { let resource = this.#byModel.get(Model) if (!resource) { - resource = class extends JsonApiResource {} + resource = class extends JsonApiResource { + static model = () => Model + } this.#byModel.set(Model, resource) } return resource } + /** + * The resource class for a row, resolving through resolveResource when + * the row's resource declares one (single-table inheritance). Hooks can + * chain; a repeated class ends the walk so a cycle cannot loop. + */ resourceForRow(row: LucidRow): JsonApiResourceClass { - return this.resourceFor(row.constructor as LucidModel) + let resource = this.resourceFor(row.constructor as LucidModel) + const visited = new Set([resource]) + + while (resource.resolveResource) { + const resolved = resource.resolveResource(row as never) + if (!resolved || visited.has(resolved)) break + visited.add(resolved) + resource = resolved + } + return resource + } + + /** + * The JSON:API type string for a row. Differs from typeFor(Model) only + * when the row's resource resolves to a concrete subtype resource. + */ + typeForRow(row: LucidRow): string { + return this.#typeOf(this.resourceForRow(row)) + } + + /** + * The types a relation targeting this model accepts on writes. A model + * whose resource declares subtypes accepts every member of the family + * and nothing else — the base's own abstract type never appears in + * payloads. Every other model accepts its single type, as before. + */ + acceptedTypesFor(Model: LucidModel): string[] { + const subtypes = this.resourceFor(Model).subtypes?.() + if (!subtypes) return [this.typeFor(Model)] + return subtypes.map((subtype) => this.#typeOf(subtype)) + } + + /** + * Every type string comes from resource.typeName(), cached per resource + * class because row serialization is a hot path. + */ + #typeOf(resource: JsonApiResourceClass): string { + let type = this.#typeByResource.get(resource) + if (!type) { + type = resource.typeName() + this.#typeByResource.set(resource, type) + } + return type } } diff --git a/src/relationships.ts b/src/relationships.ts index 50aadb1..fb4ddc2 100644 --- a/src/relationships.ts +++ b/src/relationships.ts @@ -50,7 +50,7 @@ export function getRelationOrFail(Model: LucidModel, name: string, registry: Jso */ function parseLinkage( body: unknown, - relatedType: string, + acceptedTypes: string[], cardinality: 'to-one' | 'to-many' ): ResourceIdentifier[] | null { if (!isPlainObject(body) || !('data' in body)) { @@ -62,11 +62,12 @@ function parseLinkage( if (!isPlainObject(value) || typeof value.type !== 'string' || typeof value.id !== 'string') { throw invalidLinkage('Resource identifier objects must have string "type" and "id"') } - if (value.type !== relatedType) { + if (!acceptedTypes.includes(value.type)) { + const listed = acceptedTypes.map((type) => `"${type}"`).join(' or ') throw new JsonApiException( { title: 'Conflict', - detail: `This relationship holds resources of type "${relatedType}"`, + detail: `This relationship holds resources of type ${listed}`, source: { pointer: '/data' }, }, { status: 409 } @@ -99,7 +100,7 @@ export async function updateRelationship( const Model = row.constructor as LucidModel const relation = getRelationOrFail(Model, name, registry) const relationName = relation.relationName - const relatedType = registry.typeFor(relation.relatedModel()) + const acceptedTypes = registry.acceptedTypesFor(relation.relatedModel()) if (relation.type === 'belongsTo') { if (action !== 'replace') { @@ -108,7 +109,7 @@ export async function updateRelationship( { status: 405 } ) } - const identifiers = parseLinkage(body, relatedType, 'to-one') + const identifiers = parseLinkage(body, acceptedTypes, 'to-one') const foreignKey = (relation as unknown as { foreignKey: string }).foreignKey if (identifiers === null) { setAttribute(row, foreignKey, null) @@ -121,7 +122,7 @@ export async function updateRelationship( } if (relation.type === 'manyToMany') { - const identifiers = parseLinkage(body, relatedType, 'to-many')! + const identifiers = parseLinkage(body, acceptedTypes, 'to-many')! const ids = identifiers.map((identifier) => identifier.id) await verifyRelatedExist(Model, [{ relation: relationName, ids }]) const related = relatedClient(row, relationName) @@ -148,7 +149,7 @@ export async function updateRelationship( { status: 403 } ) } - const identifiers = parseLinkage(body, relatedType, 'to-many')! + const identifiers = parseLinkage(body, acceptedTypes, 'to-many')! const ids = identifiers.map((identifier) => identifier.id) await verifyRelatedExist(Model, [{ relation: relationName, ids }]) const RelatedModel = relation.relatedModel() @@ -178,7 +179,7 @@ export async function fetchLinkage( const loaded = row.$preloaded[relationName] as LucidRow | LucidRow[] | null | undefined const identify = (related: LucidRow): ResourceIdentifier => ({ - type: registry.typeFor(related.constructor as LucidModel), + type: registry.typeForRow(related), id: String(related.$primaryKeyValue), }) diff --git a/src/resource.ts b/src/resource.ts index d3c5b46..866e830 100644 --- a/src/resource.ts +++ b/src/resource.ts @@ -1,7 +1,9 @@ +import string from '@adonisjs/core/helpers/string' import type { HttpContext } from '@adonisjs/core/http' import type { LucidModel, LucidRow } from '@adonisjs/lucid/types/model' import type { Links, Meta } from './types.ts' import type { FilterHandler } from './filters.ts' +import type { JsonApiResourceClass } from './registry.ts' /** * Describes how a Lucid model serializes into a JSON:API resource object. @@ -43,6 +45,22 @@ export class JsonApiResource { */ declare static type?: string + /** + * The JSON:API type this resource serializes as. The single home for + * type derivation: the registry consults it for models and rows alike. + * Override it to compute types under a different scheme entirely. + */ + static typeName(this: JsonApiResourceClass): string { + if (this.type) return this.type + const Model = this.model?.() + if (!Model) { + throw new Error( + `JSON:API resource "${this.name}" must declare a static type or model to derive its type` + ) + } + return string.dashCase(Model.table) + } + /** * The Lucid model this resource describes. Required when registering the * resource explicitly. @@ -62,6 +80,24 @@ export class JsonApiResource { */ declare static filters?: Record + /** + * For the base resource of a single-table inheritance family: the + * concrete resources rows of this model can serialize as. Declaring this + * makes relations targeting the base accept any member type on writes, + * and switches unloaded belongsTo linkage to links-only (the concrete + * type of an unloaded row is unknowable from a bare foreign key). + */ + declare static subtypes?: () => JsonApiResourceClass[] + + /** + * For the base resource of a single-table inheritance family: maps a row + * to its concrete resource, typically by reading the discriminator + * column. Returning undefined keeps the row on this resource. Consulted + * by the registry wherever a row is serialized, so linkage and included + * documents carry concrete types. + */ + declare static resolveResource?: (row: never) => JsonApiResourceClass | undefined + constructor( protected resource: Row, protected ctx?: HttpContext diff --git a/tests/fixtures/models.ts b/tests/fixtures/models.ts index 0942f43..a56f13e 100644 --- a/tests/fixtures/models.ts +++ b/tests/fixtures/models.ts @@ -136,3 +136,57 @@ export function make( Object.assign(row, attributes) return row } + +/** + * Single-table inheritance family for polymorphism tests. SportsTeam is + * the base, FootballTeam and RugbyTeam are subclasses over the same + * table, sport is the discriminator. This is the pattern the polymorphism + * feature exists for (issue #9). + */ +export class SportsTeam extends BaseModel { + static table = 'sports_teams' + + @column({ isPrimary: true }) + declare id: number + + @column() + declare name: string + + @column() + declare sport: 'football' | 'rugby' +} + +export class FootballTeam extends SportsTeam { + static table = 'sports_teams' +} + +export class RugbyTeam extends SportsTeam { + static table = 'sports_teams' +} + +/** A belongsTo targeting the STI base. */ +export class Stadium extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare name: string + + @column() + declare teamId: number + + @belongsTo(() => SportsTeam, { foreignKey: 'teamId' }) + declare team: BelongsTo +} + +/** A manyToMany targeting the STI base. */ +export class Fan extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare name: string + + @manyToMany(() => SportsTeam, { pivotTable: 'favourites' }) + declare favourites: ManyToMany +} diff --git a/tests/unit/sti_documents.spec.ts b/tests/unit/sti_documents.spec.ts new file mode 100644 index 0000000..2099f77 --- /dev/null +++ b/tests/unit/sti_documents.spec.ts @@ -0,0 +1,157 @@ +/** + * Documents carry concrete types for STI rows (issue #9). Loaded rows + * resolve through resolveResource wherever they appear: as primary data, + * in linkage, and in included. An unloaded belongsTo targeting an STI + * base emits no data member at all, because a bare foreign key cannot + * name a concrete type; guessing produced a second identity for the same + * row and a full-linkage violation. + */ +import { test } from '@japa/runner' +import { DocumentBuilder } from '../../src/document_builder.ts' +import { JsonApiRegistry } from '../../src/registry.ts' +import { JsonApiResource } from '../../src/resource.ts' +import { LinkBuilder } from '../../src/links.ts' +import { parseQueryParams } from '../../src/params.ts' +import { Fan, FootballTeam, RugbyTeam, SportsTeam, Stadium, make } from '../fixtures/models.ts' +import { stubRouter } from '../fixtures/stub_router.ts' + +class FootballTeamResource extends JsonApiResource { + static type = 'football-teams' + static model = () => FootballTeam +} + +class RugbyTeamResource extends JsonApiResource { + static type = 'rugby-teams' + static model = () => RugbyTeam +} + +class SportsTeamResource extends JsonApiResource { + static model = () => SportsTeam + static subtypes = () => [FootballTeamResource, RugbyTeamResource] + static resolveResource(row: SportsTeam) { + return { football: FootballTeamResource, rugby: RugbyTeamResource }[row.sport] + } +} + +function registry() { + return new JsonApiRegistry().register([ + SportsTeamResource, + FootballTeamResource, + RugbyTeamResource, + ]) +} + +function build(input: any, qs: Record = {}, reg = registry()) { + return new DocumentBuilder(reg, parseQueryParams(qs), new LinkBuilder(false)).build(input) +} + +test.group('STI documents: primary data', () => { + test('a base-hydrated row serializes under its concrete type', ({ assert }) => { + const doc = build(make(SportsTeam, { name: 'Arsenal', sport: 'football' })) + assert.equal((doc.data as any).type, 'football-teams') + }) + + test('a mixed collection carries one concrete type per row', ({ assert }) => { + const doc = build([ + make(SportsTeam, { name: 'Arsenal', sport: 'football' }), + make(SportsTeam, { name: 'Saracens', sport: 'rugby' }), + ]) + assert.deepEqual( + (doc.data as any[]).map((resource) => resource.type), + ['football-teams', 'rugby-teams'] + ) + }) + + test('sparse fieldsets key on the concrete type', ({ assert }) => { + const doc = build(make(SportsTeam, { name: 'Arsenal', sport: 'football' }), { + fields: { 'football-teams': 'name' }, + }) + assert.deepEqual(Object.keys((doc.data as any).attributes), ['name']) + }) +}) + +test.group('STI documents: linkage and included', () => { + test('to-many linkage carries the concrete type per row', ({ assert }) => { + const fan = make(Fan, { name: 'Liam' }) + fan.$setRelated('favourites', [ + make(SportsTeam, { name: 'Arsenal', sport: 'football' }), + make(SportsTeam, { name: 'Saracens', sport: 'rugby' }), + ]) + + const doc = build(fan) + const linkage = (doc.data as any).relationships.favourites.data + assert.deepEqual( + linkage.map((identifier: any) => identifier.type), + ['football-teams', 'rugby-teams'] + ) + }) + + test('included resources carry concrete types and dedupe under them', ({ assert }) => { + const fan = make(Fan, { name: 'Liam' }) + const team = make(SportsTeam, { name: 'Arsenal', sport: 'football' }) + fan.$setRelated('favourites', [team]) + + const doc = build(fan, { include: 'favourites' }) + const included = doc.included as any[] + assert.equal(included.length, 1) + assert.equal(included[0].type, 'football-teams') + assert.equal(included[0].attributes.name, 'Arsenal') + }) + + test('a loaded belongsTo to the base gives concrete linkage', ({ assert }) => { + const stadium = make(Stadium, { name: 'Emirates', teamId: 5 }) + stadium.$setRelated('team', make(SportsTeam, { id: 5, name: 'Arsenal', sport: 'rugby' })) + + const doc = build(stadium) + assert.deepEqual((doc.data as any).relationships.team.data, { type: 'rugby-teams', id: '5' }) + }) +}) + +test.group('STI documents: unloaded belongsTo to the base', () => { + test('emits no data member, because the concrete type is unknowable', ({ assert }) => { + const stadium = make(Stadium, { name: 'Emirates', teamId: 5 }) + + const doc = build(stadium) + const team = (doc.data as any).relationships?.team + if (team !== undefined) { + assert.notProperty(team, 'data') + } + }) + + test('a relationship self link survives without the data member', ({ assert }) => { + const stadium = make(Stadium, { name: 'Emirates', teamId: 5 }) + const links = new LinkBuilder(true, stubRouter(), 'api.stadiums.show') + + const doc = new DocumentBuilder(registry(), parseQueryParams({}), links).build(stadium) + const team = (doc.data as any).relationships.team + assert.notProperty(team, 'data') + assert.isDefined(team.links) + }) + + test('an unloaded belongsTo to a non-STI target keeps FK-derived linkage', ({ assert }) => { + class PlainStadium extends Stadium {} + // Stadium.team targets SportsTeam; registering it WITHOUT subtypes + // makes it a normal model, so the FK fallback must behave exactly as + // today. + class PlainTeamResource extends JsonApiResource { + static type = 'sports-teams' + static model = () => SportsTeam + } + const reg = new JsonApiRegistry().register([PlainTeamResource]) + const stadium = make(PlainStadium, { name: 'Emirates', teamId: 5 }) + + const doc = build(stadium, {}, reg) + assert.deepEqual((doc.data as any).relationships.team.data, { + type: 'sports-teams', + id: '5', + }) + }) + + test('a loaded-but-null belongsTo still says data null', ({ assert }) => { + const stadium = make(Stadium, { name: 'Orphan Park', teamId: null }) + stadium.$setRelated('team', null) + + const doc = build(stadium) + assert.isNull((doc.data as any).relationships.team.data) + }) +}) diff --git a/tests/unit/sti_registry.spec.ts b/tests/unit/sti_registry.spec.ts new file mode 100644 index 0000000..3bf6be0 --- /dev/null +++ b/tests/unit/sti_registry.spec.ts @@ -0,0 +1,174 @@ +/** + * The registry's per-row resolution seam for single-table inheritance + * (issue #9). A base resource declares resolveResource(row) and the + * registry resolves rows through it, so a SportsTeam row whose + * discriminator says football serializes as football-teams, not + * sports-teams. + */ +import { test } from '@japa/runner' +import { JsonApiRegistry } from '../../src/registry.ts' +import { JsonApiResource } from '../../src/resource.ts' +import { FootballTeam, RugbyTeam, SportsTeam, make } from '../fixtures/models.ts' + +class FootballTeamResource extends JsonApiResource { + static type = 'football-teams' + static model = () => FootballTeam +} + +class RugbyTeamResource extends JsonApiResource { + static type = 'rugby-teams' + static model = () => RugbyTeam +} + +class SportsTeamResource extends JsonApiResource { + static model = () => SportsTeam + static subtypes = () => [FootballTeamResource, RugbyTeamResource] + static resolveResource(row: SportsTeam) { + return { football: FootballTeamResource, rugby: RugbyTeamResource }[row.sport] + } +} + +function registry() { + return new JsonApiRegistry().register([ + SportsTeamResource, + FootballTeamResource, + RugbyTeamResource, + ]) +} + +test.group('registry: resolveResource', () => { + test('a base-hydrated row resolves to its concrete resource', ({ assert }) => { + const row = make(SportsTeam, { name: 'Arsenal', sport: 'football' }) + assert.strictEqual(registry().resourceForRow(row), FootballTeamResource) + }) + + test('the discriminator decides, not the constructor', ({ assert }) => { + const football = make(SportsTeam, { name: 'Arsenal', sport: 'football' }) + const rugby = make(SportsTeam, { name: 'Saracens', sport: 'rugby' }) + const reg = registry() + assert.strictEqual(reg.resourceForRow(football), FootballTeamResource) + assert.strictEqual(reg.resourceForRow(rugby), RugbyTeamResource) + }) + + test('typeForRow gives the concrete type for a base-hydrated row', ({ assert }) => { + const reg = registry() + assert.equal(reg.typeForRow(make(SportsTeam, { sport: 'football' })), 'football-teams') + assert.equal(reg.typeForRow(make(SportsTeam, { sport: 'rugby' })), 'rugby-teams') + }) + + test('an unrecognised discriminator falls back to the base resource', ({ assert }) => { + const row = make(SportsTeam, { name: 'Mystery XI', sport: 'handball' }) + const reg = registry() + assert.strictEqual(reg.resourceForRow(row), SportsTeamResource) + assert.equal(reg.typeForRow(row), 'sports-teams') + }) + + test('models without resolveResource behave exactly as before', ({ assert }) => { + class PlainResource extends JsonApiResource { + static type = 'plain-teams' + static model = () => FootballTeam + } + const reg = new JsonApiRegistry().register([PlainResource]) + const row = make(FootballTeam, { name: 'Arsenal', sport: 'football' }) + assert.strictEqual(reg.resourceForRow(row), PlainResource) + assert.equal(reg.typeForRow(row), 'plain-teams') + }) + + test('typeForRow equals typeFor for unregistered models', ({ assert }) => { + const reg = new JsonApiRegistry() + const row = make(FootballTeam, { name: 'Arsenal', sport: 'football' }) + assert.equal(reg.typeForRow(row), reg.typeFor(FootballTeam)) + }) + + test('a hook returning its own class does not loop', ({ assert }) => { + class SelfResource extends JsonApiResource { + static type = 'selfies' + static model = () => SportsTeam + static resolveResource(_row: SportsTeam) { + return SelfResource + } + } + const reg = new JsonApiRegistry().register([SelfResource]) + const row = make(SportsTeam, { sport: 'football' }) + assert.strictEqual(reg.resourceForRow(row), SelfResource) + assert.equal(reg.typeForRow(row), 'selfies') + }) + + test('a chain of hooks resolves through every link', ({ assert }) => { + class FinalResource extends JsonApiResource { + static type = 'finals' + static model = () => SportsTeam + } + class MiddleResource extends JsonApiResource { + static model = () => SportsTeam + static resolveResource(_row: SportsTeam) { + return FinalResource + } + } + class EntryResource extends JsonApiResource { + static model = () => SportsTeam + static resolveResource(_row: SportsTeam) { + return MiddleResource + } + } + const reg = new JsonApiRegistry().register([EntryResource]) + const row = make(SportsTeam, { sport: 'football' }) + assert.strictEqual(reg.resourceForRow(row), FinalResource) + assert.equal(reg.typeForRow(row), 'finals') + }) + + test('a cycle between two hooks stops instead of looping', ({ assert }) => { + class AResource extends JsonApiResource { + static type = 'a-things' + static model = () => SportsTeam + static resolveResource(_row: SportsTeam): typeof JsonApiResource { + return BResource + } + } + class BResource extends JsonApiResource { + static type = 'b-things' + static model = () => SportsTeam + static resolveResource(_row: SportsTeam) { + return AResource + } + } + const reg = new JsonApiRegistry().register([AResource]) + const row = make(SportsTeam, { sport: 'football' }) + // resolution must terminate; landing on either class is acceptable + const resolved = reg.resourceForRow(row) + assert.include([AResource, BResource], resolved) + }) +}) + +test.group('registry: typeName as the single home for type derivation', () => { + test('a resource overriding typeName controls its type everywhere', ({ assert }) => { + class VersionedTeamResource extends JsonApiResource { + static model = () => FootballTeam + static typeName() { + return 'v2-football-teams' + } + } + class RoutingResource extends JsonApiResource { + static model = () => SportsTeam + static resolveResource(_row: SportsTeam) { + return VersionedTeamResource + } + } + const reg = new JsonApiRegistry().register([RoutingResource, VersionedTeamResource]) + + assert.equal(reg.typeForRow(make(SportsTeam, { sport: 'football' })), 'v2-football-teams') + assert.equal(reg.typeFor(FootballTeam), 'v2-football-teams') + }) + + test('the default typeName is the static type, then the kebab-cased table', ({ assert }) => { + class NamedResource extends JsonApiResource { + static type = 'named-things' + static model = () => SportsTeam + } + class UnnamedResource extends JsonApiResource { + static model = () => SportsTeam + } + assert.equal(NamedResource.typeName(), 'named-things') + assert.equal(UnnamedResource.typeName(), 'sports-teams') + }) +}) diff --git a/tests/unit/sti_writes.spec.ts b/tests/unit/sti_writes.spec.ts new file mode 100644 index 0000000..a83229a --- /dev/null +++ b/tests/unit/sti_writes.spec.ts @@ -0,0 +1,183 @@ +/** + * Write paths accept the STI family (issue #9). A relation targeting a + * base resource that declares subtypes accepts any member type in the + * family, and rejects types outside it — including the base's own + * abstract type, which never belongs in a payload. Rejections happen + * before any database access, so they are unit-testable; the accept-and- + * attach paths are covered functionally in the example app. + */ +import { test } from '@japa/runner' +import { JsonApiRegistry } from '../../src/registry.ts' +import { JsonApiResource } from '../../src/resource.ts' +import { JsonApiException } from '../../src/errors.ts' +import { deserializeResourceDocument } from '../../src/deserializer.ts' +import { updateRelationship } from '../../src/relationships.ts' +import { Fan, FootballTeam, RugbyTeam, SportsTeam, Stadium, make } from '../fixtures/models.ts' + +class FootballTeamResource extends JsonApiResource { + static type = 'football-teams' + static model = () => FootballTeam +} + +class RugbyTeamResource extends JsonApiResource { + static type = 'rugby-teams' + static model = () => RugbyTeam +} + +class SportsTeamResource extends JsonApiResource { + static model = () => SportsTeam + static subtypes = () => [FootballTeamResource, RugbyTeamResource] + static resolveResource(row: SportsTeam) { + return { football: FootballTeamResource, rugby: RugbyTeamResource }[row.sport] + } +} + +function registry() { + return new JsonApiRegistry().register([ + SportsTeamResource, + FootballTeamResource, + RugbyTeamResource, + ]) +} + +async function rejection(fn: () => Promise): Promise { + try { + await fn() + } catch (error) { + return error as JsonApiException + } + throw new Error('expected the call to reject, it resolved') +} + +test.group('STI writes: resource body (deserializer)', () => { + test('a belongsTo to the base accepts any family member type', ({ assert }) => { + for (const type of ['football-teams', 'rugby-teams']) { + const result = deserializeResourceDocument(Stadium, registry(), { + data: { + type: 'stadiums', + attributes: { name: 'Emirates' }, + relationships: { team: { data: { type, id: '5' } } }, + }, + }) + assert.equal(result.attributes.teamId, '5') + } + }) + + test('a type outside the family is a 409 naming every acceptable type', ({ assert }) => { + const error = assert.throws( + () => + deserializeResourceDocument(Stadium, registry(), { + data: { + type: 'stadiums', + relationships: { team: { data: { type: 'trains', id: '5' } } }, + }, + }), + JsonApiException + ) as unknown as JsonApiException + + assert.equal(error.status, 409) + assert.match(error.errors[0].detail!, /football-teams/) + assert.match(error.errors[0].detail!, /rugby-teams/) + }) + + test('the abstract base type is rejected too', ({ assert }) => { + const error = assert.throws( + () => + deserializeResourceDocument(Stadium, registry(), { + data: { + type: 'stadiums', + relationships: { team: { data: { type: 'sports-teams', id: '5' } } }, + }, + }), + JsonApiException + ) as unknown as JsonApiException + assert.equal(error.status, 409) + }) + + test('a to-many to the base accepts mixed family types in one payload', ({ assert }) => { + const result = deserializeResourceDocument(Fan, registry(), { + data: { + type: 'fans', + attributes: { name: 'Liam' }, + relationships: { + favourites: { + data: [ + { type: 'football-teams', id: '1' }, + { type: 'rugby-teams', id: '2' }, + ], + }, + }, + }, + }) + assert.deepEqual(result.toMany.favourites, ['1', '2']) + }) + + test('a relation to a non-STI target still holds its single type', ({ assert }) => { + class PlainTeamResource extends JsonApiResource { + static type = 'sports-teams' + static model = () => SportsTeam + } + const reg = new JsonApiRegistry().register([PlainTeamResource]) + + const error = assert.throws( + () => + deserializeResourceDocument(Stadium, reg, { + data: { + type: 'stadiums', + relationships: { team: { data: { type: 'football-teams', id: '5' } } }, + }, + }), + JsonApiException + ) as unknown as JsonApiException + assert.equal(error.status, 409) + }) +}) + +test.group('STI writes: relationship endpoints (parse stage)', () => { + test('a to-one write with a type outside the family is a 409', async ({ assert }) => { + const stadium = make(Stadium, { name: 'Emirates', teamId: 7 }) + + const error = await rejection(() => + updateRelationship( + stadium, + 'team', + registry(), + { data: { type: 'trains', id: '1' } }, + 'replace' + ) + ) + assert.equal(error.status, 409) + assert.match(error.errors[0].detail!, /football-teams/) + }) + + test('a to-many write with a type outside the family is a 409', async ({ assert }) => { + const fan = make(Fan, { name: 'Liam' }) + + const error = await rejection(() => + updateRelationship( + fan, + 'favourites', + registry(), + { data: [{ type: 'trains', id: '1' }] }, + 'add' + ) + ) + assert.equal(error.status, 409) + assert.match(error.errors[0].detail!, /rugby-teams/) + }) + + test('the abstract base type is rejected at the endpoint too', async ({ assert }) => { + const fan = make(Fan, { name: 'Liam' }) + + const error = await rejection(() => + updateRelationship( + fan, + 'favourites', + registry(), + { data: [{ type: 'sports-teams', id: '1' }] }, + 'add' + ) + ) + assert.equal(error.status, 409) + }) +}) From c6e4cc20777b8f8a60c327e7dc420d9957fce999 Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Thu, 13 Aug 2026 20:22:01 +0100 Subject: [PATCH 2/4] Verify claimed subtypes and give the example app an STI family 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. --- docs/low-level.md | 2 +- examples/blog/app/models/article.ts | 4 + examples/blog/app/models/attachment.ts | 16 ++ .../blog/app/resources/attachment_resource.ts | 25 +++ examples/blog/config/jsonapi.ts | 2 + .../1769000000004_create_attachments_table.ts | 45 +++++ examples/blog/database/schema.ts | 32 ++++ .../blog/tests/functional/jsonapi_sti.spec.ts | 176 ++++++++++++++++++ src/context.ts | 2 +- src/deserializer.ts | 52 +++++- src/registry.ts | 10 + src/relationships.ts | 18 +- tests/unit/deserializer.spec.ts | 11 +- tests/unit/sti_registry.spec.ts | 20 ++ 14 files changed, 402 insertions(+), 13 deletions(-) create mode 100644 examples/blog/app/models/attachment.ts create mode 100644 examples/blog/app/resources/attachment_resource.ts create mode 100644 examples/blog/database/migrations/1769000000004_create_attachments_table.ts create mode 100644 examples/blog/tests/functional/jsonapi_sti.spec.ts diff --git a/docs/low-level.md b/docs/low-level.md index 710b925..22839a8 100644 --- a/docs/low-level.md +++ b/docs/low-level.md @@ -115,7 +115,7 @@ const registry = await app.container.make(JsonApiRegistry) const input = deserializeResourceDocument(Article, registry, payload, { allowClientIds: false, }) -await verifyRelatedExist(Article, input.references) // 404-style JsonApiException if missing +await verifyRelatedExist(Article, input.references, registry) // 404-style JsonApiException if missing const article = await Article.create(input.attributes) ``` diff --git a/examples/blog/app/models/article.ts b/examples/blog/app/models/article.ts index c684da5..60cd9aa 100644 --- a/examples/blog/app/models/article.ts +++ b/examples/blog/app/models/article.ts @@ -4,6 +4,7 @@ import type { BelongsTo, HasMany, ManyToMany } from '@adonisjs/lucid/types/relat import User from '#models/user' import Comment from '#models/comment' import Tag from '#models/tag' +import Attachment from '#models/attachment' export default class Article extends ArticleSchema { @belongsTo(() => User, { foreignKey: 'authorId' }) @@ -14,4 +15,7 @@ export default class Article extends ArticleSchema { @manyToMany(() => Tag, { pivotTable: 'article_tags' }) declare tags: ManyToMany + + @manyToMany(() => Attachment, { pivotTable: 'article_attachments' }) + declare attachments: ManyToMany } diff --git a/examples/blog/app/models/attachment.ts b/examples/blog/app/models/attachment.ts new file mode 100644 index 0000000..bbfe33e --- /dev/null +++ b/examples/blog/app/models/attachment.ts @@ -0,0 +1,16 @@ +import { AttachmentSchema } from '#database/schema' + +/** + * The base of a single-table inheritance family: images and videos share + * the attachments table, told apart by the kind column. Application code + * uses the subclasses; relations that can hold either target this base. + */ +export default class Attachment extends AttachmentSchema {} + +export class Image extends Attachment { + static table = 'attachments' +} + +export class Video extends Attachment { + static table = 'attachments' +} diff --git a/examples/blog/app/resources/attachment_resource.ts b/examples/blog/app/resources/attachment_resource.ts new file mode 100644 index 0000000..c8609c0 --- /dev/null +++ b/examples/blog/app/resources/attachment_resource.ts @@ -0,0 +1,25 @@ +import Attachment, { Image, Video } from '#models/attachment' +import { JsonApiResource } from '@evoactivity/jsonapi-adonis' + +export class ImageResource extends JsonApiResource { + static type = 'images' + static model = () => Image +} + +export class VideoResource extends JsonApiResource