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..080dd3b38 100644 --- a/packages/pinia-orm/src/repository/Repository.ts +++ b/packages/pinia-orm/src/repository/Repository.ts @@ -403,15 +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.getModel().$newInstance(record, { + const makeOne = (record?: Element): M => { + const model = this.getModel() + const typeModel = record ? model.$getDiscriminatedModel(record) : undefined + + return (typeModel ? typeModel.newRawInstance() as M : model).$newInstance(record, { relations: true, - })) + }) } - return this.getModel().$newInstance(records, { - relations: true, - }) + return isArray(records) ? records.map(makeOne) : makeOne(records) } /* 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) + }) })