Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/content/1.guide/2.model/7.single-table-inheritance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
17 changes: 14 additions & 3 deletions packages/pinia-orm/src/composables/useRepo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>) => {
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())
}
Expand Down
24 changes: 24 additions & 0 deletions packages/pinia-orm/src/model/Model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Model>([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.
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/pinia-orm/src/query/Query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1086,7 +1086,7 @@ export class Query<M extends Model = Model> {
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()
Expand Down
13 changes: 7 additions & 6 deletions packages/pinia-orm/src/repository/Repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,15 +403,16 @@ export class Repository<M extends Model = Model> {
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)
}

/*
Expand Down
95 changes: 95 additions & 0 deletions packages/pinia-orm/tests/unit/model/Model_STI.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
Loading