From c5d70bfc59c9f451f54d83fefdc23748abf00249 Mon Sep 17 00:00:00 2001 From: Gregor Becker Date: Sun, 19 Jul 2026 23:46:16 +0200 Subject: [PATCH 1/2] feat(pinia-orm): hydrate recursively discriminated models Discriminated models can now be nested over multiple levels (e.g. Document -> File -> Video). When hydrating a record, the type keys are walked down the hierarchy and an instance of the most specific matching type is created. make() now also resolves discriminated types, and nested type models are registered to the database. closes #1995 --- .../2.model/7.single-table-inheritance.md | 66 +++++++++++++ packages/pinia-orm/src/composables/useRepo.ts | 17 +++- packages/pinia-orm/src/model/Model.ts | 24 +++++ packages/pinia-orm/src/query/Query.ts | 2 +- .../pinia-orm/src/repository/Repository.ts | 9 +- .../tests/unit/model/Model_STI.spec.ts | 95 +++++++++++++++++++ 6 files changed, 205 insertions(+), 8 deletions(-) diff --git a/docs/content/1.guide/2.model/7.single-table-inheritance.md b/docs/content/1.guide/2.model/7.single-table-inheritance.md index f606e73bc..c22ef9faf 100644 --- a/docs/content/1.guide/2.model/7.single-table-inheritance.md +++ b/docs/content/1.guide/2.model/7.single-table-inheritance.md @@ -196,6 +196,72 @@ const people = useRepo(Person).all() */ ``` +### Nested Discriminators + +Discriminated models can be nested over multiple levels. A derived model may define its own `typeKey` and `types()` map — when hydrating a record, Pinia ORM walks the type keys down the hierarchy and creates an instance of the most specific matching type. + +```js +class Animal extends Model { + static entity = 'animals' + + static types () { + return { + animal: Animal, + dog: Dog, + } + } + + static fields () { + return { + id: this.attr(null), + type: this.attr('animal'), + } + } +} + +class Dog extends Animal { + static entity = 'dogs' + + static baseEntity = 'animals' + + // Second discriminator level with its own type key. + static typeKey = 'race' + + static types () { + return { + labrador: Dog, + terrier: Terrier, + } + } + + static fields () { + return { + ...super.fields(), + type: this.attr('dog'), + race: this.attr('labrador'), + } + } +} + +class Terrier extends Dog { + static entity = 'terriers' + + static baseEntity = 'animals' + + static fields () { + return { + ...super.fields(), + race: this.attr('terrier'), + speed: this.attr(0), + } + } +} + +const animal = useRepo(Animal).make({ id: 1, type: 'dog', race: 'terrier', speed: 42 }) + +animal instanceof Terrier // true +``` + ### Exposing the Discriminator Field Note that if the `static fields` method doesn't expose the discriminator field (default or custom one), it will not be exposed in the results when fetching data. If you want to be able to read the discriminator field, you'll need to add it to the `fields` method **on the base entity**: diff --git a/packages/pinia-orm/src/composables/useRepo.ts b/packages/pinia-orm/src/composables/useRepo.ts index 31b5153ae..7a5d9d0a9 100644 --- a/packages/pinia-orm/src/composables/useRepo.ts +++ b/packages/pinia-orm/src/composables/useRepo.ts @@ -26,9 +26,20 @@ export function useRepo ( : new Repository(database, pinia).initialize(ModelOrRepository) try { - const typeModels = Object.values(repository.getModel().$types()) - if (typeModels.length > 0) { - typeModels.forEach(typeModel => repository.database.register(typeModel.newRawInstance())) + const registerTypeModels = (model: Model, registered: Set) => { + Object.values(model.$types()).forEach((typeModel) => { + if (registered.has(typeModel.modelEntity())) { return } + + registered.add(typeModel.modelEntity()) + const instance = typeModel.newRawInstance() + repository.database.register(instance) + // Also register nested discriminated models (e.g. Document -> File -> Video). + registerTypeModels(instance, registered) + }) + } + + if (Object.values(repository.getModel().$types()).length > 0) { + registerTypeModels(repository.getModel(), new Set()) } else { repository.database.register(repository.getModel()) } diff --git a/packages/pinia-orm/src/model/Model.ts b/packages/pinia-orm/src/model/Model.ts index c3ec69799..1e9d5ae5f 100644 --- a/packages/pinia-orm/src/model/Model.ts +++ b/packages/pinia-orm/src/model/Model.ts @@ -709,6 +709,30 @@ export class Model { return this.$self().types() } + /** + * Resolve the most specific discriminated model for the given record by + * walking nested type keys (e.g. Document -> File -> Video). + */ + $getDiscriminatedModel (record: Element): typeof Model | undefined { + let modelByType = this.$types()[record[this.$typeKey()]] + + if (!modelByType) { return undefined } + + const visited = new Set([modelByType]) + + while (true) { + const instance = modelByType.newRawInstance() + const nextModel = instance.$types()[record[instance.$typeKey()]] + + if (!nextModel || visited.has(nextModel)) { break } + + visited.add(nextModel) + modelByType = nextModel + } + + return modelByType + } + /** * Get the pinia options for this model. */ diff --git a/packages/pinia-orm/src/query/Query.ts b/packages/pinia-orm/src/query/Query.ts index da983d75b..222b18d68 100644 --- a/packages/pinia-orm/src/query/Query.ts +++ b/packages/pinia-orm/src/query/Query.ts @@ -1086,7 +1086,7 @@ export class Query { savedHydratedModel ) { return savedHydratedModel } - const modelByType = this.model.$types()[record[this.model.$typeKey()]] + const modelByType = this.model.$getDiscriminatedModel(record) const getNewInsance = (newOptions?: ModelOptions) => (modelByType ? modelByType.newRawInstance() as M : this.model) .$newInstance(record, { relations: false, ...(options || {}), ...newOptions }) const hydratedModel = getNewInsance() diff --git a/packages/pinia-orm/src/repository/Repository.ts b/packages/pinia-orm/src/repository/Repository.ts index e7b7684d2..f28949b7f 100644 --- a/packages/pinia-orm/src/repository/Repository.ts +++ b/packages/pinia-orm/src/repository/Repository.ts @@ -404,12 +404,13 @@ export class Repository { make (record?: Element): M make (records?: Element | Element[]): M | M[] { if (isArray(records)) { - return records.map(record => this.getModel().$newInstance(record, { - relations: true, - })) + return records.map(record => this.make(record)) } - return this.getModel().$newInstance(records, { + const model = this.getModel() + const typeModel = records ? model.$getDiscriminatedModel(records) : undefined + + return (typeModel ? typeModel.newRawInstance() as M : model).$newInstance(records, { relations: true, }) } diff --git a/packages/pinia-orm/tests/unit/model/Model_STI.spec.ts b/packages/pinia-orm/tests/unit/model/Model_STI.spec.ts index d06e95a36..d90f85ed6 100644 --- a/packages/pinia-orm/tests/unit/model/Model_STI.spec.ts +++ b/packages/pinia-orm/tests/unit/model/Model_STI.spec.ts @@ -389,4 +389,99 @@ describe('unit/model/Model_STI', () => { expect(personneMoraleRepo.all().length).toBe(1) expect(personneRepo.all().length).toBe(2) }) + + it('hydrates recursively discriminated models to the most specific type', () => { + class Animal extends Model { + static entity = 'animals' + + static fields () { + return { + id: this.attr(null), + type: this.attr('animal'), + } + } + + static types () { + return { + animal: Animal, + dog: Dog, + } + } + } + + class Dog extends Animal { + static entity = 'dogs' + + static baseEntity = 'animals' + + static typeKey = 'race' + + static fields () { + return { + ...super.fields(), + type: this.attr('dog'), + race: this.attr('labrador'), + } + } + + static types () { + return { + labrador: Dog, + terrier: Terrier, + } + } + } + + class Terrier extends Dog { + static entity = 'terriers' + + static baseEntity = 'animals' + + static fields () { + return { + ...super.fields(), + race: this.attr('terrier'), + speed: this.attr(0), + } + } + } + + const animalsRepo = useRepo(Animal) + + const terrier = animalsRepo.make({ + id: 1, + type: 'dog', + race: 'terrier', + speed: 42, + }) + + expect(terrier).toBeInstanceOf(Terrier) + expect((terrier as Terrier).speed).toBe(42) + + const labrador = animalsRepo.make({ + id: 2, + type: 'dog', + race: 'labrador', + }) + + expect(labrador).toBeInstanceOf(Dog) + expect(labrador).not.toBeInstanceOf(Terrier) + + const animal = animalsRepo.make({ + id: 3, + type: 'animal', + }) + + expect(animal).toBeInstanceOf(Animal) + expect(animal).not.toBeInstanceOf(Dog) + + animalsRepo.save({ + id: 4, + type: 'dog', + race: 'terrier', + speed: 10, + }) + + expect(animalsRepo.find(4)).toBeInstanceOf(Terrier) + }) }) From 86a6a806fe50ffc587106717eade1e1c799867f8 Mon Sep 17 00:00:00 2001 From: Gregor Becker Date: Sun, 19 Jul 2026 23:50:55 +0200 Subject: [PATCH 2/2] fix(pinia-orm): resolve make overload for dts build --- packages/pinia-orm/src/repository/Repository.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/pinia-orm/src/repository/Repository.ts b/packages/pinia-orm/src/repository/Repository.ts index f28949b7f..080dd3b38 100644 --- a/packages/pinia-orm/src/repository/Repository.ts +++ b/packages/pinia-orm/src/repository/Repository.ts @@ -403,16 +403,16 @@ export class Repository { make (records: Element[]): M[] make (record?: Element): M make (records?: Element | Element[]): M | M[] { - if (isArray(records)) { - return records.map(record => this.make(record)) - } + const makeOne = (record?: Element): M => { + const model = this.getModel() + const typeModel = record ? model.$getDiscriminatedModel(record) : undefined - const model = this.getModel() - const typeModel = records ? model.$getDiscriminatedModel(records) : undefined + return (typeModel ? typeModel.newRawInstance() as M : model).$newInstance(record, { + relations: true, + }) + } - return (typeModel ? typeModel.newRawInstance() as M : model).$newInstance(records, { - relations: true, - }) + return isArray(records) ? records.map(makeOne) : makeOne(records) } /*