Skip to content

Commit c5d70bf

Browse files
committed
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
1 parent c6bebb9 commit c5d70bf

6 files changed

Lines changed: 205 additions & 8 deletions

File tree

docs/content/1.guide/2.model/7.single-table-inheritance.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,72 @@ const people = useRepo(Person).all()
196196
*/
197197
```
198198

199+
### Nested Discriminators
200+
201+
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.
202+
203+
```js
204+
class Animal extends Model {
205+
static entity = 'animals'
206+
207+
static types () {
208+
return {
209+
animal: Animal,
210+
dog: Dog,
211+
}
212+
}
213+
214+
static fields () {
215+
return {
216+
id: this.attr(null),
217+
type: this.attr('animal'),
218+
}
219+
}
220+
}
221+
222+
class Dog extends Animal {
223+
static entity = 'dogs'
224+
225+
static baseEntity = 'animals'
226+
227+
// Second discriminator level with its own type key.
228+
static typeKey = 'race'
229+
230+
static types () {
231+
return {
232+
labrador: Dog,
233+
terrier: Terrier,
234+
}
235+
}
236+
237+
static fields () {
238+
return {
239+
...super.fields(),
240+
type: this.attr('dog'),
241+
race: this.attr('labrador'),
242+
}
243+
}
244+
}
245+
246+
class Terrier extends Dog {
247+
static entity = 'terriers'
248+
249+
static baseEntity = 'animals'
250+
251+
static fields () {
252+
return {
253+
...super.fields(),
254+
race: this.attr('terrier'),
255+
speed: this.attr(0),
256+
}
257+
}
258+
}
259+
260+
const animal = useRepo(Animal).make({ id: 1, type: 'dog', race: 'terrier', speed: 42 })
261+
262+
animal instanceof Terrier // true
263+
```
264+
199265
### Exposing the Discriminator Field
200266

201267
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**:

packages/pinia-orm/src/composables/useRepo.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,20 @@ export function useRepo (
2626
: new Repository(database, pinia).initialize(ModelOrRepository)
2727

2828
try {
29-
const typeModels = Object.values(repository.getModel().$types())
30-
if (typeModels.length > 0) {
31-
typeModels.forEach(typeModel => repository.database.register(typeModel.newRawInstance()))
29+
const registerTypeModels = (model: Model, registered: Set<string>) => {
30+
Object.values(model.$types()).forEach((typeModel) => {
31+
if (registered.has(typeModel.modelEntity())) { return }
32+
33+
registered.add(typeModel.modelEntity())
34+
const instance = typeModel.newRawInstance()
35+
repository.database.register(instance)
36+
// Also register nested discriminated models (e.g. Document -> File -> Video).
37+
registerTypeModels(instance, registered)
38+
})
39+
}
40+
41+
if (Object.values(repository.getModel().$types()).length > 0) {
42+
registerTypeModels(repository.getModel(), new Set())
3243
} else {
3344
repository.database.register(repository.getModel())
3445
}

packages/pinia-orm/src/model/Model.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,30 @@ export class Model {
709709
return this.$self().types()
710710
}
711711

712+
/**
713+
* Resolve the most specific discriminated model for the given record by
714+
* walking nested type keys (e.g. Document -> File -> Video).
715+
*/
716+
$getDiscriminatedModel (record: Element): typeof Model | undefined {
717+
let modelByType = this.$types()[record[this.$typeKey()]]
718+
719+
if (!modelByType) { return undefined }
720+
721+
const visited = new Set<typeof Model>([modelByType])
722+
723+
while (true) {
724+
const instance = modelByType.newRawInstance()
725+
const nextModel = instance.$types()[record[instance.$typeKey()]]
726+
727+
if (!nextModel || visited.has(nextModel)) { break }
728+
729+
visited.add(nextModel)
730+
modelByType = nextModel
731+
}
732+
733+
return modelByType
734+
}
735+
712736
/**
713737
* Get the pinia options for this model.
714738
*/

packages/pinia-orm/src/query/Query.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1086,7 +1086,7 @@ export class Query<M extends Model = Model> {
10861086
savedHydratedModel
10871087
) { return savedHydratedModel }
10881088

1089-
const modelByType = this.model.$types()[record[this.model.$typeKey()]]
1089+
const modelByType = this.model.$getDiscriminatedModel(record)
10901090
const getNewInsance = (newOptions?: ModelOptions) => (modelByType ? modelByType.newRawInstance() as M : this.model)
10911091
.$newInstance(record, { relations: false, ...(options || {}), ...newOptions })
10921092
const hydratedModel = getNewInsance()

packages/pinia-orm/src/repository/Repository.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -404,12 +404,13 @@ export class Repository<M extends Model = Model> {
404404
make (record?: Element): M
405405
make (records?: Element | Element[]): M | M[] {
406406
if (isArray(records)) {
407-
return records.map(record => this.getModel().$newInstance(record, {
408-
relations: true,
409-
}))
407+
return records.map(record => this.make(record))
410408
}
411409

412-
return this.getModel().$newInstance(records, {
410+
const model = this.getModel()
411+
const typeModel = records ? model.$getDiscriminatedModel(records) : undefined
412+
413+
return (typeModel ? typeModel.newRawInstance() as M : model).$newInstance(records, {
413414
relations: true,
414415
})
415416
}

packages/pinia-orm/tests/unit/model/Model_STI.spec.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,4 +389,99 @@ describe('unit/model/Model_STI', () => {
389389
expect(personneMoraleRepo.all().length).toBe(1)
390390
expect(personneRepo.all().length).toBe(2)
391391
})
392+
393+
it('hydrates recursively discriminated models to the most specific type', () => {
394+
class Animal extends Model {
395+
static entity = 'animals'
396+
397+
static fields () {
398+
return {
399+
id: this.attr(null),
400+
type: this.attr('animal'),
401+
}
402+
}
403+
404+
static types () {
405+
return {
406+
animal: Animal,
407+
dog: Dog,
408+
}
409+
}
410+
}
411+
412+
class Dog extends Animal {
413+
static entity = 'dogs'
414+
415+
static baseEntity = 'animals'
416+
417+
static typeKey = 'race'
418+
419+
static fields () {
420+
return {
421+
...super.fields(),
422+
type: this.attr('dog'),
423+
race: this.attr('labrador'),
424+
}
425+
}
426+
427+
static types () {
428+
return {
429+
labrador: Dog,
430+
terrier: Terrier,
431+
}
432+
}
433+
}
434+
435+
class Terrier extends Dog {
436+
static entity = 'terriers'
437+
438+
static baseEntity = 'animals'
439+
440+
static fields () {
441+
return {
442+
...super.fields(),
443+
race: this.attr('terrier'),
444+
speed: this.attr(0),
445+
}
446+
}
447+
}
448+
449+
const animalsRepo = useRepo(Animal)
450+
451+
const terrier = animalsRepo.make({
452+
id: 1,
453+
type: 'dog',
454+
race: 'terrier',
455+
speed: 42,
456+
})
457+
458+
expect(terrier).toBeInstanceOf(Terrier)
459+
expect((terrier as Terrier).speed).toBe(42)
460+
461+
const labrador = animalsRepo.make({
462+
id: 2,
463+
type: 'dog',
464+
race: 'labrador',
465+
})
466+
467+
expect(labrador).toBeInstanceOf(Dog)
468+
expect(labrador).not.toBeInstanceOf(Terrier)
469+
470+
const animal = animalsRepo.make({
471+
id: 3,
472+
type: 'animal',
473+
})
474+
475+
expect(animal).toBeInstanceOf(Animal)
476+
expect(animal).not.toBeInstanceOf(Dog)
477+
478+
animalsRepo.save({
479+
id: 4,
480+
type: 'dog',
481+
race: 'terrier',
482+
speed: 10,
483+
})
484+
485+
expect(animalsRepo.find(4)).toBeInstanceOf(Terrier)
486+
})
392487
})

0 commit comments

Comments
 (0)