Skip to content

Commit

Permalink
feat(core): allow specifying custom pivot table entity (#2901)
Browse files Browse the repository at this point in the history
  • Loading branch information
B4nan committed Mar 13, 2022
1 parent dfb7e10 commit 8237d16
Show file tree
Hide file tree
Showing 12 changed files with 276 additions and 67 deletions.
1 change: 1 addition & 0 deletions packages/core/src/decorators/ManyToMany.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface ManyToManyOptions<T, O> extends ReferenceOptions<T, O> {
fixedOrder?: boolean;
fixedOrderColumn?: string;
pivotTable?: string;
pivotEntity?: string | (() => EntityName<any>);
joinColumn?: string;
joinColumns?: string[];
inverseJoinColumn?: string;
Expand Down
14 changes: 4 additions & 10 deletions packages/core/src/drivers/DatabaseDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export abstract class DatabaseDriver<C extends Connection> implements IDatabaseD
}

if (prop.fixedOrder) {
return [{ [`${prop.pivotTable}.${prop.fixedOrderColumn}`]: QueryOrder.ASC } as QueryOrderMap<T>];
return [{ [`${prop.pivotEntity}.${prop.fixedOrderColumn}`]: QueryOrder.ASC } as QueryOrderMap<T>];
}

return [];
Expand All @@ -203,19 +203,13 @@ export abstract class DatabaseDriver<C extends Connection> implements IDatabaseD
}

protected getPivotInverseProperty(prop: EntityProperty): EntityProperty {
const pivotMeta = this.metadata.find(prop.pivotTable)!;
const targetType = prop.targetMeta?.root.className;
let inverse: string;
const pivotMeta = this.metadata.find(prop.pivotEntity)!;

if (prop.owner) {
const pivotProp1 = pivotMeta.properties[targetType + '_inverse'];
inverse = pivotProp1.mappedBy;
} else {
const pivotProp1 = pivotMeta.properties[targetType + '_owner'];
inverse = pivotProp1.inversedBy;
return pivotMeta.relations[0];
}

return pivotMeta.properties[inverse];
return pivotMeta.relations[1];
}

