diff --git a/docs/docs/defining-entities.md b/docs/docs/defining-entities.md index 8e8ccb1a5d1b..441ee7f2f748 100644 --- a/docs/docs/defining-entities.md +++ b/docs/docs/defining-entities.md @@ -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. diff --git a/docs/docs/inheritance-mapping.md b/docs/docs/inheritance-mapping.md new file mode 100644 index 000000000000..bebb969c6ed4 --- /dev/null +++ b/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. diff --git a/docs/sidebars.js b/docs/sidebars.js index 9aa417832dec..e5c3ff91cb63 100755 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -31,6 +31,7 @@ module.exports = { 'naming-strategy', 'custom-types', 'entity-schema', + 'inheritance-mapping', 'metadata-providers', 'metadata-cache', 'debugging', diff --git a/packages/core/src/decorators/Entity.ts b/packages/core/src/decorators/Entity.ts index 85c4633ad6cd..93ac4b7990bc 100644 --- a/packages/core/src/decorators/Entity.ts +++ b/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 = {}): Function { return function }>(target: T) { @@ -17,5 +17,8 @@ export function Entity(options: EntityOptions = {}): Function { export type EntityOptions> = { tableName?: string; collection?: string; + discriminatorColumn?: string; + discriminatorMap?: Dictionary; + discriminatorValue?: string; customRepository?: () => Constructor>; }; diff --git a/packages/core/src/entity/EntityAssigner.ts b/packages/core/src/entity/EntityAssigner.ts index f5aa2511514d..eeca8eeeda11 100644 --- a/packages/core/src/entity/EntityAssigner.ts +++ b/packages/core/src/entity/EntityAssigner.ts @@ -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; @@ -25,6 +26,10 @@ export class EntityAssigner { return; } + if (props[prop]?.inherited || root.discriminatorColumn === prop) { + return; + } + let value = data[prop as keyof EntityData]; if (props[prop] && props[prop].customType && !Utils.isEntity(data)) { diff --git a/packages/core/src/entity/EntityFactory.ts b/packages/core/src/entity/EntityFactory.ts index 747a0228b380..cfaa24315306 100644 --- a/packages/core/src/entity/EntityFactory.ts +++ b/packages/core/src/entity/EntityFactory.ts @@ -63,7 +63,16 @@ export class EntityFactory { } private createEntity>(data: EntityData, meta: EntityMetadata): T { - const Entity = this.metadata.get(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(data, meta); if (meta.primaryKeys.some(pk => !Utils.isDefined(data[pk as keyof T], true))) { diff --git a/packages/core/src/entity/EntityValidator.ts b/packages/core/src/entity/EntityValidator.ts index 032d866c9eae..60c68146c78b 100644 --- a/packages/core/src/entity/EntityValidator.ts +++ b/packages/core/src/entity/EntityValidator.ts @@ -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]; + } }); } diff --git a/packages/core/src/hydration/Hydrator.ts b/packages/core/src/hydration/Hydrator.ts index ce353a9ece07..7cbc0922e261 100644 --- a/packages/core/src/hydration/Hydrator.ts +++ b/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'; @@ -8,7 +8,14 @@ export abstract class Hydrator { protected readonly em: EntityManager) { } hydrate>(entity: T, meta: EntityMetadata, data: EntityData, newEntity: boolean): void { - for (const prop of Object.values(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(meta.properties).filter(prop => !prop.inherited && root.discriminatorColumn !== prop.name)) { this.hydrateProperty(entity, prop, data[prop.name], newEntity); } } diff --git a/packages/core/src/metadata/EntitySchema.ts b/packages/core/src/metadata/EntitySchema.ts index 12706607296b..3469e061d114 100644 --- a/packages/core/src/metadata/EntitySchema.ts +++ b/packages/core/src/metadata/EntitySchema.ts @@ -32,8 +32,12 @@ export class EntitySchema = AnyEntity, U extends AnyEntit constructor(meta: Metadata | EntityMetadata, 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; } diff --git a/packages/core/src/metadata/MetadataDiscovery.ts b/packages/core/src/metadata/MetadataDiscovery.ts index 7b3d277ac1ef..87768574c4c0 100644 --- a/packages/core/src/metadata/MetadataDiscovery.ts +++ b/packages/core/src/metadata/MetadataDiscovery.ts @@ -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)); @@ -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); @@ -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; + 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 { diff --git a/packages/core/src/typings.ts b/packages/core/src/typings.ts index 9abaa57ea829..ee4205a45fdf 100644 --- a/packages/core/src/typings.ts +++ b/packages/core/src/typings.ts @@ -103,6 +103,7 @@ export interface EntityProperty = any> { customType: Type; primary: boolean; serializedPrimaryKey: boolean; + discriminator?: boolean; length?: any; reference: ReferenceType; wrappedReference?: boolean; @@ -111,6 +112,7 @@ export interface EntityProperty = any> { index?: boolean | string; unique?: boolean | string; nullable?: boolean; + inherited?: boolean; unsigned: boolean; persist?: boolean; hidden?: boolean; @@ -147,6 +149,9 @@ export interface EntityMetadata = any> { className: string; tableName: string; pivotTable: boolean; + discriminatorColumn?: string; + discriminatorValue?: string; + discriminatorMap?: Dictionary; constructorParams: string[]; toJsonParams: string[]; extends: string; diff --git a/packages/core/src/unit-of-work/UnitOfWork.ts b/packages/core/src/unit-of-work/UnitOfWork.ts index 3f59e4b04247..9c0a75ca9d62 100644 --- a/packages/core/src/unit-of-work/UnitOfWork.ts +++ b/packages/core/src/unit-of-work/UnitOfWork.ts @@ -40,7 +40,8 @@ export class UnitOfWork { return; } - this.identityMap[`${wrapped.constructor.name}-${wrapped.__serializedPrimaryKey}`] = wrapped; + const root = Utils.getRootEntity(this.metadata, wrapped.__meta); + this.identityMap[`${root.name}-${wrapped.__serializedPrimaryKey}`] = wrapped; if (mergeData || !this.originalEntityData[wrapped.__uuid]) { this.originalEntityData[wrapped.__uuid] = Utils.prepareEntity(entity, this.metadata, this.platform); @@ -53,8 +54,9 @@ export class UnitOfWork { * Returns entity from the identity map. For composite keys, you need to pass an array of PKs in the same order as they are defined in `meta.primaryKeys`. */ getById>(entityName: string, id: Primary | Primary[]): T { + const root = Utils.getRootEntity(this.metadata, this.metadata.get(entityName)); const hash = Utils.getPrimaryKeyHash(Utils.asArray(id) as string[]); - const token = `${entityName}-${hash}`; + const token = `${root.name}-${hash}`; return this.identityMap[token] as T; } @@ -151,7 +153,8 @@ export class UnitOfWork { } unsetIdentity(entity: AnyEntity): void { - delete this.identityMap[`${entity.constructor.name}-${wrap(entity).__serializedPrimaryKey}`]; + const root = Utils.getRootEntity(this.metadata, wrap(entity).__meta); + delete this.identityMap[`${root.name}-${wrap(entity).__serializedPrimaryKey}`]; delete this.identifierMap[wrap(entity).__uuid]; delete this.originalEntityData[wrap(entity).__uuid]; } diff --git a/packages/core/src/utils/Utils.ts b/packages/core/src/utils/Utils.ts index 5b04385f97c9..707b6b97ced8 100644 --- a/packages/core/src/utils/Utils.ts +++ b/packages/core/src/utils/Utils.ts @@ -86,6 +86,22 @@ export class Utils { return Utils.merge(target, ...sources); } + static getRootEntity(metadata: MetadataStorage, meta: EntityMetadata): EntityMetadata { + const base = meta.extends && metadata.get(meta.extends, false, false); + + if (!base || base === meta) { // make sure we do not fall into infinite loop + return meta; + } + + const root = Utils.getRootEntity(metadata, base); + + if (root.discriminatorColumn) { + return root; + } + + return meta; + } + /** * Computes difference between two objects, ignoring items missing in `b`. */ @@ -120,11 +136,16 @@ export class Utils { } const meta = metadata.get(entity.constructor.name); + const root = Utils.getRootEntity(metadata, meta); const ret = {} as EntityData; + if (meta.discriminatorValue) { + ret[root.discriminatorColumn as keyof T] = meta.discriminatorValue as unknown as T[keyof T]; + } + // copy all props, ignore collections and references, process custom types Object.values>(meta.properties).forEach(prop => { - if (Utils.shouldIgnoreProperty(entity, prop)) { + if (Utils.shouldIgnoreProperty(entity, prop, root)) { return; } @@ -148,7 +169,7 @@ export class Utils { return ret; } - private static shouldIgnoreProperty(entity: T, prop: EntityProperty) { + private static shouldIgnoreProperty(entity: T, prop: EntityProperty, root: EntityMetadata) { if (!(prop.name in entity) || prop.persist === false) { return true; } @@ -157,12 +178,13 @@ export class Utils { const noPkRef = Utils.isEntity(entity[prop.name]) && !wrap(entity[prop.name]).__primaryKeys.every(pk => pk); const noPkProp = prop.primary && !Utils.isDefined(entity[prop.name], true); const inverse = prop.reference === ReferenceType.ONE_TO_ONE && !prop.owner; + const discriminator = prop.name === root.discriminatorColumn; // bidirectional 1:1 and m:1 fields are defined as setters, we need to check for `undefined` explicitly const isSetter = [ReferenceType.ONE_TO_ONE, ReferenceType.MANY_TO_ONE].includes(prop.reference) && (prop.inversedBy || prop.mappedBy); const emptyRef = isSetter && entity[prop.name] === undefined; - return collection || noPkProp || noPkRef || inverse || emptyRef; + return collection || noPkProp || noPkRef || inverse || discriminator || emptyRef; } /** diff --git a/packages/knex/src/schema/SchemaGenerator.ts b/packages/knex/src/schema/SchemaGenerator.ts index 52ee0e64e7ad..d3335c68edbd 100644 --- a/packages/knex/src/schema/SchemaGenerator.ts +++ b/packages/knex/src/schema/SchemaGenerator.ts @@ -42,13 +42,14 @@ export class SchemaGenerator { } async getCreateSchemaSQL(wrap = true): Promise { + const metadata = Object.values(this.metadata.getAll()).filter(meta => !meta.discriminatorValue); let ret = ''; - for (const meta of Object.values(this.metadata.getAll())) { + for (const meta of metadata) { ret += this.dump(this.createTable(meta)); } - for (const meta of Object.values(this.metadata.getAll())) { + for (const meta of metadata) { ret += this.dump(this.knex.schema.alterTable(meta.collection, table => this.createForeignKeys(table, meta))); } @@ -68,7 +69,7 @@ export class SchemaGenerator { async getDropSchemaSQL(wrap = true, dropMigrationsTable = false): Promise { let ret = ''; - for (const meta of Object.values(this.metadata.getAll())) { + for (const meta of Object.values(this.metadata.getAll()).filter(meta => !meta.discriminatorValue)) { ret += this.dump(this.dropTable(meta.collection), '\n'); } @@ -85,14 +86,15 @@ export class SchemaGenerator { } async getUpdateSchemaSQL(wrap = true, safe = false, dropTables = true): Promise { + const metadata = Object.values(this.metadata.getAll()).filter(meta => !meta.discriminatorValue); const schema = await DatabaseSchema.create(this.connection, this.helper, this.config); let ret = ''; - for (const meta of Object.values(this.metadata.getAll())) { + for (const meta of metadata) { ret += this.getUpdateTableSQL(meta, schema, safe); } - for (const meta of Object.values(this.metadata.getAll())) { + for (const meta of metadata) { ret += this.getUpdateTableFKsSQL(meta, schema); } @@ -100,7 +102,7 @@ export class SchemaGenerator { return this.wrapSchema(ret, wrap); } - const definedTables = Object.values(this.metadata.getAll()).map(meta => meta.collection); + const definedTables = metadata.map(meta => meta.collection); const remove = schema.getTables().filter(table => !definedTables.includes(table.name)); for (const table of remove) { diff --git a/tests/__snapshots__/EntityGenerator.test.ts.snap b/tests/__snapshots__/EntityGenerator.test.ts.snap index 4ce8e909fa93..9140c0eca14f 100644 --- a/tests/__snapshots__/EntityGenerator.test.ts.snap +++ b/tests/__snapshots__/EntityGenerator.test.ts.snap @@ -97,6 +97,41 @@ export class AuthorToFriend { @ManyToOne({ entity: () => Author2, fieldName: 'author2_2_id', cascade: [Cascade.ALL], primary: true, index: 'author_to_friend_author2_2_id_index' }) author22!: Author2; +} +", + "import { Entity, Index, ManyToOne, OneToOne, PrimaryKey, Property } from 'mikro-orm'; + +@Entity() +export class BaseUser2 { + + @PrimaryKey() + id!: number; + + @Property({ length: 100 }) + firstName!: string; + + @Property({ length: 100 }) + lastName!: string; + + @Index({ name: 'base_user2_type_index' }) + @Property({ columnType: 'enum' }) + type!: string; + + @Property({ nullable: true }) + employeeProp?: number; + + @Property({ length: 255, nullable: true }) + managerProp?: string; + + @Property({ length: 255, nullable: true }) + ownerProp?: string; + + @ManyToOne({ entity: () => BaseUser2, nullable: true, index: 'base_user2_favourite_employee_id_index' }) + favouriteEmployee?: BaseUser2; + + @OneToOne({ entity: () => BaseUser2, nullable: true, index: 'base_user2_favourite_manager_id_index', unique: 'base_user2_favourite_manager_id_unique' }) + favouriteManager?: BaseUser2; + } ", "import { Cascade, Entity, ManyToOne, PrimaryKey, Property } from 'mikro-orm'; diff --git a/tests/__snapshots__/SchemaGenerator.test.ts.snap b/tests/__snapshots__/SchemaGenerator.test.ts.snap index 70e9ff3110d7..0473c1c1d16f 100644 --- a/tests/__snapshots__/SchemaGenerator.test.ts.snap +++ b/tests/__snapshots__/SchemaGenerator.test.ts.snap @@ -65,6 +65,12 @@ alter table \`user2\` add index \`user2_first_name_index\`(\`first_name\`); alter table \`user2\` add index \`user2_last_name_index\`(\`last_name\`); alter table \`user2\` add primary key \`user2_pkey\`(\`first_name\`, \`last_name\`); +create table \`base_user2\` (\`id\` int unsigned not null auto_increment primary key, \`first_name\` varchar(100) not null, \`last_name\` varchar(100) not null, \`type\` enum('employee', 'manager', 'owner') not null, \`employee_prop\` int(11) null, \`manager_prop\` varchar(255) null, \`owner_prop\` varchar(255) null, \`favourite_employee_id\` int(11) unsigned null, \`favourite_manager_id\` int(11) unsigned null) default character set utf8mb4 engine = InnoDB; +alter table \`base_user2\` add index \`base_user2_type_index\`(\`type\`); +alter table \`base_user2\` add index \`base_user2_favourite_employee_id_index\`(\`favourite_employee_id\`); +alter table \`base_user2\` add index \`base_user2_favourite_manager_id_index\`(\`favourite_manager_id\`); +alter table \`base_user2\` add unique \`base_user2_favourite_manager_id_unique\`(\`favourite_manager_id\`); + create table \`author_to_friend\` (\`author2_1_id\` int(11) unsigned not null, \`author2_2_id\` int(11) unsigned not null) default character set utf8mb4 engine = InnoDB; alter table \`author_to_friend\` add index \`author_to_friend_author2_1_id_index\`(\`author2_1_id\`); alter table \`author_to_friend\` add index \`author_to_friend_author2_2_id_index\`(\`author2_2_id\`); @@ -114,6 +120,9 @@ alter table \`configuration2\` add constraint \`configuration2_test_id_foreign\` alter table \`car_owner2\` add constraint \`car_owner2_car_name_foreign\` foreign key (\`car_name\`) references \`car2\` (\`name\`) on update cascade; alter table \`car_owner2\` add constraint \`car_owner2_car_year_foreign\` foreign key (\`car_year\`) references \`car2\` (\`year\`) on update cascade; +alter table \`base_user2\` add constraint \`base_user2_favourite_employee_id_foreign\` foreign key (\`favourite_employee_id\`) references \`base_user2\` (\`id\`) on update cascade on delete set null; +alter table \`base_user2\` add constraint \`base_user2_favourite_manager_id_foreign\` foreign key (\`favourite_manager_id\`) references \`base_user2\` (\`id\`) on update cascade on delete set null; + alter table \`author_to_friend\` add constraint \`author_to_friend_author2_1_id_foreign\` foreign key (\`author2_1_id\`) references \`author2\` (\`id\`) on update cascade on delete cascade; alter table \`author_to_friend\` add constraint \`author_to_friend_author2_2_id_foreign\` foreign key (\`author2_2_id\`) references \`author2\` (\`id\`) on update cascade on delete cascade; @@ -155,6 +164,7 @@ drop table if exists \`configuration2\`; drop table if exists \`car2\`; drop table if exists \`car_owner2\`; drop table if exists \`user2\`; +drop table if exists \`base_user2\`; drop table if exists \`author_to_friend\`; drop table if exists \`author2_to_author2\`; drop table if exists \`book2_to_book_tag2\`; @@ -183,6 +193,7 @@ drop table if exists \`configuration2\`; drop table if exists \`car2\`; drop table if exists \`car_owner2\`; drop table if exists \`user2\`; +drop table if exists \`base_user2\`; drop table if exists \`author_to_friend\`; drop table if exists \`author2_to_author2\`; drop table if exists \`book2_to_book_tag2\`; @@ -251,6 +262,12 @@ alter table \`user2\` add index \`user2_first_name_index\`(\`first_name\`); alter table \`user2\` add index \`user2_last_name_index\`(\`last_name\`); alter table \`user2\` add primary key \`user2_pkey\`(\`first_name\`, \`last_name\`); +create table \`base_user2\` (\`id\` int unsigned not null auto_increment primary key, \`first_name\` varchar(100) not null, \`last_name\` varchar(100) not null, \`type\` enum('employee', 'manager', 'owner') not null, \`employee_prop\` int(11) null, \`manager_prop\` varchar(255) null, \`owner_prop\` varchar(255) null, \`favourite_employee_id\` int(11) unsigned null, \`favourite_manager_id\` int(11) unsigned null) default character set utf8mb4 engine = InnoDB; +alter table \`base_user2\` add index \`base_user2_type_index\`(\`type\`); +alter table \`base_user2\` add index \`base_user2_favourite_employee_id_index\`(\`favourite_employee_id\`); +alter table \`base_user2\` add index \`base_user2_favourite_manager_id_index\`(\`favourite_manager_id\`); +alter table \`base_user2\` add unique \`base_user2_favourite_manager_id_unique\`(\`favourite_manager_id\`); + create table \`author_to_friend\` (\`author2_1_id\` int(11) unsigned not null, \`author2_2_id\` int(11) unsigned not null) default character set utf8mb4 engine = InnoDB; alter table \`author_to_friend\` add index \`author_to_friend_author2_1_id_index\`(\`author2_1_id\`); alter table \`author_to_friend\` add index \`author_to_friend_author2_2_id_index\`(\`author2_2_id\`); @@ -300,6 +317,9 @@ alter table \`configuration2\` add constraint \`configuration2_test_id_foreign\` alter table \`car_owner2\` add constraint \`car_owner2_car_name_foreign\` foreign key (\`car_name\`) references \`car2\` (\`name\`) on update cascade; alter table \`car_owner2\` add constraint \`car_owner2_car_year_foreign\` foreign key (\`car_year\`) references \`car2\` (\`year\`) on update cascade; +alter table \`base_user2\` add constraint \`base_user2_favourite_employee_id_foreign\` foreign key (\`favourite_employee_id\`) references \`base_user2\` (\`id\`) on update cascade on delete set null; +alter table \`base_user2\` add constraint \`base_user2_favourite_manager_id_foreign\` foreign key (\`favourite_manager_id\`) references \`base_user2\` (\`id\`) on update cascade on delete set null; + alter table \`author_to_friend\` add constraint \`author_to_friend_author2_1_id_foreign\` foreign key (\`author2_1_id\`) references \`author2\` (\`id\`) on update cascade on delete cascade; alter table \`author_to_friend\` add constraint \`author_to_friend_author2_2_id_foreign\` foreign key (\`author2_2_id\`) references \`author2\` (\`id\`) on update cascade on delete cascade; @@ -914,6 +934,12 @@ alter table \`user2\` add index \`user2_first_name_index\`(\`first_name\`); alter table \`user2\` add index \`user2_last_name_index\`(\`last_name\`); alter table \`user2\` add primary key \`user2_pkey\`(\`first_name\`, \`last_name\`); +create table \`base_user2\` (\`id\` int unsigned not null auto_increment primary key, \`first_name\` varchar(100) not null, \`last_name\` varchar(100) not null, \`type\` enum('employee', 'manager', 'owner') not null, \`employee_prop\` int(11) null, \`manager_prop\` varchar(255) null, \`owner_prop\` varchar(255) null, \`favourite_employee_id\` int(11) unsigned null, \`favourite_manager_id\` int(11) unsigned null) default character set utf8mb4 engine = InnoDB; +alter table \`base_user2\` add index \`base_user2_type_index\`(\`type\`); +alter table \`base_user2\` add index \`base_user2_favourite_employee_id_index\`(\`favourite_employee_id\`); +alter table \`base_user2\` add index \`base_user2_favourite_manager_id_index\`(\`favourite_manager_id\`); +alter table \`base_user2\` add unique \`base_user2_favourite_manager_id_unique\`(\`favourite_manager_id\`); + create table \`author_to_friend\` (\`author2_1_id\` int(11) unsigned not null, \`author2_2_id\` int(11) unsigned not null) default character set utf8mb4 engine = InnoDB; alter table \`author_to_friend\` add index \`author_to_friend_author2_1_id_index\`(\`author2_1_id\`); alter table \`author_to_friend\` add index \`author_to_friend_author2_2_id_index\`(\`author2_2_id\`); @@ -963,6 +989,9 @@ alter table \`configuration2\` add constraint \`configuration2_test_id_foreign\` alter table \`car_owner2\` add constraint \`car_owner2_car_name_foreign\` foreign key (\`car_name\`) references \`car2\` (\`name\`) on update cascade; alter table \`car_owner2\` add constraint \`car_owner2_car_year_foreign\` foreign key (\`car_year\`) references \`car2\` (\`year\`) on update cascade; +alter table \`base_user2\` add constraint \`base_user2_favourite_employee_id_foreign\` foreign key (\`favourite_employee_id\`) references \`base_user2\` (\`id\`) on update cascade on delete set null; +alter table \`base_user2\` add constraint \`base_user2_favourite_manager_id_foreign\` foreign key (\`favourite_manager_id\`) references \`base_user2\` (\`id\`) on update cascade on delete set null; + alter table \`author_to_friend\` add constraint \`author_to_friend_author2_1_id_foreign\` foreign key (\`author2_1_id\`) references \`author2\` (\`id\`) on update cascade on delete cascade; alter table \`author_to_friend\` add constraint \`author_to_friend_author2_2_id_foreign\` foreign key (\`author2_2_id\`) references \`author2\` (\`id\`) on update cascade on delete cascade; diff --git a/tests/bootstrap.ts b/tests/bootstrap.ts index c19359c838eb..27a4450bfe9b 100644 --- a/tests/bootstrap.ts +++ b/tests/bootstrap.ts @@ -8,7 +8,10 @@ import { MariaDbDriver } from '@mikro-orm/mariadb'; import { PostgreSqlDriver } from '@mikro-orm/postgresql'; import { Author, Book, BookTag, Publisher, Test } from './entities'; -import { Author2, Book2, BookTag2, FooBar2, FooBaz2, Publisher2, Test2, Label2, Configuration2, Address2, FooParam2, Car2, CarOwner2, User2 } from './entities-sql'; +import { + Author2, Book2, BookTag2, FooBar2, FooBaz2, Publisher2, Test2, Label2, Configuration2, Address2, FooParam2, + Car2, CarOwner2, User2, BaseUser2, Employee2, Manager2, CompanyOwner2, +} from './entities-sql'; import { BaseEntity2 } from './entities-sql/BaseEntity2'; import { BaseEntity22 } from './entities-sql/BaseEntity22'; import { FooBaz } from './entities/FooBaz'; @@ -51,7 +54,10 @@ export async function initORMMongo() { export async function initORMMySql(type: 'mysql' | 'mariadb' = 'mysql') { let orm = await MikroORM.init({ - entities: [Author2, Address2, Book2, BookTag2, Publisher2, Test2, FooBar2, FooBaz2, FooParam2, Configuration2, BaseEntity2, BaseEntity22, Car2, CarOwner2, User2], + entities: [ + Author2, Address2, Book2, BookTag2, Publisher2, Test2, FooBar2, FooBaz2, FooParam2, Configuration2, BaseEntity2, BaseEntity22, + Car2, CarOwner2, User2, BaseUser2, Employee2, Manager2, CompanyOwner2, + ], discovery: { tsConfigPath: BASE_DIR + '/tsconfig.test.json' }, clientUrl: `mysql://root@127.0.0.1:3306/mikro_orm_test`, port: type === 'mysql' ? 3307 : 3309, @@ -173,6 +179,7 @@ export async function wipeDatabaseMySql(em: SqlEntityManager) { await em.createQueryBuilder(Configuration2).truncate().execute(); await em.createQueryBuilder(Car2).truncate().execute(); await em.createQueryBuilder(CarOwner2).truncate().execute(); + await em.createQueryBuilder(BaseUser2).truncate().execute(); await em.createQueryBuilder('author2_to_author2').truncate().execute(); await em.createQueryBuilder('book2_to_book_tag2').truncate().execute(); await em.createQueryBuilder('book_to_tag_unordered').truncate().execute(); diff --git a/tests/entities-sql/Address2.ts b/tests/entities-sql/Address2.ts index b079e5471cb8..46d31cfd1f34 100644 --- a/tests/entities-sql/Address2.ts +++ b/tests/entities-sql/Address2.ts @@ -1,4 +1,4 @@ -import { Entity, Property, OneToOne, Index } from '@mikro-orm/core'; +import { Entity, Property, OneToOne } from '@mikro-orm/core'; import { Author2 } from './Author2'; @Entity() diff --git a/tests/entities-sql/BaseUser2.ts b/tests/entities-sql/BaseUser2.ts new file mode 100644 index 000000000000..3ca8e48623fc --- /dev/null +++ b/tests/entities-sql/BaseUser2.ts @@ -0,0 +1,39 @@ +import { AfterCreate, AfterUpdate, Entity, PrimaryKey, Property } from '@mikro-orm/core'; + +@Entity({ + discriminatorColumn: 'type', + discriminatorMap: { + employee: 'Employee2', + manager: 'Manager2', + owner: 'CompanyOwner2', + }, +}) +export abstract class BaseUser2 { + + @PrimaryKey() + id!: number; + + @Property({ length: 100 }) + firstName: string; + + @Property({ length: 100 }) + lastName: string; + + baseState?: string; + + constructor(firstName: string, lastName: string) { + this.firstName = firstName; + this.lastName = lastName; + } + + @AfterCreate() + afterCreate1() { + this.baseState = 'created'; + } + + @AfterUpdate() + afterUpdate1() { + this.baseState = 'updated'; + } + +} diff --git a/tests/entities-sql/CompanyOwner2.ts b/tests/entities-sql/CompanyOwner2.ts new file mode 100644 index 000000000000..2e172a6ff056 --- /dev/null +++ b/tests/entities-sql/CompanyOwner2.ts @@ -0,0 +1,29 @@ +import { AfterCreate, AfterUpdate, Entity, ManyToOne, OneToOne, Property } from '@mikro-orm/core'; +import { Manager2 } from './Manager2'; +import { Employee2 } from './Employee2'; + +@Entity() +export class CompanyOwner2 extends Manager2 { + + @Property() + ownerProp!: string; + + @ManyToOne(() => Employee2) + favouriteEmployee?: Employee2; + + @OneToOne(() => Manager2) + favouriteManager?: Manager2; + + state?: string; + + @AfterCreate() + afterCreate2() { + this.state = 'created'; + } + + @AfterUpdate() + afterUpdate2() { + this.state = 'updated'; + } + +} diff --git a/tests/entities-sql/Employee2.ts b/tests/entities-sql/Employee2.ts new file mode 100644 index 000000000000..fbcf5f54fd1d --- /dev/null +++ b/tests/entities-sql/Employee2.ts @@ -0,0 +1,11 @@ +import { Entity, Property } from '@mikro-orm/core'; +import { BaseUser2 } from './BaseUser2'; + +@Entity() +export class Employee2 extends BaseUser2 { + + @Property() + employeeProp!: number; + +} + diff --git a/tests/entities-sql/Manager2.ts b/tests/entities-sql/Manager2.ts new file mode 100644 index 000000000000..f28b254cd8a4 --- /dev/null +++ b/tests/entities-sql/Manager2.ts @@ -0,0 +1,10 @@ +import { Entity, Property } from '@mikro-orm/core'; +import { BaseUser2 } from './BaseUser2'; + +@Entity() +export class Manager2 extends BaseUser2 { + + @Property() + managerProp!: string; + +} diff --git a/tests/entities-sql/index.ts b/tests/entities-sql/index.ts index 12e266f47cbd..861c0453fcf8 100644 --- a/tests/entities-sql/index.ts +++ b/tests/entities-sql/index.ts @@ -12,3 +12,7 @@ export * from './Address2'; export * from './Car2'; export * from './CarOwner2'; export * from './User2'; +export * from './BaseUser2'; +export * from './Employee2'; +export * from './Manager2'; +export * from './CompanyOwner2'; diff --git a/tests/mysql-schema.sql b/tests/mysql-schema.sql index 0cfb03618d86..a76e34217d8f 100644 --- a/tests/mysql-schema.sql +++ b/tests/mysql-schema.sql @@ -14,6 +14,7 @@ drop table if exists `configuration2`; drop table if exists `car2`; drop table if exists `car_owner2`; drop table if exists `user2`; +drop table if exists `base_user2`; drop table if exists `author_to_friend`; drop table if exists `author2_to_author2`; drop table if exists `book2_to_book_tag2`; @@ -85,6 +86,13 @@ alter table `user2` add index `user2_first_name_index`(`first_name`); alter table `user2` add index `user2_last_name_index`(`last_name`); alter table `user2` add primary key `user2_pkey`(`first_name`, `last_name`); + +create table `base_user2` (`id` int unsigned not null auto_increment primary key, `first_name` varchar(100) not null, `last_name` varchar(100) not null, `type` enum('employee', 'manager', 'owner') not null, `employee_prop` int(11) null, `manager_prop` varchar(255) null, `owner_prop` varchar(255) null, `favourite_employee_id` int(11) unsigned null, `favourite_manager_id` int(11) unsigned null) default character set utf8mb4 engine = InnoDB; +alter table `base_user2` add index `base_user2_type_index`(`type`); +alter table `base_user2` add index `base_user2_favourite_employee_id_index`(`favourite_employee_id`); +alter table `base_user2` add index `base_user2_favourite_manager_id_index`(`favourite_manager_id`); +alter table `base_user2` add unique `base_user2_favourite_manager_id_unique`(`favourite_manager_id`); + create table `author_to_friend` (`author2_1_id` int(11) unsigned not null, `author2_2_id` int(11) unsigned not null) default character set utf8mb4 engine = InnoDB; alter table `author_to_friend` add index `author_to_friend_author2_1_id_index`(`author2_1_id`); alter table `author_to_friend` add index `author_to_friend_author2_2_id_index`(`author2_2_id`); @@ -135,6 +143,9 @@ alter table `configuration2` add constraint `configuration2_test_id_foreign` for alter table `car_owner2` add constraint `car_owner2_car_name_foreign` foreign key (`car_name`) references `car2` (`name`) on update cascade; alter table `car_owner2` add constraint `car_owner2_car_year_foreign` foreign key (`car_year`) references `car2` (`year`) on update cascade; +alter table `base_user2` add constraint `base_user2_favourite_employee_id_foreign` foreign key (`favourite_employee_id`) references `base_user2` (`id`) on update cascade on delete set null; +alter table `base_user2` add constraint `base_user2_favourite_manager_id_foreign` foreign key (`favourite_manager_id`) references `base_user2` (`id`) on update cascade on delete set null; + alter table `author_to_friend` add constraint `author_to_friend_author2_1_id_foreign` foreign key (`author2_1_id`) references `author2` (`id`) on update cascade on delete cascade; alter table `author_to_friend` add constraint `author_to_friend_author2_2_id_foreign` foreign key (`author2_2_id`) references `author2` (`id`) on update cascade on delete cascade; diff --git a/tests/single-table-inheritance.mysql.test.ts b/tests/single-table-inheritance.mysql.test.ts new file mode 100644 index 000000000000..c3a6c09f83cd --- /dev/null +++ b/tests/single-table-inheritance.mysql.test.ts @@ -0,0 +1,151 @@ +import { Dictionary, MetadataDiscovery, MetadataStorage, MikroORM, ReferenceType, wrap } from '@mikro-orm/core'; +import { MySqlDriver } from '@mikro-orm/mysql'; +import { BaseUser2, CompanyOwner2, Employee2, Manager2 } from './entities-sql'; +import { initORMMySql, wipeDatabaseMySql } from './bootstrap'; + +describe('single table inheritance in mysql', () => { + + let orm: MikroORM; + + beforeAll(async () => orm = await initORMMySql()); + beforeEach(async () => wipeDatabaseMySql(orm.em)); + + async function createEntities() { + const employee1 = new Employee2('Emp', '1'); + employee1.employeeProp = 1; + const employee2 = new Employee2('Emp', '2'); + employee2.employeeProp = 2; + const manager = new Manager2('Man', '3'); + manager.managerProp = 'i am manager'; + const owner = new CompanyOwner2('Bruce', 'Almighty'); + owner.ownerProp = 'i am owner'; + owner.managerProp = 'i said i am owner'; + owner.favouriteEmployee = employee2; + owner.favouriteManager = manager; + + expect((owner as any).type).not.toBeDefined(); + await orm.em.persistAndFlush([owner, employee1]); + orm.em.clear(); + + expect(owner.state).toBe('created'); + expect(owner.baseState).toBe('created'); + expect((owner as any).type).not.toBeDefined(); + } + + test('check metadata', async () => { + expect(orm.getMetadata().get('BaseUser2').hooks).toEqual({ + afterCreate: ['afterCreate1'], + afterUpdate: ['afterUpdate1'], + }); + expect(orm.getMetadata().get('Employee2').hooks).toEqual({ + afterCreate: ['afterCreate1'], + afterUpdate: ['afterUpdate1'], + }); + expect(orm.getMetadata().get('Manager2').hooks).toEqual({ + afterCreate: ['afterCreate1'], + afterUpdate: ['afterUpdate1'], + }); + expect(orm.getMetadata().get('CompanyOwner2').hooks).toEqual({ + afterCreate: ['afterCreate1', 'afterCreate2'], + afterUpdate: ['afterUpdate1', 'afterUpdate2'], + }); + }); + + test('persisting and loading STI entities', async () => { + await createEntities(); + const users = await orm.em.find(BaseUser2, {}, { orderBy: { lastName: 'asc', firstName: 'asc' } }); + expect(users).toHaveLength(4); + expect(users[0]).toBeInstanceOf(Employee2); + expect(users[1]).toBeInstanceOf(Employee2); + expect(users[2]).toBeInstanceOf(Manager2); + expect(users[3]).toBeInstanceOf(CompanyOwner2); + expect((users[3] as CompanyOwner2).favouriteEmployee).toBeInstanceOf(Employee2); + expect((users[3] as CompanyOwner2).favouriteManager).toBeInstanceOf(Manager2); + expect(users[0]).toEqual({ + id: 2, + firstName: 'Emp', + lastName: '1', + employeeProp: 1, + }); + expect((users[0] as any).type).not.toBeDefined(); + expect(users[1]).toEqual({ + id: 1, + firstName: 'Emp', + lastName: '2', + employeeProp: 2, + }); + expect(users[2]).toEqual({ + id: 3, + firstName: 'Man', + lastName: '3', + managerProp: 'i am manager', + }); + expect(users[3]).toEqual({ + id: 4, + firstName: 'Bruce', + lastName: 'Almighty', + managerProp: 'i said i am owner', + ownerProp: 'i am owner', + favouriteEmployee: users[1], + favouriteManager: users[2], + }); + + expect(Object.keys(orm.em.getUnitOfWork().getIdentityMap())).toEqual(['BaseUser2-2', 'BaseUser2-1', 'BaseUser2-3', 'BaseUser2-4']); + + const o = await orm.em.findOneOrFail(CompanyOwner2, 4); + expect(o.state).toBeUndefined(); + expect(o.baseState).toBeUndefined(); + o.firstName = 'Changed'; + await orm.em.flush(); + expect(o.state).toBe('updated'); + expect(o.baseState).toBe('updated'); + }); + + test('STI in m:1 and 1:1 relations', async () => { + await createEntities(); + const owner = await orm.em.findOneOrFail(CompanyOwner2, { firstName: 'Bruce' }); + expect(owner).toBeInstanceOf(CompanyOwner2); + expect(owner.favouriteEmployee).toBeInstanceOf(Employee2); + expect(wrap(owner.favouriteEmployee).isInitialized()).toBe(false); + await wrap(owner.favouriteEmployee).init(); + expect(wrap(owner.favouriteEmployee).isInitialized()).toBe(true); + expect(owner.favouriteManager).toBeInstanceOf(Manager2); + expect(wrap(owner.favouriteManager).isInitialized()).toBe(false); + await wrap(owner.favouriteManager).init(); + expect(wrap(owner.favouriteManager).isInitialized()).toBe(true); + }); + + test('generated discriminator map', async () => { + const storage = new MetadataStorage({ + A: { name: 'A', className: 'A', primaryKeys: ['id'], discriminatorColumn: 'type', properties: { id: { name: 'id', type: 'string', reference: ReferenceType.SCALAR } } }, + B: { name: 'B', className: 'B', primaryKeys: ['id'], extends: 'A', properties: { id: { name: 'id', type: 'string', reference: ReferenceType.SCALAR } } }, + C: { name: 'C', className: 'C', primaryKeys: ['id'], extends: 'A', properties: { id: { name: 'id', type: 'string', reference: ReferenceType.SCALAR } } }, + } as Dictionary); + class A { + + toJSON(a: string, b: string) { + // + } + + } + class B {} + class C {} + orm.config.set('entities', [A, B, C]); + const discovery = new MetadataDiscovery(storage, orm.em.getDriver().getPlatform(), orm.config); + const discovered = await discovery.discover(); + expect(discovered.get('A').discriminatorMap).toEqual({ a: 'A', b: 'B', c: 'C' }); + expect(discovered.get('A').properties.type).toMatchObject({ + name: 'type', + enum: true, + type: 'string', + index: true, + items: ['a', 'b', 'c'], + }); + expect(discovered.get('A').discriminatorValue).toBe('a'); + expect(discovered.get('B').discriminatorValue).toBe('b'); + expect(discovered.get('C').discriminatorValue).toBe('c'); + }); + + afterAll(async () => orm.close(true)); + +});