Skip to content

Commit

Permalink
feat(core): add support for single table inheritance (#503)
Browse files Browse the repository at this point in the history
```typescript
@entity({
  discriminatorColumn: 'discr',
  discriminatorMap: { person: 'Person', employee: 'Employee' },
})
export class Person {
  // ...
}

@entity()
export class Employee extends Person {
  // ...
}
```

Closes #33
  • Loading branch information
B4nan committed Aug 9, 2020
1 parent f3aa9cd commit 8c45339
Show file tree
Hide file tree
Showing 25 changed files with 622 additions and 26 deletions.
4 changes: 3 additions & 1 deletion docs/docs/defining-entities.md
Expand Up @@ -363,7 +363,9 @@ add any suffix behind the dot, not just `.model.ts` or `.entity.ts`.
## Using BaseEntity

You can define your own base entity with properties that you require on all entities, like
primary key and created/updated time.
primary key and created/updated time. Single table inheritance is also supported.

Read more about this topic in [Inheritance Mapping](inheritance-mapping.md) section.

> If you are initializing the ORM via `entities` option, you need to specify all your
> base entities as well.
Expand Down
138 changes: 138 additions & 0 deletions docs/docs/inheritance-mapping.md
@@ -0,0 +1,138 @@
---
title: Inheritance Mapping
---

# Mapped Superclasses

A mapped superclass is an abstract or concrete class that provides persistent entity state and
mapping information for its subclasses, but which is not itself an entity. Typically, the purpose
of such a mapped superclass is to define state and mapping information that is common to multiple
entity classes.

Mapped superclasses, just as regular, non-mapped classes, can appear in the middle of an otherwise
mapped inheritance hierarchy (through Single Table Inheritance).

> A mapped superclass cannot be an entity, it is not query-able and persistent relationships defined
> by a mapped superclass must be unidirectional (with an owning side only). This means that One-To-Many
> associations are not possible on a mapped superclass at all. Furthermore Many-To-Many associations
> are only possible if the mapped superclass is only used in exactly one entity at the moment. For
> further support of inheritance, the single table inheritance features have to be used.
```typescript
// do not use @Entity decorator on base classes
export abstract class Person {

@Property()
mapped1!: number;

@Property()
mapped2!: string;

@OneToOne()
toothbrush!: Toothbrush;

// ... more fields and methods
}

@Entity()
export class Employee extends Person {

@PrimaryKey()
id!: number;

@Property()
name!: string;

// ... more fields and methods

}

@Entity()
export class Toothbrush {

@PrimaryKey()
id!: number;

// ... more fields and methods

}
```

The DDL for the corresponding database schema would look something like this (this is for SQLite):

```sql
create table `employee` (
`id` int unsigned not null auto_increment primary key,
`name` varchar(255) not null, `mapped1` integer not null,
`mapped2` varchar(255) not null,
`toothbrush_id` integer not null
);
```

As you can see from this DDL snippet, there is only a single table for the entity
subclass. All the mappings from the mapped superclass were inherited to the subclass
as if they had been defined on that class directly.

# Single Table Inheritance

> Support for STI was added in version 4.0
[Single Table Inheritance](https://martinfowler.com/eaaCatalog/singleTableInheritance.html)
is an inheritance mapping strategy where all classes of a hierarchy are mapped to a single
database table. In order to distinguish which row represents which type in the hierarchy
a so-called discriminator column is used.

```typescript
@Entity({
discriminatorColumn: 'discr',
discriminatorMap: { person: 'Person', employee: 'Employee' },
})
export class Person {
// ...
}

@Entity()
export class Employee extends Person {
// ...
}
```

Things to note:

- The `discriminatorColumn` option must be specified on the topmost class that is
part of the mapped entity hierarchy.
- The `discriminatorMap` specifies which values of the discriminator column identify
a row as being of a certain type. In the case above a value of `person` identifies
a row as being of type `Person` and `employee` identifies a row as being of type
`Employee`.
- All entity classes that is part of the mapped entity hierarchy (including the topmost
class) should be specified in the `discriminatorMap`. In the case above `Person` class
included.
- If no discriminator map is provided, then the map is generated automatically.
The automatically generated discriminator map contains the table names that would be
otherwise used in case of regular entities.

## Design-time considerations

This mapping approach works well when the type hierarchy is fairly simple and stable.
Adding a new type to the hierarchy and adding fields to existing supertypes simply
involves adding new columns to the table, though in large deployments this may have
an adverse impact on the index and column layout inside the database.

## Performance impact

This strategy is very efficient for querying across all types in the hierarchy or
for specific types. No table joins are required, only a WHERE clause listing the
type identifiers. In particular, relationships involving types that employ this
mapping strategy are very performing.

## SQL Schema considerations

For Single-Table-Inheritance to work in scenarios where you are using either a legacy
database schema or a self-written database schema you have to make sure that all
columns that are not in the root entity but in any of the different sub-entities
has to allow null values. Columns that have NOT NULL constraints have to be on the
root entity of the single-table inheritance hierarchy.

> This part of documentation is highly inspired by [doctrine docs](https://www.doctrine-project.org/projects/doctrine-orm/en/latest/reference/inheritance-mapping.html)
> as the behaviour here is pretty much the same.
1 change: 1 addition & 0 deletions docs/sidebars.js
Expand Up @@ -31,6 +31,7 @@ module.exports = {
'naming-strategy',
'custom-types',
'entity-schema',
'inheritance-mapping',
'metadata-providers',
'metadata-cache',
'debugging',
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/decorators/Entity.ts
@@ -1,7 +1,7 @@
import { MetadataStorage } from '../metadata';
import { EntityRepository } from '../entity';
import { Utils } from '../utils';
import { AnyEntity, Constructor } from '../typings';
import { AnyEntity, Constructor, Dictionary } from '../typings';

export function Entity(options: EntityOptions<any> = {}): Function {
return function <T extends { new(...args: any[]): AnyEntity<T> }>(target: T) {
Expand All @@ -17,5 +17,8 @@ export function Entity(options: EntityOptions<any> = {}): Function {
export type EntityOptions<T extends AnyEntity<T>> = {
tableName?: string;
collection?: string;
discriminatorColumn?: string;
discriminatorMap?: Dictionary<string>;
discriminatorValue?: string;
customRepository?: () => Constructor<EntityRepository<T>>;
};
5 changes: 5 additions & 0 deletions packages/core/src/entity/EntityAssigner.ts
Expand Up @@ -16,6 +16,7 @@ export class EntityAssigner {
const options = (typeof onlyProperties === 'boolean' ? { onlyProperties } : onlyProperties);
const em = options.em || wrap(entity).__em;
const meta = wrap(entity).__internal.metadata.get(entity.constructor.name);
const root = Utils.getRootEntity(wrap(entity).__internal.metadata, meta);
const validator = wrap(entity).__internal.validator;
const platform = wrap(entity).__internal.platform;
const props = meta.properties;
Expand All @@ -25,6 +26,10 @@ export class EntityAssigner {
return;
}

if (props[prop]?.inherited || root.discriminatorColumn === prop) {
return;
}

let value = data[prop as keyof EntityData<T>];

if (props[prop] && props[prop].customType && !Utils.isEntity(data)) {
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/entity/EntityFactory.ts
Expand Up @@ -63,7 +63,16 @@ export class EntityFactory {
}

private createEntity<T extends AnyEntity<T>>(data: EntityData<T>, meta: EntityMetadata<T>): T {
const Entity = this.metadata.get<T>(meta.name).class;
const root = Utils.getRootEntity(this.metadata, meta);

if (root.discriminatorColumn) {
const value = data[root.discriminatorColumn];
delete data[root.discriminatorColumn];
const type = root.discriminatorMap![value];
meta = type ? this.metadata.get(type) : meta;
}

const Entity = meta.class;
const pks = Utils.getOrderedPrimaryKeys<T>(data, meta);

if (meta.primaryKeys.some(pk => !Utils.isDefined(data[pk as keyof T], true))) {
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/entity/EntityValidator.ts
Expand Up @@ -28,7 +28,11 @@ export class EntityValidator {
return;
}

payload[prop] = entity[prop as keyof T] = this.validateProperty(property, payload[prop], entity);
payload[prop] = this.validateProperty(property, payload[prop], entity);

if (entity[prop]) {
entity[prop] = payload[prop];
}
});
}

Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/hydration/Hydrator.ts
@@ -1,4 +1,4 @@
import { EntityManager } from '..';
import { EntityManager, Utils } from '..';
import { AnyEntity, EntityData, EntityMetadata, EntityProperty } from '../typings';
import { EntityFactory } from '../entity';

Expand All @@ -8,7 +8,14 @@ export abstract class Hydrator {
protected readonly em: EntityManager) { }

hydrate<T extends AnyEntity<T>>(entity: T, meta: EntityMetadata<T>, data: EntityData<T>, newEntity: boolean): void {
for (const prop of Object.values<EntityProperty>(meta.properties)) {
const metadata = this.em.getMetadata();
const root = Utils.getRootEntity(metadata, meta);

if (root.discriminatorColumn) {
meta = metadata.get(entity.constructor.name);
}

for (const prop of Object.values<EntityProperty>(meta.properties).filter(prop => !prop.inherited && root.discriminatorColumn !== prop.name)) {
this.hydrateProperty(entity, prop, data[prop.name], newEntity);
}
}
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/metadata/EntitySchema.ts
Expand Up @@ -32,8 +32,12 @@ export class EntitySchema<T extends AnyEntity<T> = AnyEntity, U extends AnyEntit

constructor(meta: Metadata<T, U> | EntityMetadata<T>, internal = false) {
meta.name = meta.class ? meta.class.name : meta.name;
Utils.renameKey(meta, 'tableName', 'collection');
meta.tableName = meta.collection;

if (meta.tableName || meta.collection) {
Utils.renameKey(meta, 'tableName', 'collection');
meta.tableName = meta.collection;
}

Object.assign(this._meta, { className: meta.name, properties: {}, hooks: {}, indexes: [], uniques: [] }, meta);
this.internal = internal;
}
Expand Down
71 changes: 68 additions & 3 deletions packages/core/src/metadata/MetadataDiscovery.ts
Expand Up @@ -32,6 +32,7 @@ export class MetadataDiscovery {

// ignore base entities (not annotated with @Entity)
const filtered = this.discovered.filter(meta => meta.name);
filtered.forEach(meta => this.initSingleTableInheritance(meta));
filtered.forEach(meta => this.defineBaseEntityProperties(meta));
filtered.forEach(meta => this.metadata.set(meta.className, new EntitySchema(meta, true).init().meta));
filtered.forEach(meta => this.defineBaseEntityProperties(meta));
Expand Down Expand Up @@ -167,7 +168,9 @@ export class MetadataDiscovery {
}

if (!meta.collection && meta.name) {
meta.collection = this.namingStrategy.classToTableName(meta.name);
const root = Utils.getRootEntity(this.metadata, meta);
const entityName = root.discriminatorColumn ? root.name : meta.name;
meta.collection = this.namingStrategy.classToTableName(entityName);
}

await this.saveToCache(meta);
Expand Down Expand Up @@ -475,9 +478,71 @@ export class MetadataDiscovery {
}

Object.keys(base.hooks).forEach(type => {
meta.hooks[type] = meta.hooks[type] || [];
meta.hooks[type].unshift(...base.hooks[type]);
meta.hooks[type] = Utils.unique([...base.hooks[type], ...(meta.hooks[type] || [])]);
});

if (meta.constructorParams.length === 0 && base.constructorParams.length > 0) {
meta.constructorParams = [...base.constructorParams];
}

if (meta.toJsonParams.length === 0 && base.toJsonParams.length > 0) {
meta.toJsonParams = [...base.toJsonParams];
}
}

private initSingleTableInheritance(meta: EntityMetadata): void {
const root = Utils.getRootEntity(this.metadata, meta);

if (!root.discriminatorColumn) {
return;
}

if (!root.discriminatorMap) {
root.discriminatorMap = {} as Dictionary<string>;
const children = Object.values(this.metadata.getAll()).filter(m => Utils.getRootEntity(this.metadata, m) === root);
children.forEach(m => {
const name = m.discriminatorValue || this.namingStrategy.classToTableName(m.className);
root.discriminatorMap![name] = m.className;
});
}

meta.discriminatorValue = Object.entries(root.discriminatorMap!).find(([, className]) => className === meta.className)?.[0];

if (!root.properties[root.discriminatorColumn]) {
root.properties[root.discriminatorColumn] = this.createDiscriminatorProperty(root);
}

if (root === meta) {
return;
}

Object.values(meta.properties).forEach(prop => {
const exists = root.properties[prop.name];
root.properties[prop.name] = Utils.copy(prop);
root.properties[prop.name].nullable = true;

if (!exists) {
root.properties[prop.name].inherited = true;
}
});

root.indexes = Utils.unique([...root.indexes, ...meta.indexes]);
root.uniques = Utils.unique([...root.uniques, ...meta.uniques]);
}

private createDiscriminatorProperty(meta: EntityMetadata): EntityProperty {
const prop = {
name: meta.discriminatorColumn!,
type: 'string',
enum: true,
index: true,
reference: ReferenceType.SCALAR,
items: Object.keys(meta.discriminatorMap!),
} as EntityProperty;
this.initFieldName(prop);
this.initColumnType(prop);

return prop;
}

private getDefaultVersionValue(prop: EntityProperty): any {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/typings.ts
Expand Up @@ -103,6 +103,7 @@ export interface EntityProperty<T extends AnyEntity<T> = any> {
customType: Type;
primary: boolean;
serializedPrimaryKey: boolean;
discriminator?: boolean;
length?: any;
reference: ReferenceType;
wrappedReference?: boolean;
Expand All @@ -111,6 +112,7 @@ export interface EntityProperty<T extends AnyEntity<T> = any> {
index?: boolean | string;
unique?: boolean | string;
nullable?: boolean;
inherited?: boolean;
unsigned: boolean;
persist?: boolean;
hidden?: boolean;
Expand Down Expand Up @@ -147,6 +149,9 @@ export interface EntityMetadata<T extends AnyEntity<T> = any> {
className: string;
tableName: string;
pivotTable: boolean;
discriminatorColumn?: string;
discriminatorValue?: string;
discriminatorMap?: Dictionary<string>;
constructorParams: string[];
toJsonParams: string[];
extends: string;
Expand Down

0 comments on commit 8c45339

Please sign in to comment.