protected createReplicas(cb: (c: ConnectionOptions) => C): C[] {
Expand Down
23 changes: 18 additions & 5 deletions packages/core/src/metadata/MetadataDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,15 +337,22 @@ export class MetadataDiscovery {
private initManyToManyFields(meta: EntityMetadata, prop: EntityProperty): void {
const meta2 = this.metadata.get(prop.type);
Utils.defaultValue(prop, 'fixedOrder', !!prop.fixedOrderColumn);
const pivotMeta = this.metadata.find(prop.pivotEntity);

if (pivotMeta) {
pivotMeta.pivotTable = true;
prop.pivotTable = pivotMeta.tableName;
}

if (!prop.pivotTable && prop.owner && this.platform.usesPivotTable()) {
prop.pivotTable = this.namingStrategy.joinTableName(meta.collection, meta2.collection, prop.name);
prop.pivotTable = this.namingStrategy.joinTableName(meta.tableName, meta2.tableName, prop.name);
}

if (prop.mappedBy) {
const prop2 = meta2.properties[prop.mappedBy];
this.initManyToManyFields(meta2, prop2);
prop.pivotTable = prop2.pivotTable;
prop.pivotEntity = prop2.pivotEntity ?? prop2.pivotTable;
prop.fixedOrder = prop2.fixedOrder;
prop.fixedOrderColumn = prop2.fixedOrderColumn;
prop.joinColumns = prop2.inverseJoinColumns;
Expand Down Expand Up @@ -419,8 +426,7 @@ export class MetadataDiscovery {
const ret: EntityMetadata[] = [];

if (this.platform.usesPivotTable()) {
const promises = Object
.values(meta.properties)
const promises = Object.values(meta.properties)
.filter(prop => prop.reference === ReferenceType.MANY_TO_MANY && prop.owner && prop.pivotTable)
.map(prop => this.definePivotTableEntity(meta, prop));
(await Promise.all(promises)).forEach(meta => ret.push(meta));
Expand All @@ -430,7 +436,7 @@ export class MetadataDiscovery {
}

private initFactoryField<T>(meta: EntityMetadata<T>, prop: EntityProperty<T>): void {
['mappedBy', 'inversedBy'].forEach(type => {
['mappedBy', 'inversedBy', 'pivotEntity'].forEach(type => {
const value = prop[type];

if (value instanceof Function) {
Expand All @@ -445,6 +451,12 @@ export class MetadataDiscovery {
}

private async definePivotTableEntity(meta: EntityMetadata, prop: EntityProperty): Promise<EntityMetadata> {
const pivotMeta = this.metadata.find(prop.pivotEntity);

if (pivotMeta) {
return pivotMeta;
}

let tableName = prop.pivotTable;
let schemaName: string | undefined;

Expand All @@ -461,6 +473,7 @@ export class MetadataDiscovery {
schema: schemaName,
pivotTable: true,
});
prop.pivotEntity = data.className;

if (prop.fixedOrder) {
const primaryProp = await this.defineFixedOrderProperty(prop, targetType);
Expand All @@ -486,7 +499,7 @@ export class MetadataDiscovery {
data.properties[meta.root.name + '_owner'] = await this.definePivotProperty(prop, meta.root.name + '_owner', meta.root.name!, targetType + '_inverse', true);
data.properties[targetType + '_inverse'] = await this.definePivotProperty(prop, targetType + '_inverse', targetType, meta.root.name + '_owner', false);

return this.metadata.set(prop.pivotTable, data);
return this.metadata.set(data.className, data);
}

private async defineFixedOrderProperty(prop: EntityProperty, targetType: string): Promise<EntityProperty> {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/metadata/MetadataStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export class MetadataStorage {
}

get<T extends AnyEntity<T> = any>(entity: string, init = false, validate = true): EntityMetadata<T> {
if (validate && !init && entity && !this.has(entity)) {
if (validate && !init && !this.has(entity)) {
throw MetadataError.missingMetadata(entity);
}

Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/typings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ export interface EntityProperty<T extends AnyEntity<T> = any> {
fixedOrder?: boolean;
fixedOrderColumn?: string;
pivotTable: string;
pivotEntity: string;
joinColumns: string[];
inverseJoinColumns: string[];
referencedColumnNames: string[];
Expand Down Expand Up @@ -302,6 +303,10 @@ export class EntityMetadata<T extends AnyEntity<T> = any> {
}

addProperty(prop: EntityProperty<T>, sync = true) {
if (prop.pivotTable && !prop.pivotEntity) {
prop.pivotEntity = prop.pivotTable;
}

this.properties[prop.name] = prop;
this.propertyOrder.set(prop.name, this.props.length);

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/unit-of-work/CommitOrderCalculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export class CommitOrderCalculator {

discoverProperty(prop: EntityProperty, entityName: string): void {
const toOneOwner = (prop.reference === ReferenceType.ONE_TO_ONE && prop.owner) || prop.reference === ReferenceType.MANY_TO_ONE;
const toManyOwner = prop.reference === ReferenceType.MANY_TO_MANY && prop.owner && !prop.pivotTable;
const toManyOwner = prop.reference === ReferenceType.MANY_TO_MANY && prop.owner && !prop.pivotEntity;

if (!toOneOwner && !toManyOwner) {
return;
Expand Down
12 changes: 6 additions & 6 deletions packages/knex/src/AbstractSqlDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ export abstract class AbstractSqlDriver<C extends AbstractSqlConnection = Abstra

/* istanbul ignore next */
const ownerSchema = wrapped.getSchema() === '*' ? this.config.get('schema') : wrapped.getSchema();
const pivotMeta = this.metadata.find(coll.property.pivotTable)!;
const pivotMeta = this.metadata.find(coll.property.pivotEntity)!;

if (pivotMeta.schema === '*') {
/* istanbul ignore next */
Expand All @@ -452,7 +452,7 @@ export abstract class AbstractSqlDriver<C extends AbstractSqlConnection = Abstra
async loadFromPivotTable<T, O>(prop: EntityProperty, owners: Primary<O>[][], where: FilterQuery<T> = {} as FilterQuery<T>, orderBy?: QueryOrderMap<T>[], ctx?: Transaction, options?: FindOptions<T>): Promise<Dictionary<T[]>> {
const pivotProp2 = this.getPivotInverseProperty(prop);
const ownerMeta = this.metadata.find(pivotProp2.type)!;
const cond = { [`${prop.pivotTable}.${pivotProp2.name}`]: { $in: ownerMeta.compositePK ? owners : owners.map(o => o[0]) } };
const cond = { [`${prop.pivotEntity}.${pivotProp2.name}`]: { $in: ownerMeta.compositePK ? owners : owners.map(o => o[0]) } };

/* istanbul ignore if */
if (!Utils.isEmpty(where) && Object.keys(where as Dictionary).every(k => Utils.isOperator(k, false))) {
Expand All @@ -465,7 +465,7 @@ export abstract class AbstractSqlDriver<C extends AbstractSqlConnection = Abstra
const qb = this.createQueryBuilder<T>(prop.type, ctx, options?.connectionType)
.unsetFlag(QueryFlag.CONVERT_CUSTOM_TYPES)
.withSchema(this.getSchemaName(prop.targetMeta, options));
const populate = this.autoJoinOneToOneOwner(prop.targetMeta!, [{ field: prop.pivotTable }]);
const populate = this.autoJoinOneToOneOwner(prop.targetMeta!, [{ field: prop.pivotEntity }]);
const fields = this.buildFields(prop.targetMeta!, (options?.populate ?? []) as unknown as PopulateOptions<T>[], [], qb, options?.fields as Field<T>[]);
qb.select(fields).populate(populate).where(where).orderBy(orderBy!).setLockMode(options?.lockMode, options?.lockTableAliases);

Expand Down Expand Up @@ -677,7 +677,7 @@ export abstract class AbstractSqlDriver<C extends AbstractSqlConnection = Abstra
}

if (deleteDiff === true || deleteDiff.length > 0) {
const qb1 = this.createQueryBuilder(prop.pivotTable, options?.ctx, 'write')
const qb1 = this.createQueryBuilder(prop.pivotEntity, options?.ctx, 'write')
.withSchema(this.getSchemaName(meta, options))
.unsetFlag(QueryFlag.CONVERT_CUSTOM_TYPES);
const knex = qb1.getKnex();
Expand All @@ -704,10 +704,10 @@ export abstract class AbstractSqlDriver<C extends AbstractSqlConnection = Abstra

/* istanbul ignore else */
if (this.platform.allowsMultiInsert()) {
await this.nativeInsertMany<T>(prop.pivotTable, items as EntityData<T>[], { ...options, convertCustomTypes: false, processCollections: false });
await this.nativeInsertMany<T>(prop.pivotEntity, items as EntityData<T>[], { ...options, convertCustomTypes: false, processCollections: false });
} else {
await Utils.runSerial(items, item => {
return this.createQueryBuilder(prop.pivotTable, options?.ctx, 'write')
return this.createQueryBuilder(prop.pivotEntity, options?.ctx, 'write')
.unsetFlag(QueryFlag.CONVERT_CUSTOM_TYPES)
.withSchema(this.getSchemaName(meta, options))
.insert(item)
Expand Down
51 changes: 11 additions & 40 deletions packages/knex/src/query/QueryBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,7 @@
import type { Knex } from 'knex';
import type {
AnyEntity,
ConnectionType,
Dictionary,
EntityData,
EntityMetadata,
EntityProperty,
FilterQuery,
FlatQueryOrderMap,
FlushMode,
GroupOperator,
MetadataStorage,
PopulateOptions,
QBFilterQuery,
QBQueryOrderMap,
QueryOrderMap,
QueryResult,
RequiredEntityData,
AnyEntity, ConnectionType, Dictionary, EntityData, EntityMetadata, EntityProperty, FlatQueryOrderMap, RequiredEntityData,
GroupOperator, MetadataStorage, PopulateOptions, QBFilterQuery, QueryOrderMap, QueryResult, FlushMode, FilterQuery, QBQueryOrderMap,
} from '@mikro-orm/core';
import { LoadStrategy, LockMode, QueryFlag, QueryHelper, ReferenceType, Utils, ValidationError } from '@mikro-orm/core';
import { QueryType } from './enums';
Expand Down Expand Up @@ -648,13 +633,13 @@ export class QueryBuilder<T extends AnyEntity<T> = AnyEntity> {

if (type !== 'pivotJoin') {
const oldPivotAlias = this.getAliasForJoinPath(path + '[pivot]');
pivotAlias = oldPivotAlias ?? this.getNextAlias(prop.pivotTable);
pivotAlias = oldPivotAlias ?? this.getNextAlias(prop.pivotEntity);
aliasedName = `${fromAlias}.${prop.name}#${pivotAlias}`;
}

const joins = this.helper.joinManyToManyReference(prop, fromAlias, alias, pivotAlias, type, cond, path);
Object.assign(this._joins, joins);
this._aliasMap[pivotAlias] = prop.pivotTable;
this._aliasMap[pivotAlias] = prop.pivotEntity;
} else if (prop.reference === ReferenceType.ONE_TO_ONE) {
this._joins[aliasedName] = this.helper.joinOneToReference(prop, fromAlias, alias, type, cond);
} else { // MANY_TO_ONE
Expand Down Expand Up @@ -821,7 +806,7 @@ export class QueryBuilder<T extends AnyEntity<T> = AnyEntity> {
this.autoJoinPivotTable(field);
} else if (meta && this.helper.isOneToOneInverse(fromField)) {
const prop = meta.properties[fromField];
const alias = this.getNextAlias(prop.pivotTable ?? prop.type);
const alias = this.getNextAlias(prop.pivotEntity ?? prop.type);
const aliasedName = `${fromAlias}.${prop.name}#${alias}`;
this._joins[aliasedName] = this.helper.joinOneToReference(prop, this.alias, alias, 'leftJoin');
this._populateMap[aliasedName] = this._joins[aliasedName].alias;
Expand Down Expand Up @@ -914,8 +899,8 @@ export class QueryBuilder<T extends AnyEntity<T> = AnyEntity> {

private autoJoinPivotTable(field: string): void {
const pivotMeta = this.metadata.find(field)!;
const owner = pivotMeta.props.find(prop => prop.reference === ReferenceType.MANY_TO_ONE && prop.owner)!;
const inverse = pivotMeta.props.find(prop => prop.reference === ReferenceType.MANY_TO_ONE && !prop.owner)!;
const owner = pivotMeta.relations[0];
const inverse = pivotMeta.relations[1];
const prop = this._cond[pivotMeta.name + '.' + owner.name] || this._orderBy[pivotMeta.name + '.' + owner.name] ? inverse : owner;
const pivotAlias = this.getNextAlias(pivotMeta.name!);

Expand All @@ -929,44 +914,30 @@ export class QueryBuilder<T extends AnyEntity<T> = AnyEntity> {

export interface RunQueryBuilder<T> extends Omit<QueryBuilder<T>, 'getResult' | 'getSingleResult' | 'getResultList' | 'where'> {
where(cond: QBFilterQuery<T> | string, params?: keyof typeof GroupOperator | any[], operator?: keyof typeof GroupOperator): this;

execute<U = QueryResult<T>>(method?: 'all' | 'get' | 'run', mapResults?: boolean): Promise<U>;

then<TResult1 = QueryResult<T>, TResult2 = never>(onfulfilled?: ((value: QueryResult<T>) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<QueryResult<T>>;
}

export interface SelectQueryBuilder<T> extends QueryBuilder<T> {
execute<U = T[]>(method?: 'all' | 'get' | 'run', mapResults?: boolean): Promise<U>;

execute<U = T[]>(method: 'all', mapResults?: boolean): Promise<U>;

execute<U = T>(method: 'get', mapResults?: boolean): Promise<U>;

execute<U = QueryResult<T>>(method: 'run', mapResults?: boolean): Promise<U>;

then<TResult1 = T[], TResult2 = never>(onfulfilled?: ((value: T[]) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<T[]>;
}

export interface CountQueryBuilder<T> extends QueryBuilder<T> {
execute<U = { count: number }[]>(method?: 'all' | 'get' | 'run', mapResults?: boolean): Promise<U>;

execute<U = { count: number }[]>(method: 'all', mapResults?: boolean): Promise<U>;

execute<U = { count: number }>(method: 'get', mapResults?: boolean): Promise<U>;

execute<U = QueryResult<{ count: number }>>(method: 'run', mapResults?: boolean): Promise<U>;

then<TResult1 = number, TResult2 = never>(onfulfilled?: ((value: number) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<number>;
}

export interface InsertQueryBuilder<T> extends RunQueryBuilder<T> {
}
export interface InsertQueryBuilder<T> extends RunQueryBuilder<T> {}

export interface UpdateQueryBuilder<T> extends RunQueryBuilder<T> {
}
export interface UpdateQueryBuilder<T> extends RunQueryBuilder<T> {}

export interface DeleteQueryBuilder<T> extends RunQueryBuilder<T> {
}
export interface DeleteQueryBuilder<T> extends RunQueryBuilder<T> {}

export interface TruncateQueryBuilder<T> extends RunQueryBuilder<T> {
}
export interface TruncateQueryBuilder<T> extends RunQueryBuilder<T> {}
6 changes: 3 additions & 3 deletions packages/knex/src/query/QueryBuilderHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export class QueryBuilderHelper {
inverseJoinColumns: prop.inverseJoinColumns,
primaryKeys: prop.referencedColumnNames,
table: prop.pivotTable,
schema: this.driver.getSchemaName(this.metadata.find(prop.pivotTable)),
schema: this.driver.getSchemaName(this.metadata.find(prop.pivotEntity)),
path: path.endsWith('[pivot]') ? path : `${path}[pivot]`,
} as JoinOptions,
};
Expand All @@ -184,7 +184,7 @@ export class QueryBuilderHelper {
return ret;
}

const prop2 = this.metadata.find(prop.pivotTable)!.properties[prop.type + (prop.owner ? '_inverse' : '_owner')];
const prop2 = this.metadata.find(prop.pivotEntity)!.properties[prop.type + (prop.owner ? '_inverse' : '_owner')];
ret[`${pivotAlias}.${prop2.name}#${alias}`] = this.joinManyToOneReference(prop2, pivotAlias, alias, type);
ret[`${pivotAlias}.${prop2.name}#${alias}`].path = path;

Expand All @@ -193,7 +193,7 @@ export class QueryBuilderHelper {

joinPivotTable(field: string, prop: EntityProperty, ownerAlias: string, alias: string, type: 'leftJoin' | 'innerJoin' | 'pivotJoin', cond: Dictionary = {}): JoinOptions {
const pivotMeta = this.metadata.find(field)!;
const prop2 = pivotMeta.properties[prop.mappedBy || prop.inversedBy];
const prop2 = pivotMeta.relations[0] === prop ? pivotMeta.relations[1] : pivotMeta.relations[0];

return {
prop, type, cond, ownerAlias, alias,
Expand Down
1 change: 0 additions & 1 deletion tests/EntityManager.sqlite2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { SqliteDriver } from '@mikro-orm/sqlite';
import { initORMSqlite2, mockLogger } from './bootstrap';
import type { IAuthor4, IPublisher4, ITest4 } from './entities-schema';
import { Author4, Book4, BookTag4, FooBar4, Publisher4, PublisherType, Test4 } from './entities-schema';
import { Book2 } from './entities-sql';

describe.each(['sqlite', 'better-sqlite'] as const)('EntityManager (%s)', driver => {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`custom pivot entity for m:n with additional properties schema 1`] = `
"pragma foreign_keys = off;
create table \`order\` (\`id\` integer not null primary key autoincrement, \`paid\` integer not null, \`shipped\` integer not null, \`created\` datetime not null);
create table \`product\` (\`id\` integer not null primary key autoincrement, \`name\` text not null, \`current_price\` integer not null);
create table \`order_item\` (\`order_id\` integer not null, \`product_id\` integer not null, \`amount\` integer not null default 1, \`offered_price\` integer not null default 0, constraint \`order_item_order_id_foreign\` foreign key(\`order_id\`) references \`order\`(\`id\`) on update cascade, constraint \`order_item_product_id_foreign\` foreign key(\`product_id\`) references \`product\`(\`id\`) on update cascade, primary key (\`order_id\`, \`product_id\`));
create index \`order_item_order_id_index\` on \`order_item\` (\`order_id\`);
create index \`order_item_product_id_index\` on \`order_item\` (\`product_id\`);
pragma foreign_keys = on;
"
`;
Loading

0 comments on commit 8237d16

Please sign in to comment.