Skip to content

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

Description

@evoactivity

The problem

With single table inheritance (STI), several typed subclasses share one table and a discriminator column tells them apart. Each subclass gets its own resource and its own JSON:API type: movies, shows, channels.

That works for direct queries. It breaks for any relation whose declared target is the base class.

class MetadataItem extends BaseModel {
  @column() declare metadataType: 'movie' | 'show' | 'channel'
}

class Movie extends MetadataItem {
  static table = 'metadata_items'
}

class User extends BaseModel {
  @manyToMany(() => MetadataItem, { pivotTable: 'favourites' })
  declare favourites: ManyToMany<typeof MetadataItem>
}

GET /movies/1 is correct, because the row really is a Movie. But a favourite serialises as metadata-items, not movies, and a client sending { "type": "movies" } to the relationship endpoint gets a 409.

Why

Lucid preloads through relation.relatedModel(), which for a base-targeted relation is the base class, so every row hydrates as the base class no matter what the discriminator says:

direct query class : Movie
favourite class    : MetadataItem
favourite type col : movie

The row knows exactly what it is. Nothing in the serialisation path looks at the column.

This is not a Lucid bug. Lucid has no concept of a discriminator and is hydrating the declared type correctly. Supporting STI there would mean subclass hydration from a column, which is a large feature upstream. We can resolve the concrete type ourselves from data the row already carries.

Where it goes wrong

Reads are already per-row for the resource class, but per-model for the type:

  • document_builder.ts:114 and :204 and :211 call typeFor(Model) / typeFor(row.constructor)
  • registry.ts:54 typeFor is a map keyed by model class, with no per-row escape
  • registry.ts:76 resourceForRow already takes a row, but immediately discards it with resourceFor(row.constructor)

Writes are pinned to a single string derived from the declared relation target:

  • relationships.ts:95 const relatedType = registry.typeFor(relation.relatedModel()), then a strict equality check that 409s
  • deserializer.ts:187 the same, for relationship members inside a resource POST/PATCH

What clients need

WarpDrive's relationship polymorphism guide is explicit that resource data and linkage should carry the concrete type. Ours is textbook single-table polymorphism, one shared id space over many types, and WarpDrive will usually upgrade an abstract identity when a later document supplies the concrete one. When it does not, the escape valves are request-handler post-processing with lid assignment, or a custom identity-generation hook. Neither is something an app should carry to talk to its own API.

Proposed direction

Reads. A per-row type resolution seam. resourceForRow already has the row in hand, so the shape is there. A resource declaring itself the base of an STI family says how to map a row to a concrete resource, most likely by naming the discriminator column, and the registry resolves through it.

Writes. A relation needs to declare the concrete types it accepts, so an incoming movies identifier resolves to the right model for existence checks and attachment, instead of being compared against one string.

Both are contained. The alternative, doing nothing, means STI apps either send and receive the base table's name everywhere, which pushes the problem into every client, or avoid base-targeted relations entirely, which rules out things like a favourites pivot over mixed content.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions