Skip to content

Commit

Permalink
feat(core): add support for polymorphic embeddables
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({ type: String })
  name!: string;

}

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

  @Property({ type: Boolean, nullable: true })
  canMeow = true;

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

}

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

  @Property({ type: Boolean, nullable: true })
  canBark = 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
  • Loading branch information
B4nan committed Nov 16, 2021
1 parent 3fca9a6 commit 1dc27b3
Show file tree
Hide file tree
Showing 15 changed files with 394 additions and 75 deletions.
67 changes: 67 additions & 0 deletions docs/docs/embeddables.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,70 @@ 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
enum AnimalType {
CAT,
DOG,
}

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

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

@Property({ type: String })
name!: string;

}

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

@Property({ type: Boolean, nullable: true })
canMeow = true;

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

}

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

@Property({ type: Boolean, nullable: true })
canBark = 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;

}
```
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
2 changes: 1 addition & 1 deletion packages/core/src/entity/EntityFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,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
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.create('${meta.className}', data${prop.object ? dataKey : ''}, { newEntity, convertCustomTypes });`);
ret.push(` }`);
});
} else {
ret.push(` entity${entityKey} = factory.create('${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
50 changes: 49 additions & 1 deletion packages/core/src/metadata/MetadataDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,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 +605,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 +734,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
4 changes: 3 additions & 1 deletion packages/core/src/typings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ export interface EntityProperty<T extends AnyEntity<T> = any> {
embedded?: [string, string];
embeddable: Constructor<T>;
embeddedProps: Dictionary<EntityProperty>;
discriminatorColumn?: string; // only for poly embeddables currently
object?: boolean;
index?: boolean | string;
unique?: boolean | string;
Expand Down Expand Up @@ -344,7 +345,7 @@ export interface EntityMetadata<T extends AnyEntity<T> = any> {
schema?: string;
pivotTable: boolean;
discriminatorColumn?: string;
discriminatorValue?: string;
discriminatorValue?: number | string;
discriminatorMap?: Dictionary<string>;
embeddable: boolean;
constructorParams: string[];
Expand Down Expand Up @@ -374,6 +375,7 @@ export interface EntityMetadata<T extends AnyEntity<T> = any> {
comment?: string;
selfReferencing?: boolean;
readonly?: boolean;
polymorphs?: EntityMetadata[];
root: EntityMetadata<T>;
}

Expand Down
Loading

0 comments on commit 1dc27b3

Please sign in to comment.