From b9888742d70846019892f5079a4bce0b505fafc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ad=C3=A1mek?= Date: Sat, 8 Apr 2023 10:17:19 +0200 Subject: [PATCH] feat(core): rework serialization rules to always respect populate hint Closes #4138 --- packages/core/src/EntityManager.ts | 6 +- packages/core/src/drivers/DatabaseDriver.ts | 6 +- packages/core/src/drivers/IDatabaseDriver.ts | 2 +- packages/core/src/entity/Collection.ts | 58 ++++--- packages/core/src/entity/EntityLoader.ts | 11 -- packages/core/src/entity/WrappedEntity.ts | 6 +- .../src/serialization/EntityTransformer.ts | 29 +++- .../src/serialization/SerializationContext.ts | 12 +- packages/core/src/typings.ts | 1 - packages/core/src/unit-of-work/UnitOfWork.ts | 2 +- packages/knex/src/AbstractSqlDriver.ts | 3 +- tests/EntityManager.mongo.test.ts | 5 +- tests/features/serialization/GH4138.test.ts | 144 ++++++++++++++++++ tests/issues/GH222.test.ts | 2 +- 14 files changed, 223 insertions(+), 64 deletions(-) create mode 100644 tests/features/serialization/GH4138.test.ts diff --git a/packages/core/src/EntityManager.ts b/packages/core/src/EntityManager.ts index b0080799135b..112630115e8a 100644 --- a/packages/core/src/EntityManager.ts +++ b/packages/core/src/EntityManager.ts @@ -296,14 +296,14 @@ export class EntityManager { Fields extends string = '*', >(entityName: string, where: FilterQuery, options: FindOptions | FindOneOptions, type: 'read' | 'update' | 'delete'): Promise> { where = QueryHelper.processWhere({ - where: where as FilterQuery, + where, entityName, metadata: this.metadata, platform: this.driver.getPlatform(), convertCustomTypes: options.convertCustomTypes, aliased: type === 'read', }); - where = await this.applyFilters(entityName, where, options.filters ?? {}, type); + where = (await this.applyFilters(entityName, where, options.filters ?? {}, type))!; where = await this.applyDiscriminatorCondition(entityName, where); return where; @@ -334,7 +334,7 @@ export class EntityManager { /** * @internal */ - async applyFilters(entityName: string, where: FilterQuery, options: Dictionary | string[] | boolean, type: 'read' | 'update' | 'delete'): Promise> { + async applyFilters(entityName: string, where: FilterQuery | undefined, options: Dictionary | string[] | boolean, type: 'read' | 'update' | 'delete'): Promise | undefined> { const meta = this.metadata.find(entityName); const filters: FilterDef[] = []; const ret: Dictionary[] = []; diff --git a/packages/core/src/drivers/DatabaseDriver.ts b/packages/core/src/drivers/DatabaseDriver.ts index 71a43386ad2c..cb762faaa172 100644 --- a/packages/core/src/drivers/DatabaseDriver.ts +++ b/packages/core/src/drivers/DatabaseDriver.ts @@ -91,7 +91,7 @@ export abstract class DatabaseDriver implements IDatabaseD throw new Error(`Aggregations are not supported by ${this.constructor.name} driver`); } - async loadFromPivotTable(prop: EntityProperty, owners: Primary[][], where?: FilterQuery, orderBy?: QueryOrderMap[], ctx?: Transaction, options?: FindOptions): Promise> { + async loadFromPivotTable(prop: EntityProperty, owners: Primary[][], where?: FilterQuery, orderBy?: OrderDefinition, ctx?: Transaction, options?: FindOptions): Promise> { throw new Error(`${this.constructor.name} does not use pivot tables`); } @@ -313,9 +313,9 @@ export abstract class DatabaseDriver implements IDatabaseD }); } - protected getPivotOrderBy(prop: EntityProperty, orderBy?: QueryOrderMap[]): QueryOrderMap[] { + protected getPivotOrderBy(prop: EntityProperty, orderBy?: OrderDefinition): QueryOrderMap[] { if (!Utils.isEmpty(orderBy)) { - return orderBy!; + return orderBy as QueryOrderMap[]; } if (!Utils.isEmpty(prop.orderBy)) { diff --git a/packages/core/src/drivers/IDatabaseDriver.ts b/packages/core/src/drivers/IDatabaseDriver.ts index da5f3fe71c68..f4953f3f3da7 100644 --- a/packages/core/src/drivers/IDatabaseDriver.ts +++ b/packages/core/src/drivers/IDatabaseDriver.ts @@ -63,7 +63,7 @@ export interface IDatabaseDriver { /** * When driver uses pivot tables for M:N, this method will load identifiers for given collections from them */ - loadFromPivotTable(prop: EntityProperty, owners: Primary[][], where?: FilterQuery, orderBy?: QueryOrderMap[], ctx?: Transaction, options?: FindOptions): Promise>; + loadFromPivotTable(prop: EntityProperty, owners: Primary[][], where?: FilterQuery, orderBy?: OrderDefinition, ctx?: Transaction, options?: FindOptions): Promise>; getPlatform(): Platform; diff --git a/packages/core/src/entity/Collection.ts b/packages/core/src/entity/Collection.ts index 98bc010c0471..ac5ca01f9ef2 100644 --- a/packages/core/src/entity/Collection.ts +++ b/packages/core/src/entity/Collection.ts @@ -19,6 +19,7 @@ import { Reference } from './Reference'; import type { Transaction } from '../connections/Connection'; import type { FindOptions } from '../drivers/IDatabaseDriver'; import { helper } from './wrap'; +import type { EntityManager } from '../EntityManager'; export interface MatchingOptions extends FindOptions { where?: FilterQuery; @@ -30,8 +31,7 @@ export class Collection extends Arr private snapshot: T[] | undefined = []; // used to create a diff of the collection at commit time, undefined marks overridden values so we need to wipe when flushing private readonly?: boolean; - private _populated = false; - private _lazyInitialized = false; + private _populated?: boolean; private _em?: unknown; constructor(owner: O, items?: T[], initialized = true) { @@ -77,17 +77,25 @@ export class Collection extends Arr const em = this.getEntityManager(); const pivotMeta = em.getMetadata().find(this.property.pivotEntity)!; + const where = this.createLoadCountCondition(options.where ?? {} as FilterQuery, pivotMeta); if (!em.getPlatform().usesPivotTable() && this.property.kind === ReferenceKind.MANY_TO_MANY) { return this._count = this.length; - } else if (this.property.pivotTable && !(this.property.inversedBy || this.property.mappedBy)) { - const count = await em.count(this.property.type, this.createLoadCountCondition(options.where ?? {} as FilterQuery, pivotMeta), { populate: [{ field: this.property.pivotEntity }] }); + } + + if (this.property.pivotTable && !(this.property.inversedBy || this.property.mappedBy)) { + const count = await em.count(this.property.type, where, { + populate: [{ field: this.property.pivotEntity } as any], + }); + if (!options.where) { this._count = count; } + return count; } - const count = await em.count(this.property.type, this.createLoadCountCondition(options.where ?? {} as FilterQuery, pivotMeta)); + const count = await em.count(this.property.type, where); + if (!options.where) { this._count = count; } @@ -103,9 +111,9 @@ export class Collection extends Arr if (this.property.kind === ReferenceKind.MANY_TO_MANY && em.getPlatform().usesPivotTable()) { const cond = await em.applyFilters(this.property.type, where, options.filters ?? {}, 'read'); const map = await em.getDriver().loadFromPivotTable(this.property, [helper(this.owner).__primaryKeys], cond, opts.orderBy, ctx, options); - items = map[helper(this.owner).getSerializedPrimaryKey()].map((item: EntityData) => em.merge(this.property.type, item, { convertCustomTypes: true })); + items = map[helper(this.owner).getSerializedPrimaryKey()].map((item: EntityData) => em.merge(this.property.type, item, { convertCustomTypes: true })) as any; } else { - items = await em.find(this.property.type, this.createCondition(where), opts); + items = await em.find(this.property.type, this.createCondition(where), opts) as any; } if (options.store) { @@ -209,13 +217,20 @@ export class Collection extends Arr return super.count(); } - shouldPopulate(): boolean { - return this._populated && !this._lazyInitialized; + shouldPopulate(populated?: boolean): boolean { + if (!this.isInitialized(true)) { + return false; + } + + if (this._populated != null) { + return this._populated; + } + + return !!populated; } - populated(populated = true): void { + populated(populated: boolean | undefined = true): void { this._populated = populated; - this._lazyInitialized = false; } async init(options: InitOptions = {}): Promise>> { @@ -234,7 +249,6 @@ export class Collection extends Arr const cond = await em.applyFilters(this.property.type, options.where, {}, 'read'); const map = await em.getDriver().loadFromPivotTable(this.property, [helper(this.owner).__primaryKeys], cond, options.orderBy, undefined, options); this.hydrate(map[helper(this.owner).getSerializedPrimaryKey()].map((item: EntityData) => em.merge(this.property.type, item, { convertCustomTypes: true })), true); - this._lazyInitialized = true; return this as unknown as LoadedCollection>; } @@ -243,18 +257,17 @@ export class Collection extends Arr if (this.property.kind === ReferenceKind.MANY_TO_MANY && (this.property.owner || em.getPlatform().usesPivotTable()) && this.length === 0) { this.initialized = true; this.dirty = false; - this._lazyInitialized = true; return this as unknown as LoadedCollection>; } - const where = this.createCondition(options.where as FilterQuery); + const where = this.createCondition(options.where); const order = [...this.items]; // copy order of references const customOrder = !!options.orderBy; - const items: T[] = await em.find(this.property.type, where, { + const items: TT[] = await em.find(this.property.type, where, { populate: options.populate, lockMode: options.lockMode, - orderBy: this.createOrderBy(options.orderBy as QueryOrderMap), + orderBy: this.createOrderBy(options.orderBy as QueryOrderMap), connectionType: options.connectionType, schema: this.property.targetMeta!.schema === '*' ? helper(this.owner).__schema @@ -274,7 +287,6 @@ export class Collection extends Arr this.initialized = true; this.dirty = false; - this._lazyInitialized = true; return this as unknown as LoadedCollection>; } @@ -320,12 +332,12 @@ export class Collection extends Arr throw ValidationError.entityNotManaged(this.owner); } - return em; + return em as EntityManager; } - private createCondition(cond: FilterQuery = {}): FilterQuery { + private createCondition(cond: FilterQuery = {}): FilterQuery { if (this.property.kind === ReferenceKind.ONE_TO_MANY) { - cond[this.property.mappedBy as FilterKey] = helper(this.owner).getPrimaryKey() as any; + cond[this.property.mappedBy as FilterKey] = helper(this.owner).getPrimaryKey() as any; } else { // MANY_TO_MANY this.createManyToManyCondition(cond); } @@ -333,18 +345,18 @@ export class Collection extends Arr return cond; } - private createOrderBy(orderBy: QueryOrderMap | QueryOrderMap[] = []): QueryOrderMap[] { + private createOrderBy(orderBy: QueryOrderMap | QueryOrderMap[] = []): QueryOrderMap[] { if (Utils.isEmpty(orderBy) && this.property.kind === ReferenceKind.ONE_TO_MANY) { const defaultOrder = this.property.referencedColumnNames.map(name => { return { [name]: QueryOrder.ASC }; }); - orderBy = this.property.orderBy as QueryOrderMap || defaultOrder; + orderBy = this.property.orderBy as QueryOrderMap || defaultOrder; } return Utils.asArray(orderBy); } - private createManyToManyCondition(cond: FilterQuery) { + private createManyToManyCondition(cond: FilterQuery) { const dict = cond as Dictionary; if (this.property.owner || this.property.pivotTable) { diff --git a/packages/core/src/entity/EntityLoader.ts b/packages/core/src/entity/EntityLoader.ts index 5960d7356c94..c09e99f2c411 100644 --- a/packages/core/src/entity/EntityLoader.ts +++ b/packages/core/src/entity/EntityLoader.ts @@ -193,17 +193,6 @@ export class EntityLoader { return []; } - // set populate flag - entities.forEach(entity => { - const value = entity[field]; - - if (Utils.isEntity(value, true)) { - (value as AnyEntity).__helper!.populated(); - } else if (Utils.isCollection(value)) { - (value as Collection).populated(); - } - }); - const filtered = this.filterCollections(entities, field, options.refresh); const innerOrderBy = Utils.asArray(options.orderBy) .filter(orderBy => Utils.isObject(orderBy[prop.name])) diff --git a/packages/core/src/entity/WrappedEntity.ts b/packages/core/src/entity/WrappedEntity.ts index c86174aab7fb..cebf901307a0 100644 --- a/packages/core/src/entity/WrappedEntity.ts +++ b/packages/core/src/entity/WrappedEntity.ts @@ -20,7 +20,6 @@ export class WrappedEntity { __initialized = true; __touched = false; __populated?: boolean; - __lazyInitialized?: boolean; __managed?: boolean; __onLoadFired?: boolean; __schema?: string; @@ -56,9 +55,8 @@ export class WrappedEntity { return this.__touched; } - populated(populated = true): void { + populated(populated: boolean | undefined = true): void { this.__populated = populated; - this.__lazyInitialized = false; } toReference(): Ref & LoadedReference>> { @@ -93,8 +91,6 @@ export class WrappedEntity { } await this.__em.findOne(this.entity.constructor.name, this.entity, { refresh: true, lockMode, populate, connectionType, schema: this.__schema }); - this.populated(populated); - this.__lazyInitialized = true; return this.entity; } diff --git a/packages/core/src/serialization/EntityTransformer.ts b/packages/core/src/serialization/EntityTransformer.ts index e7b08d8ae839..51493212252c 100644 --- a/packages/core/src/serialization/EntityTransformer.ts +++ b/packages/core/src/serialization/EntityTransformer.ts @@ -55,13 +55,14 @@ export class EntityTransformer { [...keys] .filter(prop => raw ? meta.properties[prop] : isVisible(meta, prop, ignoreFields)) .map(prop => { + const populated = root.isMarkedAsPopulated(meta.className, prop); const cycle = root.visit(meta.className, prop); if (cycle && visited) { return [prop, undefined]; } - const val = EntityTransformer.processProperty(prop, entity, raw); + const val = EntityTransformer.processProperty(prop, entity, raw, populated); if (!cycle) { root.leave(meta.className, prop); @@ -109,7 +110,7 @@ export class EntityTransformer { return prop; } - private static processProperty(prop: EntityKey, entity: Entity, raw: boolean): EntityValue | undefined { + private static processProperty(prop: EntityKey, entity: Entity, raw: boolean, populated: boolean): EntityValue | undefined { const wrapped = helper(entity); const property = wrapped.__meta.properties[prop]; const serializer = property?.serializer; @@ -119,11 +120,11 @@ export class EntityTransformer { } if (Utils.isCollection(entity[prop])) { - return EntityTransformer.processCollection(prop, entity, raw); + return EntityTransformer.processCollection(prop, entity, raw, populated); } if (Utils.isEntity(entity[prop], true)) { - return EntityTransformer.processEntity(prop, entity, wrapped.__platform, raw); + return EntityTransformer.processEntity(prop, entity, wrapped.__platform, raw, populated); } if (property.kind === ReferenceKind.EMBEDDED) { @@ -147,7 +148,7 @@ export class EntityTransformer { return wrapped.__platform.normalizePrimaryKey(entity[prop] as unknown as IPrimaryKey) as unknown as EntityValue; } - private static processEntity(prop: keyof Entity, entity: Entity, platform: Platform, raw: boolean): EntityValue | undefined { + private static processEntity(prop: keyof Entity, entity: Entity, platform: Platform, raw: boolean, populated: boolean): EntityValue | undefined { const child = entity[prop] as unknown as Entity | Reference; const wrapped = helper(child); @@ -155,7 +156,19 @@ export class EntityTransformer { return wrapped.toPOJO() as unknown as EntityValue; } - if (wrapped.isInitialized() && (wrapped.__populated || !wrapped.__managed) && child !== entity && !wrapped.__lazyInitialized) { + function isPopulated() { + if (wrapped.__populated != null) { + return wrapped.__populated; + } + + if (populated) { + return true; + } + + return !wrapped.__managed; + } + + if (wrapped.isInitialized() && isPopulated() && child !== entity) { const args = [...wrapped.__meta.toJsonParams.map(() => undefined)]; return wrap(child).toJSON(...args) as EntityValue; } @@ -163,14 +176,14 @@ export class EntityTransformer { return platform.normalizePrimaryKey(wrapped.getPrimaryKey() as IPrimaryKey) as unknown as EntityValue; } - private static processCollection(prop: keyof Entity, entity: Entity, raw: boolean): EntityValue | undefined { + private static processCollection(prop: keyof Entity, entity: Entity, raw: boolean, populated: boolean): EntityValue | undefined { const col = entity[prop] as Collection; if (raw && col.isInitialized(true)) { return col.getItems().map(item => wrap(item).toPOJO()) as EntityValue; } - if (col.isInitialized(true) && col.shouldPopulate()) { + if (col.shouldPopulate(populated)) { return col.toArray() as EntityValue; } diff --git a/packages/core/src/serialization/SerializationContext.ts b/packages/core/src/serialization/SerializationContext.ts index b7dbae4d63e6..2b4a243d34a8 100644 --- a/packages/core/src/serialization/SerializationContext.ts +++ b/packages/core/src/serialization/SerializationContext.ts @@ -16,6 +16,9 @@ export class SerializationContext { constructor(private readonly populate: PopulateOptions[] = []) {} + /** + * Returns true when there is a cycle detected. + */ visit(entityName: string, prop: string): boolean { if (!this.path.find(([cls, item]) => entityName === cls && prop === item)) { this.path.push([entityName, prop]); @@ -23,7 +26,7 @@ export class SerializationContext { } // check if the path is explicitly populated - if (!this.isMarkedAsPopulated(prop)) { + if (!this.isMarkedAsPopulated(entityName, prop)) { return true; } @@ -69,7 +72,7 @@ export class SerializationContext { .forEach(item => this.propagate(root, item, isVisible)); } - private isMarkedAsPopulated(prop: string): boolean { + isMarkedAsPopulated(entityName: string, prop: string): boolean { let populate: PopulateOptions[] | undefined = this.populate; for (const segment of this.path) { @@ -80,6 +83,11 @@ export class SerializationContext { const exists = populate.find(p => p.field === segment[1]) as PopulateOptions; if (exists) { + // we need to check for cycles here too, as we could fall into endless loops for bidirectional relations + if (exists.all) { + return !this.path.find(([cls, item]) => entityName === cls && prop === item); + } + populate = exists.children as PopulateOptions[]; } } diff --git a/packages/core/src/typings.ts b/packages/core/src/typings.ts index 1b5b30e32ed9..6d2c7b2ad4d6 100644 --- a/packages/core/src/typings.ts +++ b/packages/core/src/typings.ts @@ -156,7 +156,6 @@ export interface IWrappedEntityInternal< __populated: boolean; __onLoadFired: boolean; __reference?: Ref; - __lazyInitialized: boolean; __pk?: Primary; __primaryKeys: Primary[]; __serializationContext: { root?: SerializationContext; populate?: PopulateOptions[] }; diff --git a/packages/core/src/unit-of-work/UnitOfWork.ts b/packages/core/src/unit-of-work/UnitOfWork.ts index fb70d361aa2c..2cae55c37b5a 100644 --- a/packages/core/src/unit-of-work/UnitOfWork.ts +++ b/packages/core/src/unit-of-work/UnitOfWork.ts @@ -699,7 +699,7 @@ export class UnitOfWork { if ([ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(prop.kind) && collection) { if (type === Cascade.MERGE && collection.isInitialized()) { - collection.populated(); + // collection.populated(); } collection diff --git a/packages/knex/src/AbstractSqlDriver.ts b/packages/knex/src/AbstractSqlDriver.ts index 8397816e5ab9..ef68d284d597 100644 --- a/packages/knex/src/AbstractSqlDriver.ts +++ b/packages/knex/src/AbstractSqlDriver.ts @@ -39,6 +39,7 @@ import { EntityManagerType, helper, LoadStrategy, + OrderDefinition, QueryFlag, QueryHelper, raw, @@ -664,7 +665,7 @@ export abstract class AbstractSqlDriver(meta, coll.property, pks, deleteDiff, insertDiff, options)); } - override async loadFromPivotTable(prop: EntityProperty, owners: Primary[][], where: FilterQuery = {} as FilterQuery, orderBy?: QueryOrderMap[], ctx?: Transaction, options?: FindOptions): Promise> { + override async loadFromPivotTable(prop: EntityProperty, owners: Primary[][], where: FilterQuery = {} as FilterQuery, orderBy?: OrderDefinition, ctx?: Transaction, options?: FindOptions): Promise> { const pivotProp2 = this.getPivotInverseProperty(prop); const ownerMeta = this.metadata.find(pivotProp2.type)!; const pivotMeta = this.metadata.find(prop.pivotEntity)!; diff --git a/tests/EntityManager.mongo.test.ts b/tests/EntityManager.mongo.test.ts index bb4cb6a4dde8..3b9573a2e2e2 100644 --- a/tests/EntityManager.mongo.test.ts +++ b/tests/EntityManager.mongo.test.ts @@ -1893,14 +1893,11 @@ describe('EntityManagerMongo', () => { expect(ref4.isInitialized()).toBe(true); expect(ref4.getProperty('name')).toBe('God'); await expect(ref4.load('email')).resolves.toBe('hello@heaven.god'); - expect(wrap(ref4, true).__populated).toBe(true); - expect(wrap(ref4, true).__lazyInitialized).toBe(true); + expect(wrap(ref4, true).__populated).toBeUndefined(); ref4.populated(false); - expect(wrap(ref4, true).__lazyInitialized).toBe(false); expect(wrap(ref4, true).__populated).toBe(false); ref4.populated(); expect(wrap(ref4, true).__populated).toBe(true); - expect(wrap(ref4, true).__lazyInitialized).toBe(false); expect(ref4.toJSON()).toMatchObject({ name: 'God', }); diff --git a/tests/features/serialization/GH4138.test.ts b/tests/features/serialization/GH4138.test.ts new file mode 100644 index 000000000000..af7146f1e2d3 --- /dev/null +++ b/tests/features/serialization/GH4138.test.ts @@ -0,0 +1,144 @@ +import { Collection, Entity, ManyToOne, OneToMany, PrimaryKey, Property, wrap } from '@mikro-orm/core'; +import { MikroORM } from '@mikro-orm/sqlite'; + +@Entity() +class User { + + @PrimaryKey() + id!: number; + + @Property() + name!: string; + + @Property() + email!: string; + + @OneToMany(() => Shop, shop => shop.owner) + shop = new Collection(this); + + @OneToMany(() => Product, product => product.owner) + product = new Collection(this); + +} + +@Entity() +class Shop { + + @PrimaryKey() + id!: number; + + @Property() + name!: string; + + @OneToMany(() => Product, product => product.shop) + products = new Collection(this); + + @ManyToOne(() => User) + owner!: User; + +} + +@Entity() +export class Product { + + @PrimaryKey() + id!: number; + + @Property() + name!: string; + + @ManyToOne(() => Shop) + shop!: Shop; + + @ManyToOne(() => User) + owner!: User; + +} + +let orm: MikroORM; + +beforeAll(async () => { + orm = await MikroORM.init({ + entities: [User, Shop, Product], + dbName: ':memory:', + }); + await orm.schema.refreshDatabase(); +}); + +afterAll(() => orm.close()); + +beforeEach(async () => { + orm.em.create(User, { + name: 's1', + email: 'sp-1@yopmail.com', + }); + orm.em.create(User, { + name: 'sp-2', + email: 'sp-2@yopmail.com', + }); + orm.em.create(Shop, { + name: 'shop-1', + owner: 1, + }); + orm.em.create(Product, { + name: 'product-1', + shop: 1, + owner: 1, + }); + orm.em.create(Product, { + name: 'product-2', + shop: 1, + owner: 2, + }); + + await orm.em.flush(); + orm.em.clear(); +}); + +test('serialization', async () => { + const [shop] = await orm.em.find(Shop, {}, { + populate: ['products', 'owner'], + }); + + expect(wrap(shop).toObject()).toEqual({ + id: 1, + name: 'shop-1', + products: [ + { id: 1, name: 'product-1', shop: 1, owner: 1 }, + { id: 2, name: 'product-2', shop: 1, owner: 2 }, + ], + owner: { id: 1, name: 's1', email: 'sp-1@yopmail.com' }, + }); + + wrap(shop.owner).populated(false); + expect(wrap(shop).toObject()).toEqual({ + id: 1, + name: 'shop-1', + products: [ + { id: 1, name: 'product-1', shop: 1, owner: 1 }, + { id: 2, name: 'product-2', shop: 1, owner: 2 }, + ], + owner: 1, + }); + + wrap(shop.products).populated(false); + expect(wrap(shop).toObject()).toEqual({ + id: 1, + name: 'shop-1', + products: [1, 2], + owner: 1, + }); + + wrap(shop.products).populated(); + wrap(shop.owner).populated(); // populates both occurrences + expect(wrap(shop).toObject()).toEqual({ + id: 1, + name: 'shop-1', + products: [ + { id: 1, name: 'product-1', shop: 1, owner: { id: 1, name: 's1', email: 'sp-1@yopmail.com' } }, + { id: 2, name: 'product-2', shop: 1, owner: 2 }, + ], + owner: { id: 1, name: 's1', email: 'sp-1@yopmail.com' }, + }); +}); + diff --git a/tests/issues/GH222.test.ts b/tests/issues/GH222.test.ts index a4b2081ce426..692f8607138c 100644 --- a/tests/issues/GH222.test.ts +++ b/tests/issues/GH222.test.ts @@ -93,7 +93,7 @@ describe('GH issue 222', () => { await orm.em.persistAndFlush(c); orm.em.clear(); - const cc = await orm.em.findOneOrFail(C, c.id); + const cc = await orm.em.findOneOrFail(C, c.id, { populate: ['a'] }); expect(cc.bCollection.count()).toBe(1); expect(cc.a.prop).toEqual(cc.bCollection[0].a.prop); const ccJson = wrap(cc).toJSON();