diff --git a/packages/core/src/decorators/ManyToMany.ts b/packages/core/src/decorators/ManyToMany.ts index 79075c92f92b..16a073293bfb 100644 --- a/packages/core/src/decorators/ManyToMany.ts +++ b/packages/core/src/decorators/ManyToMany.ts @@ -29,6 +29,7 @@ export interface ManyToManyOptions extends ReferenceOptions { fixedOrder?: boolean; fixedOrderColumn?: string; pivotTable?: string; + pivotEntity?: string | (() => EntityName); joinColumn?: string; joinColumns?: string[]; inverseJoinColumn?: string; diff --git a/packages/core/src/drivers/DatabaseDriver.ts b/packages/core/src/drivers/DatabaseDriver.ts index 2340da87057f..bdb148e9cb96 100644 --- a/packages/core/src/drivers/DatabaseDriver.ts +++ b/packages/core/src/drivers/DatabaseDriver.ts @@ -191,7 +191,7 @@ export abstract class DatabaseDriver implements IDatabaseD } if (prop.fixedOrder) { - return [{ [`${prop.pivotTable}.${prop.fixedOrderColumn}`]: QueryOrder.ASC } as QueryOrderMap]; + return [{ [`${prop.pivotEntity}.${prop.fixedOrderColumn}`]: QueryOrder.ASC } as QueryOrderMap]; } return []; @@ -203,19 +203,13 @@ export abstract class DatabaseDriver 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[] { diff --git a/packages/core/src/metadata/MetadataDiscovery.ts b/packages/core/src/metadata/MetadataDiscovery.ts index 6e6af04f463f..0b82bf5ed20a 100644 --- a/packages/core/src/metadata/MetadataDiscovery.ts +++ b/packages/core/src/metadata/MetadataDiscovery.ts @@ -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; @@ -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)); @@ -430,7 +436,7 @@ export class MetadataDiscovery { } private initFactoryField(meta: EntityMetadata, prop: EntityProperty): void { - ['mappedBy', 'inversedBy'].forEach(type => { + ['mappedBy', 'inversedBy', 'pivotEntity'].forEach(type => { const value = prop[type]; if (value instanceof Function) { @@ -445,6 +451,12 @@ export class MetadataDiscovery { } private async definePivotTableEntity(meta: EntityMetadata, prop: EntityProperty): Promise { + const pivotMeta = this.metadata.find(prop.pivotEntity); + + if (pivotMeta) { + return pivotMeta; + } + let tableName = prop.pivotTable; let schemaName: string | undefined; @@ -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); @@ -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 { diff --git a/packages/core/src/metadata/MetadataStorage.ts b/packages/core/src/metadata/MetadataStorage.ts index 6b48b590201c..8a522396e86d 100644 --- a/packages/core/src/metadata/MetadataStorage.ts +++ b/packages/core/src/metadata/MetadataStorage.ts @@ -74,7 +74,7 @@ export class MetadataStorage { } get = any>(entity: string, init = false, validate = true): EntityMetadata { - if (validate && !init && entity && !this.has(entity)) { + if (validate && !init && !this.has(entity)) { throw MetadataError.missingMetadata(entity); } diff --git a/packages/core/src/typings.ts b/packages/core/src/typings.ts index 4a8be3b3c146..657f10c46c14 100644 --- a/packages/core/src/typings.ts +++ b/packages/core/src/typings.ts @@ -271,6 +271,7 @@ export interface EntityProperty = any> { fixedOrder?: boolean; fixedOrderColumn?: string; pivotTable: string; + pivotEntity: string; joinColumns: string[]; inverseJoinColumns: string[]; referencedColumnNames: string[]; @@ -302,6 +303,10 @@ export class EntityMetadata = any> { } addProperty(prop: EntityProperty, sync = true) { + if (prop.pivotTable && !prop.pivotEntity) { + prop.pivotEntity = prop.pivotTable; + } + this.properties[prop.name] = prop; this.propertyOrder.set(prop.name, this.props.length); diff --git a/packages/core/src/unit-of-work/CommitOrderCalculator.ts b/packages/core/src/unit-of-work/CommitOrderCalculator.ts index 7222b765dce6..8b967513db5d 100644 --- a/packages/core/src/unit-of-work/CommitOrderCalculator.ts +++ b/packages/core/src/unit-of-work/CommitOrderCalculator.ts @@ -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; diff --git a/packages/knex/src/AbstractSqlDriver.ts b/packages/knex/src/AbstractSqlDriver.ts index 3d593302872e..06cc816a1ff4 100644 --- a/packages/knex/src/AbstractSqlDriver.ts +++ b/packages/knex/src/AbstractSqlDriver.ts @@ -438,7 +438,7 @@ export abstract class AbstractSqlDriver(prop: EntityProperty, owners: Primary[][], where: FilterQuery = {} as FilterQuery, orderBy?: QueryOrderMap[], ctx?: Transaction, options?: FindOptions): Promise> { 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))) { @@ -465,7 +465,7 @@ export abstract class AbstractSqlDriver(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[], [], qb, options?.fields as Field[]); qb.select(fields).populate(populate).where(where).orderBy(orderBy!).setLockMode(options?.lockMode, options?.lockTableAliases); @@ -677,7 +677,7 @@ export abstract class AbstractSqlDriver 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(); @@ -704,10 +704,10 @@ export abstract class AbstractSqlDriver(prop.pivotTable, items as EntityData[], { ...options, convertCustomTypes: false, processCollections: false }); + await this.nativeInsertMany(prop.pivotEntity, items as EntityData[], { ...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) diff --git a/packages/knex/src/query/QueryBuilder.ts b/packages/knex/src/query/QueryBuilder.ts index d297f9871f0a..bb88ae844df5 100644 --- a/packages/knex/src/query/QueryBuilder.ts +++ b/packages/knex/src/query/QueryBuilder.ts @@ -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'; @@ -648,13 +633,13 @@ export class QueryBuilder = 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 @@ -821,7 +806,7 @@ export class QueryBuilder = 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; @@ -914,8 +899,8 @@ export class QueryBuilder = 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!); @@ -929,44 +914,30 @@ export class QueryBuilder = AnyEntity> { export interface RunQueryBuilder extends Omit, 'getResult' | 'getSingleResult' | 'getResultList' | 'where'> { where(cond: QBFilterQuery | string, params?: keyof typeof GroupOperator | any[], operator?: keyof typeof GroupOperator): this; - execute>(method?: 'all' | 'get' | 'run', mapResults?: boolean): Promise; - then, TResult2 = never>(onfulfilled?: ((value: QueryResult) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null): Promise>; } export interface SelectQueryBuilder extends QueryBuilder { execute(method?: 'all' | 'get' | 'run', mapResults?: boolean): Promise; - execute(method: 'all', mapResults?: boolean): Promise; - execute(method: 'get', mapResults?: boolean): Promise; - execute>(method: 'run', mapResults?: boolean): Promise; - then(onfulfilled?: ((value: T[]) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null): Promise; } export interface CountQueryBuilder extends QueryBuilder { execute(method?: 'all' | 'get' | 'run', mapResults?: boolean): Promise; - execute(method: 'all', mapResults?: boolean): Promise; - execute(method: 'get', mapResults?: boolean): Promise; - execute>(method: 'run', mapResults?: boolean): Promise; - then(onfulfilled?: ((value: number) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null): Promise; } -export interface InsertQueryBuilder extends RunQueryBuilder { -} +export interface InsertQueryBuilder extends RunQueryBuilder {} -export interface UpdateQueryBuilder extends RunQueryBuilder { -} +export interface UpdateQueryBuilder extends RunQueryBuilder {} -export interface DeleteQueryBuilder extends RunQueryBuilder { -} +export interface DeleteQueryBuilder extends RunQueryBuilder {} -export interface TruncateQueryBuilder extends RunQueryBuilder { -} +export interface TruncateQueryBuilder extends RunQueryBuilder {} diff --git a/packages/knex/src/query/QueryBuilderHelper.ts b/packages/knex/src/query/QueryBuilderHelper.ts index e4c7fa5123c9..9747dcbe297b 100644 --- a/packages/knex/src/query/QueryBuilderHelper.ts +++ b/packages/knex/src/query/QueryBuilderHelper.ts @@ -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, }; @@ -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; @@ -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, diff --git a/tests/EntityManager.sqlite2.test.ts b/tests/EntityManager.sqlite2.test.ts index 363c72cc4cba..991589e56a41 100644 --- a/tests/EntityManager.sqlite2.test.ts +++ b/tests/EntityManager.sqlite2.test.ts @@ -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 => { diff --git a/tests/features/composite-keys/__snapshots__/custom-pivot-entity.sqlite.test.ts.snap b/tests/features/composite-keys/__snapshots__/custom-pivot-entity.sqlite.test.ts.snap new file mode 100644 index 000000000000..c671f7ba0e9e --- /dev/null +++ b/tests/features/composite-keys/__snapshots__/custom-pivot-entity.sqlite.test.ts.snap @@ -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; +" +`; diff --git a/tests/features/composite-keys/custom-pivot-entity.sqlite.test.ts b/tests/features/composite-keys/custom-pivot-entity.sqlite.test.ts new file mode 100644 index 000000000000..eb92bfe840b0 --- /dev/null +++ b/tests/features/composite-keys/custom-pivot-entity.sqlite.test.ts @@ -0,0 +1,210 @@ +import { Entity, PrimaryKey, MikroORM, ManyToOne, PrimaryKeyType, Property, wrap, OneToMany, Collection, ManyToMany } from '@mikro-orm/core'; + +@Entity() +export class Order { + + @PrimaryKey() + id!: number; + + // eslint-disable-next-line @typescript-eslint/no-use-before-define + @OneToMany(() => OrderItem, item => item.order) + items = new Collection(this); + + // eslint-disable-next-line @typescript-eslint/no-use-before-define + @ManyToMany({ entity: () => Product, pivotEntity: () => OrderItem }) + products = new Collection(this); + + @Property() + paid: boolean = false; + + @Property() + shipped: boolean = false; + + @Property() + created: Date = new Date(); + +} + +@Entity() +export class Product { + + @PrimaryKey() + id!: number; + + @Property() + name: string; + + @Property() + currentPrice: number; + + @ManyToMany(() => Order, o => o.products) + orders = new Collection(this); + + constructor(name: string, currentPrice: number) { + this.name = name; + this.currentPrice = currentPrice; + } + +} + +@Entity() +export class OrderItem { + + @ManyToOne({ primary: true }) + order: Order; + + @ManyToOne({ primary: true }) + product: Product; + + @Property({ default: 1 }) + amount!: number; + + @Property({ default: 0 }) + offeredPrice: number; + + [PrimaryKeyType]: [number, number]; + + constructor(order: Order, product: Product) { + this.order = order; + this.product = product; + this.offeredPrice = product.currentPrice; + } + +} + +describe('custom pivot entity for m:n with additional properties', () => { + + let orm: MikroORM; + + beforeAll(async () => { + orm = await MikroORM.init({ + entities: [Product, OrderItem, Order], + dbName: ':memory:', + type: 'sqlite', + }); + await orm.getSchemaGenerator().createSchema(); + }); + + afterAll(() => orm.close(true)); + + test(`schema`, async () => { + const sql = await orm.getSchemaGenerator().getCreateSchemaSQL(); + expect(sql).toMatchSnapshot(); + }); + + test(`should work`, async () => { + const order1 = new Order(); + const order2 = new Order(); + const order3 = new Order(); + const product1 = new Product('p1', 111); + const product2 = new Product('p2', 222); + const product3 = new Product('p3', 333); + const product4 = new Product('p4', 444); + const product5 = new Product('p5', 555); + const item11 = new OrderItem(order1, product1); + item11.offeredPrice = 123; + const item12 = new OrderItem(order1, product2); + item12.offeredPrice = 3123; + const item21 = new OrderItem(order2, product1); + item21.offeredPrice = 4123; + const item22 = new OrderItem(order2, product2); + item22.offeredPrice = 1123; + const item23 = new OrderItem(order2, product5); + item23.offeredPrice = 1263; + const item31 = new OrderItem(order3, product3); + item31.offeredPrice = 7123; + const item32 = new OrderItem(order3, product4); + item32.offeredPrice = 9123; + const item33 = new OrderItem(order3, product5); + item33.offeredPrice = 5123; + + await orm.em.fork().persistAndFlush([order1, order2, order3]); + + const orders = await orm.em.find(Order, {}, { populate: true }); + expect(orders).toHaveLength(3); + + // test inverse side + const productRepository = orm.em.getRepository(Product); + let products = await productRepository.findAll(); + expect(products).toBeInstanceOf(Array); + expect(products.length).toBe(5); + expect(products[0]).toBeInstanceOf(Product); + expect(products[0].name).toBe('p1'); + expect(products[0].orders).toBeInstanceOf(Collection); + expect(products[0].orders.isInitialized()).toBe(true); + expect(products[0].orders.isDirty()).toBe(false); + expect(products[0].orders.count()).toBe(2); + expect(products[0].orders.length).toBe(2); + + orm.em.clear(); + products = await orm.em.find(Product, {}); + expect(products[0].orders.isInitialized()).toBe(false); + expect(products[0].orders.isDirty()).toBe(false); + expect(() => products[0].orders.getItems()).toThrowError(/Collection of entity Product\[\d+] not initialized/); + expect(() => products[0].orders.remove(order1, order2)).toThrowError(/Collection of entity Product\[\d+] not initialized/); + expect(() => products[0].orders.removeAll()).toThrowError(/Collection of entity Product\[\d+] not initialized/); + expect(() => products[0].orders.contains(order1)).toThrowError(/Collection of entity Product\[\d+] not initialized/); + + // test M:N lazy load + orm.em.clear(); + products = await productRepository.findAll(); + await products[0].orders.init(); + expect(products[0].orders.count()).toBe(2); + expect(products[0].orders.getItems()[0]).toBeInstanceOf(Order); + expect(products[0].orders.getItems()[0].id).toBeDefined(); + expect(wrap(products[0].orders.getItems()[0]).isInitialized()).toBe(true); + expect(products[0].orders.isInitialized()).toBe(true); + const old = products[0]; + expect(products[1].orders.isInitialized()).toBe(false); + products = await productRepository.findAll({ populate: ['orders'] as const }); + expect(products[1].orders.isInitialized()).toBe(true); + expect(products[0].id).toBe(old.id); + expect(products[0]).toBe(old); + expect(products[0].orders).toBe(old.orders); + + // test M:N lazy load + orm.em.clear(); + let order = (await orm.em.findOne(Order, { products: product1.id }))!; + expect(order.products.isInitialized()).toBe(false); + await order.products.init(); + expect(order.products.isInitialized()).toBe(true); + expect(order.products.count()).toBe(2); + expect(order.products.getItems()[0]).toBeInstanceOf(Product); + expect(order.products.getItems()[0].id).toBeDefined(); + expect(wrap(order.products.getItems()[0]).isInitialized()).toBe(true); + + // test collection CRUD + // remove + expect(order.products.count()).toBe(2); + order.products.remove(t => t.id === product1.id); // we need to get reference as product1 is detached from current EM + await orm.em.persistAndFlush(order); + orm.em.clear(); + order = (await orm.em.findOne(Order, order.id, { populate: ['products'] as const }))!; + expect(order.products.count()).toBe(1); + + // add + order.products.add(productRepository.getReference(product1.id)); // we need to get reference as product1 is detached from current EM + const product6 = new Product('fresh', 555); + order.products.add(product6); + await orm.em.persistAndFlush(order); + orm.em.clear(); + order = (await orm.em.findOne(Order, order.id, { populate: ['products'] as const }))!; + expect(order.products.count()).toBe(3); + + // contains + expect(order.products.contains(productRepository.getReference(product1.id))).toBe(true); + expect(order.products.contains(productRepository.getReference(product2.id))).toBe(true); + expect(order.products.contains(productRepository.getReference(product3.id))).toBe(false); + expect(order.products.contains(productRepository.getReference(product4.id))).toBe(false); + expect(order.products.contains(productRepository.getReference(product5.id))).toBe(false); + expect(order.products.contains(productRepository.getReference(product6.id))).toBe(true); + + // removeAll + order.products.removeAll(); + await orm.em.persistAndFlush(order); + orm.em.clear(); + order = (await orm.em.findOne(Order, order.id, { populate: ['products'] as const }))!; + expect(order.products.count()).toBe(0); + }); + +});