Skip to content

Commit

Permalink
feat(core): add support for polymorphic embeddables (#2426)
Browse files Browse the repository at this point in the history
Polymorphic embeddables allow us to define multiple classes for a single embedded property and the right one will be used based on the discriminator column, similar to how single table inheritance work.

```ts
enum AnimalType {
  CAT,
  DOG,
}

@embeddable({ abstract: true, discriminatorColumn: 'type' })
abstract class Animal {

  @enum(() => AnimalType)
  type!: AnimalType;

  @Property()
  name!: string;

}

@embeddable({ discriminatorValue: AnimalType.CAT })
class Cat extends Animal {

  @Property({ nullable: true })
  canMeow?: boolean = true;

  constructor(name: string) {
    super();
    this.type = AnimalType.CAT;
    this.name = name;
  }

}

@embeddable({ discriminatorValue: AnimalType.DOG })
class Dog extends Animal {

  @Property({ nullable: true })
  canBark?: boolean = true;

  constructor(name: string) {
    super();
    this.type = AnimalType.DOG;
    this.name = name;
  }

}

@entity()
class Owner {

  @PrimaryKey()
  id!: number;

  @Property()
  name!: string;

  @Embedded(() => [Cat, Dog])
  pet!: Cat | Dog;

}
```

Closes #1165

BREAKING CHANGE:

Embeddable instances are now created via `EntityFactory` and they respect the
`forceEntityConstructor` configuration. Due to this we need to have EM instance
when assigning to embedded properties. 

Using `em.assign()` should be preferred to get around this.

Deep assigning of child entities now works by default based on the presence of PKs in the payload.
This behaviour can be disable via updateByPrimaryKey: false in the assign options.

`mergeObjects` option is now enabled by default.
  • Loading branch information
B4nan committed Nov 17, 2021
1 parent 200c554 commit 7b7c3a2
Show file tree
Hide file tree
Showing 37 changed files with 1,011 additions and 153 deletions.
69 changes: 69 additions & 0 deletions docs/docs/embeddables.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,72 @@ class Identity {

}
```

## Polymorphic embeddables

Since v5, it is also possible to use polymorphic embeddables. This means we
can define multiple classes for a single embedded property and the right one
will be used based on the discriminator column, similar to how single table
inheritance work.

```ts
import { Embeddable, Embedded, Entity, Enum, PrimaryKey, Property } from '@mikro-orm/core';

enum AnimalType {
CAT,
DOG,
}

@Embeddable({ abstract: true, discriminatorColumn: 'type' })
abstract class Animal {

@Enum(() => AnimalType)
type!: AnimalType;

@Property()
name!: string;

}

@Embeddable({ discriminatorValue: AnimalType.CAT })
class Cat extends Animal {

@Property({ nullable: true })
canMeow?: boolean = true;

constructor(name: string) {
super();
this.type = AnimalType.CAT;
this.name = name;
}

}

@Embeddable({ discriminatorValue: AnimalType.DOG })
class Dog extends Animal {

@Property({ nullable: true })
canBark?: boolean = true;

constructor(name: string) {
super();
this.type = AnimalType.DOG;
this.name = name;
}

}

@Entity()
class Owner {

@PrimaryKey()
id!: number;

@Property()
name!: string;

@Embedded(() => [Cat, Dog])
pet!: Cat | Dog;

}
```
13 changes: 13 additions & 0 deletions docs/docs/upgrading-v4-to-v5.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,16 @@ Running migrations in production via node and ts-node is now handled the same.
This should actually not be breaking, as old format with extension is still
supported (e.g. they still can be rolled back), but newly logged migrations
will not contain the extension.

## Changes in `assign()` helper

Embeddable instances are now created via `EntityFactory` and they respect the
`forceEntityConstructor` configuration. Due to this we need to have EM instance
when assigning to embedded properties.

Using `em.assign()` should be preferred to get around this.

Deep assigning of child entities now works by default based on the presence of PKs in the payload.
This behaviour can be disable via updateByPrimaryKey: false in the assign options.

`mergeObjects` option is now enabled by default.
10 changes: 9 additions & 1 deletion packages/core/src/decorators/Embeddable.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import type { Constructor, Dictionary } from '../typings';
import { MetadataStorage } from '../metadata';

export function Embeddable() {
export function Embeddable(options: EmbeddableOptions = {}) {
return function <T>(target: T & Dictionary) {
const meta = MetadataStorage.getMetadataFromDecorator(target);
meta.class = target as unknown as Constructor<T>;
meta.name = target.name;
meta.embeddable = true;
Object.assign(meta, options);

return target;
};
}

export type EmbeddableOptions = {
discriminatorColumn?: string;
discriminatorMap?: Dictionary<string>;
discriminatorValue?: number | string;
abstract?: boolean;
};
2 changes: 1 addition & 1 deletion packages/core/src/decorators/Embedded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function Embedded(type: EmbeddedOptions | (() => AnyEntity) = {}, options
}

export type EmbeddedOptions = {
entity?: string | (() => AnyEntity);
entity?: string | (() => AnyEntity | AnyEntity[]);
type?: string;
prefix?: string | boolean;
nullable?: boolean;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/decorators/Entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export type EntityOptions<T> = {
collection?: string;
discriminatorColumn?: string;
discriminatorMap?: Dictionary<string>;
discriminatorValue?: string;
discriminatorValue?: number | string;
comment?: string;
abstract?: boolean;
readonly?: boolean;
Expand Down
13 changes: 9 additions & 4 deletions packages/core/src/entity/EntityAssigner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export class EntityAssigner {
options = {
updateNestedEntities: true,
updateByPrimaryKey: true,
mergeObjects: true,
schema: wrapped.__schema,
...options, // allow overriding the defaults
};
Expand Down Expand Up @@ -75,7 +76,7 @@ export class EntityAssigner {
return entity[prop as keyof T] = validator.validateProperty(props[prop], value, entity);
}

if (props[prop]?.reference === ReferenceType.EMBEDDED) {
if (props[prop]?.reference === ReferenceType.EMBEDDED && EntityAssigner.validateEM(em)) {
return EntityAssigner.assignEmbeddable(entity, value, props[prop], em, options);
}

Expand Down Expand Up @@ -171,10 +172,8 @@ export class EntityAssigner {
collection.set(items);
}

private static assignEmbeddable<T extends AnyEntity<T>>(entity: T, value: any, prop: EntityProperty, em: EntityManager, options: AssignOptions): void {
const Embeddable = prop.embeddable;
private static assignEmbeddable<T extends AnyEntity<T>>(entity: T, value: any, prop: EntityProperty, em: EntityManager | undefined, options: AssignOptions): void {
const propName = prop.embedded ? prop.embedded[1] : prop.name;
entity[propName] = prop.array || options.mergeObjects ? (entity[propName] || Object.create(Embeddable.prototype)) : Object.create(Embeddable.prototype);

if (!value) {
entity[propName] = value;
Expand All @@ -194,6 +193,12 @@ export class EntityAssigner {
});
}

const create = () => EntityAssigner.validateEM(em) && em!.getEntityFactory().createEmbeddable<T>(prop.type, value, {
convertCustomTypes: options.convertCustomTypes,
newEntity: options.mergeObjects ? !entity[propName] : true,
});
entity[propName] = options.mergeObjects ? (entity[propName] || create()) : create();

Object.keys(value).forEach(key => {
const childProp = prop.embeddedProps[key];

Expand Down
20 changes: 19 additions & 1 deletion packages/core/src/entity/EntityFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,15 @@ export class EntityFactory {
return this.create<T>(entityName, id as EntityData<T>, { ...options, initialized: false }) as T;
}

createEmbeddable<T>(entityName: EntityName<T>, data: EntityData<T>, options: Pick<FactoryOptions, 'newEntity' | 'convertCustomTypes'> = {}): T {
entityName = Utils.className(entityName);
data = { ...data };
const meta = this.metadata.get(entityName);
const meta2 = this.processDiscriminatorColumn<T>(meta, data);

return this.createEntity(data, meta2, options);
}

private createEntity<T extends AnyEntity<T>>(data: EntityData<T>, meta: EntityMetadata<T>, options: FactoryOptions): T {
if (options.newEntity || meta.forceConstructor) {
const params = this.extractConstructorParams<T>(meta, data, options);
Expand Down Expand Up @@ -185,7 +194,7 @@ export class EntityFactory {
}

private findEntity<T>(data: EntityData<T>, meta: EntityMetadata<T>, options: FactoryOptions): T | undefined {
if (!meta.compositePK && !meta.properties[meta.primaryKeys[0]].customType) {
if (!meta.compositePK && !meta.properties[meta.primaryKeys[0]]?.customType) {
return this.unitOfWork.getById<T>(meta.name!, data[meta.primaryKeys[0]] as Primary<T>, options.schema);
}

Expand Down Expand Up @@ -253,6 +262,15 @@ export class EntityFactory {
return this.createReference(meta.properties[k].type, data[k], options);
}

if (meta.properties[k]?.reference === ReferenceType.EMBEDDED && data[k]) {
/* istanbul ignore next */
if (Utils.isEntity<T>(data[k])) {
return data[k];
}

return this.createEmbeddable(meta.properties[k].type, data[k], options);
}

if (!meta.properties[k]) {
return data;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@ export class MetadataError<T extends AnyEntity = AnyEntity> extends ValidationEr
return new MetadataError(`Property ${className}:${name} is being overwritten by its child property ${embeddedName}:${name}. Consider using a prefix to overcome this issue.`);
}

static invalidPrimaryKey(meta: EntityMetadata, prop: EntityProperty, requiredName: string) {
return this.fromMessage(meta, prop, `has wrong field name, '${requiredName}' is required in current driver`);
}

private static fromMessage(meta: EntityMetadata, prop: EntityProperty, message: string): MetadataError {
return new MetadataError(`${meta.className}.${prop.name} ${message}`);
}
Expand Down
25 changes: 23 additions & 2 deletions packages/core/src/hydration/ObjectHydrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,14 @@ export class ObjectHydrator extends Hydrator {
const convertorKey = path.filter(k => !k.match(/\[idx_\d+]/)).map(k => this.safeKey(k)).join('_');
const ret: string[] = [];
const conds: string[] = [];
context.set(`prototype_${convertorKey}`, prop.embeddable.prototype);

if (prop.targetMeta?.polymorphs) {
prop.targetMeta.polymorphs.forEach(meta => {
context.set(`prototype_${convertorKey}_${meta.className}`, meta.prototype);
});
} else {
context.set(`prototype_${convertorKey}`, prop.embeddable.prototype);
}

if (!this.platform.convertsJsonAutomatically() && (prop.object || prop.array)) {
ret.push(
Expand All @@ -207,7 +214,21 @@ export class ObjectHydrator extends Hydrator {
}

ret.push(` if (${conds.join(' || ')}) {`);
ret.push(` entity${entityKey} = Object.create(prototype_${convertorKey});`);

if (prop.targetMeta?.polymorphs) {
const targetMeta = prop.targetMeta;
targetMeta.polymorphs!.forEach(meta => {
const childProp = prop.embeddedProps[targetMeta.discriminatorColumn!];
const childDataKey = prop.object ? dataKey + this.wrap(childProp.embedded![1]) : this.wrap(childProp.name);
// weak comparison as we can have numbers that might have been converted to strings due to being object keys
ret.push(` if (data${childDataKey} == '${meta.discriminatorValue}') {`);
ret.push(` entity${entityKey} = factory.createEmbeddable('${meta.className}', data${prop.object ? dataKey : ''}, { newEntity, convertCustomTypes });`);
ret.push(` }`);
});
} else {
ret.push(` entity${entityKey} = factory.createEmbeddable('${prop.targetMeta!.className}', data${prop.object ? dataKey : ''}, { newEntity, convertCustomTypes });`);
}

meta.props
.filter(p => p.embedded?.[0] === prop.name)
.forEach(childProp => {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/metadata/EntitySchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,8 @@ export class EntitySchema<T extends AnyEntity<T> = AnyEntity, U extends AnyEntit
if (Utils.isString(options.entity)) {
type = options.type = options.entity;
} else if (options.entity) {
type = options.type = Utils.className(options.entity());
const tmp = options.entity();
type = options.type = Array.isArray(tmp) ? tmp.map(t => Utils.className(t)).sort().join(' | ') : Utils.className(tmp);
}
}

Expand Down
55 changes: 53 additions & 2 deletions packages/core/src/metadata/MetadataDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ export class MetadataDiscovery {

this.discovered
.filter(meta => meta.name)
.forEach(meta => discovered.set(meta.name!, meta));
.forEach(meta => {
this.platform.validateMetadata(meta);
discovered.set(meta.name!, meta);
});

return discovered;
}
Expand All @@ -50,6 +53,7 @@ export class MetadataDiscovery {
for (const meta of discovered) {
let i = 1;
Object.values(meta.properties).forEach(prop => meta.propertyOrder.set(prop.name, i++));
Object.values(meta.properties).forEach(prop => this.initPolyEmbeddables(prop, discovered));
}

// ignore base entities (not annotated with @Entity)
Expand Down Expand Up @@ -604,6 +608,53 @@ export class MetadataDiscovery {
return order;
}

private initPolyEmbeddables(embeddedProp: EntityProperty, discovered: EntityMetadata[], visited = new WeakSet<EntityProperty>()): void {
if (embeddedProp.reference !== ReferenceType.EMBEDDED || visited.has(embeddedProp)) {
return;
}

visited.add(embeddedProp);
const types = embeddedProp.type.split(/ ?\| ?/);
let embeddable = this.discovered.find(m => m.name === embeddedProp.type);
const polymorphs = this.discovered.filter(m => types.includes(m.name!));

// create virtual polymorphic entity
if (!embeddable && polymorphs.length > 0) {
const properties: Dictionary<EntityProperty> = {};
let discriminatorColumn: string | undefined;

const processExtensions = (meta: EntityMetadata) => {
const parent = this.discovered.find(m => meta.extends === m.className);

if (!parent) {
return;
}

discriminatorColumn ??= parent.discriminatorColumn;
Object.values(parent.properties).forEach(prop => properties[prop.name] = prop);
processExtensions(parent);
};

polymorphs.forEach(meta => {
Object.values(meta.properties).forEach(prop => properties[prop.name] = prop);
processExtensions(meta);
});
const name = polymorphs.map(t => t.className).sort().join(' | ');
embeddable = new EntityMetadata({
name,
className: name,
embeddable: true,
abstract: true,
properties,
polymorphs,
discriminatorColumn,
});
embeddable.sync();
discovered.push(embeddable);
polymorphs.forEach(meta => meta.root = embeddable!);
}
}

private initEmbeddables(meta: EntityMetadata, embeddedProp: EntityProperty, visited = new WeakSet<EntityProperty>()): void {
if (embeddedProp.reference !== ReferenceType.EMBEDDED || visited.has(embeddedProp)) {
return;
Expand Down Expand Up @@ -686,7 +737,7 @@ export class MetadataDiscovery {
meta.root.discriminatorMap = {} as Dictionary<string>;
const children = metadata.filter(m => m.root.className === meta.root.className && !m.abstract);
children.forEach(m => {
const name = m.discriminatorValue || this.namingStrategy.classToTableName(m.className);
const name = m.discriminatorValue ?? this.namingStrategy.classToTableName(m.className);
meta.root.discriminatorMap![name] = m.className;
});
}
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/metadata/MetadataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ export abstract class MetadataProvider {
if (Utils.isString(prop.entity)) {
prop.type = prop.entity;
} else if (prop.entity) {
prop.type = Utils.className(prop.entity());
const tmp = prop.entity();
prop.type = Array.isArray(tmp) ? tmp.map(t => Utils.className(t)).sort().join(' | ') : Utils.className(tmp);
} else if (!prop.type) {
await fallback(prop);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/metadata/MetadataValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export class MetadataValidator {

// check for not discovered entities
discovered.forEach(meta => Object.values(meta.properties).forEach(prop => {
if (prop.reference !== ReferenceType.SCALAR && !discovered.find(m => m.className === prop.type)) {
if (prop.reference !== ReferenceType.SCALAR && !prop.type.split(/ ?\| ?/).every(type => discovered.find(m => m.className === type))) {
throw MetadataError.fromUnknownEntity(prop.type, `${meta.className}.${prop.name}`);
}
}));
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/platforms/Platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import clone from 'clone';
import { EntityRepository } from '../entity';
import type { NamingStrategy } from '../naming-strategy';
import { UnderscoreNamingStrategy } from '../naming-strategy';
import type { AnyEntity, Constructor, EntityProperty, IEntityGenerator, IMigrator, IPrimaryKey, ISchemaGenerator, PopulateOptions, Primary } from '../typings';
import type { AnyEntity, Constructor, EntityProperty, IEntityGenerator, IMigrator, IPrimaryKey, ISchemaGenerator, PopulateOptions, Primary, EntityMetadata } from '../typings';
import { ExceptionConverter } from './ExceptionConverter';
import type { EntityManager } from '../EntityManager';
import type { Configuration } from '../utils/Configuration';
Expand Down Expand Up @@ -377,4 +377,8 @@ export abstract class Platform {
return true;
}

validateMetadata(meta: EntityMetadata): void {
return;
}

}
Loading

0 comments on commit 7b7c3a2

Please sign in to comment.