From 7bea77c9d19fdb4d88cc413a2efba156f13aa2b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ad=C3=A1mek?= Date: Tue, 11 Apr 2023 22:13:20 +0200 Subject: [PATCH] feat(core): rework serialization rules to always respect populate hint (#4203) Implicit serialization, so calling `toObject()` or `toJSON()` on the entity, as opposed to explicitly using the `serialize()` helper, now works entirely based on `populate` hints. This means that, unless you explicitly marked some entity as populated via `wrap(entity).populated()`, it will be part of the serialized form only if it was part of the `populate` hint: ```ts // let's say both Author and Book entity has a m:1 relation to Publisher entity // we only populate the publisher relation of the Book entity const user = await em.findOneOrFail(Author, 1, { populate: ['books.publisher'], }); const dto = wrap(user).toObject(); console.log(dto.publisher); // only the FK, e.g. `123` console.log(dto.books[0].publisher); // populated, e.g. `{ id: 123, name: '...' }` ``` Moreover, the implicit serialization now respects the partial loading hints too. Previously, all loaded properties were serialized, partial loading worked only on the database query level. Since v6, we also prune the data on runtime. This means that unless the property is part of the partial loading hint (`fields` option), it won't be part of the DTO - only exception is the primary key, you can optionally hide it via `hidden: true` in the property options. Main difference here will be the foreign keys, those are often automatically selected as they are needed to build the entity graph, but will no longer be part of the DTO. ```ts const user = await em.findOneOrFail(Author, 1, { fields: ['books.publisher.name'], }); const dto = wrap(user).toObject(); // only the publisher's name will be available, previously there would be also `book.author` // `{ id: 1, books: [{ id: 2, publisher: { id: 3, name: '...' } }] }` ``` **This also works for embeddables, including nesting and object mode.** This method used to return a single object conditionally based on its inputs, but the solution broke intellisense for the `populate` option. The method signature still accepts single object or an array of objects, but always returns an array. To serialize single entity, you can use array destructing, or use `wrap(entity).serialize()` which handles a single entity only. ```ts const dtos = serialize([user1, user, ...], { exclude: ['id', 'email'], forceObject: true }); const [dto1] = serialize(user, { exclude: ['id', 'email'], forceObject: true }); const dto2 = wrap(user).serialize({ exclude: ['id', 'email'], forceObject: true }); ``` Closes #4138 Closes #4199 --- docs/docs/serializing.md | 56 ++++- docs/docs/upgrading-v5-to-v6.md | 42 ++++ .../src/commands/MigrationCommandFactory.ts | 2 +- packages/core/src/EntityManager.ts | 70 ++++-- packages/core/src/decorators/Entity.ts | 2 +- packages/core/src/drivers/DatabaseDriver.ts | 8 +- packages/core/src/drivers/IDatabaseDriver.ts | 12 +- packages/core/src/entity/BaseEntity.ts | 5 +- packages/core/src/entity/Collection.ts | 78 +++--- packages/core/src/entity/EntityFactory.ts | 13 +- packages/core/src/entity/EntityHelper.ts | 1 + packages/core/src/entity/EntityLoader.ts | 62 ++--- packages/core/src/entity/EntityRepository.ts | 14 +- packages/core/src/entity/WrappedEntity.ts | 27 ++- packages/core/src/enums.ts | 10 +- .../src/serialization/EntitySerializer.ts | 51 ++-- .../src/serialization/EntityTransformer.ts | 41 +++- .../src/serialization/SerializationContext.ts | 39 ++- packages/core/src/types/Uint8ArrayType.ts | 10 +- packages/core/src/typings.ts | 57 +++-- packages/core/src/unit-of-work/IdentityMap.ts | 2 +- packages/core/src/unit-of-work/UnitOfWork.ts | 23 +- packages/core/src/utils/Configuration.ts | 6 + packages/core/src/utils/Cursor.ts | 3 +- packages/core/src/utils/QueryHelper.ts | 2 +- packages/core/src/utils/Utils.ts | 2 +- packages/knex/src/AbstractSqlDriver.ts | 10 +- .../knex/src/schema/SqlSchemaGenerator.ts | 2 +- packages/mongodb/src/MongoDriver.ts | 2 +- tests/EntityHelper.mongo.test.ts | 6 +- tests/EntityManager.mongo.test.ts | 5 +- tests/EntityManager.postgre.test.ts | 6 +- tests/Utils.test.ts | 2 +- tests/entities/Author.ts | 4 +- tests/entities/Book.ts | 27 ++- tests/entities/FooBar.ts | 3 + .../custom-types/json-properties.test.ts | 12 +- .../nested-embeddables.postgres.test.ts | 8 +- .../assign-collection-items.test.ts | 4 +- .../EntityGenerator.test.ts.snap | 14 +- .../features/filters/filters.postgres.test.ts | 2 +- .../partial-loading.mysql.test.ts | 2 - .../check-constraint.mysql.test.ts | 4 +- tests/features/serialization/GH3788.test.ts | 4 +- .../explicit-serialization.test.ts} | 24 +- .../implicit-serialization.test.ts | 227 ++++++++++++++++++ ...haring-column-in-composite-pk-fk-2.test.ts | 2 +- tests/features/unit-of-work/GH4284.test.ts | 2 +- tests/features/upsert/upsert.test.ts | 20 +- tests/issues/GH222.test.ts | 2 +- tests/issues/GH4377.test.ts | 4 +- tests/issues/GH4533.test.ts | 2 +- tests/issues/GH4720.test.ts | 7 +- 53 files changed, 755 insertions(+), 290 deletions(-) rename tests/features/{serialize.test.ts => serialization/explicit-serialization.test.ts} (90%) create mode 100644 tests/features/serialization/implicit-serialization.test.ts diff --git a/docs/docs/serializing.md b/docs/docs/serializing.md index d7c9dedaf98c..91ef6ee1d090 100644 --- a/docs/docs/serializing.md +++ b/docs/docs/serializing.md @@ -92,6 +92,41 @@ const book = new Book(author); console.log(wrap(book).toJSON().authorName); // 'God' ``` +## Implicit serialization + +Implicit serialization means calling `toObject()` or `toJSON()` on the entity, as opposed to explicitly using the `serialize()` helper. Since v6, it works entirely based on `populate` hints. This means that, unless you explicitly marked some entity as populated via `wrap(entity).populated()`, it will be part of the serialized form only if it was part of the `populate` hint: + +```ts +// let's say both Author and Book entity has a m:1 relation to Publisher entity +// we only populate the publisher relation of the Book entity +const user = await em.findOneOrFail(Author, 1, { + populate: ['books.publisher'], +}); + +const dto = wrap(user).toObject(); +console.log(dto.publisher); // only the FK, e.g. `123` +console.log(dto.books[0].publisher); // populated, e.g. `{ id: 123, name: '...' }` +``` + +Moreover, the implicit serialization now respects the partial loading hints too. Previously, all loaded properties were serialized, partial loading worked only on the database query level. Since v6, we also prune the data on runtime. This means that unless the property is part of the partial loading hint (`fields` option), it won't be part of the DTO. Main difference here is the primary and foreign keys, that are often automatically selected as they are needed to build the entity graph, but will no longer be part of the DTO. + +```ts +const user = await em.findOneOrFail(Author, 1, { + fields: ['books.publisher.name'], +}); + +const dto = wrap(user).toObject(); +// only the publisher's name will be available + primary keys +// `{ id: 1, books: [{ id: 2, publisher: { id: 3, name: '...' } }] }` +``` + +Primary keys are automatically included. If you want to hide them, you have two options: + +- use `hidden: true` in the property options +- use `serialization: { includePrimaryKeys: false }` in the ORM config + +**This also works for embeddables, including nesting and object mode.** + ## Explicit serialization The serialization process is normally driven by the `populate` hints. If you want to take control over this, you can use the `serialize()` helper: @@ -99,22 +134,29 @@ The serialization process is normally driven by the `populate` hints. If you wan ```ts import { serialize } from '@mikro-orm/core'; -const dto = serialize(user); // serialize single entity +const dtos = serialize([user1, user2]); +// [ +// { name: '...', books: [1, 2, 3], identity: 123 }, +// { name: '...', ... }, +// ] + +const [dto] = serialize(user1); // always returns an array // { name: '...', books: [1, 2, 3], identity: 123 } -const dtos = serialize(users); // supports arrays as well -// [{ name: '...', books: [1, 2, 3], identity: 123 }, ...] +// for a single entity instance we can as well use `wrap(e).serialize()` +const dto2 = wrap(user1).serialize(); +// { name: '...', books: [1, 2, 3], identity: 123 } ``` By default, every relation is considered as not populated - this will result in the foreign key values to be present. Loaded collections will be represented as arrays of the foreign keys. To control the shape of the serialized response we can use the second `options` parameter: ```ts -export interface SerializeOptions { +export interface SerializeOptions { /** Specify which relation should be serialized as populated and which as a FK. */ populate?: AutoPath[] | boolean; /** Specify which properties should be omitted. */ - exclude?: AutoPath[]; + exclude?: AutoPath[]; /** Enforce unpopulated references to be returned as objects, e.g. `{ author: { id: 1 } }` instead of `{ author: 1 }`. */ forceObject?: boolean; @@ -130,9 +172,9 @@ export interface SerializeOptions { Here is a more complex example: ```ts -import { serialize } from '@mikro-orm/core'; +import { wrap } from '@mikro-orm/core'; -const dto = serialize(author, { +const dto = wrap(author).serialize({ populate: ['books.author', 'books.publisher', 'favouriteBook'], // populate some relations exclude: ['books.author.email'], // skip property of some relation forceObject: true, // not populated or not initialized relations will result in object, e.g. `{ author: { id: 1 } }` diff --git a/docs/docs/upgrading-v5-to-v6.md b/docs/docs/upgrading-v5-to-v6.md index 90e4fef5ea45..f72ae81b274d 100644 --- a/docs/docs/upgrading-v5-to-v6.md +++ b/docs/docs/upgrading-v5-to-v6.md @@ -234,3 +234,45 @@ console.log(ref.age); // real value is available after flush ## Metadata CacheAdapter requires sync API To allow working with cache inside `MikroORM.initSync`, the metadata cache now enforces sync API. You should usually depend on the file-based cache for the metadata, which now uses sync methods to work with the file system. + +## Implicit serialization changes + +Implicit serialization, so calling `toObject()` or `toJSON()` on the entity, as opposed to explicitly using the `serialize()` helper, now works entirely based on `populate` hints. This means that, unless you explicitly marked some entity as populated via `wrap(entity).populated()`, it will be part of the serialized form only if it was part of the `populate` hint: + +```ts +// let's say both Author and Book entity has a m:1 relation to Publisher entity +// we only populate the publisher relation of the Book entity +const user = await em.findOneOrFail(Author, 1, { + populate: ['books.publisher'], +}); + +const dto = wrap(user).toObject(); +console.log(dto.publisher); // only the FK, e.g. `123` +console.log(dto.books[0].publisher); // populated, e.g. `{ id: 123, name: '...' }` +``` + +Moreover, the implicit serialization now respects the partial loading hints too. Previously, all loaded properties were serialized, partial loading worked only on the database query level. Since v6, we also prune the data on runtime. This means that unless the property is part of the partial loading hint (`fields` option), it won't be part of the DTO - only exception is the primary key, you can optionally hide it via `hidden: true` in the property options. Main difference here will be the foreign keys, those are often automatically selected as they are needed to build the entity graph, but will no longer be part of the DTO. + +```ts +const user = await em.findOneOrFail(Author, 1, { + fields: ['books.publisher.name'], +}); + +const dto = wrap(user).toObject(); +// only the publisher's name will be available, previously there would be also `book.author` +// `{ id: 1, books: [{ id: 2, publisher: { id: 3, name: '...' } }] }` +``` + +**This also works for embeddables, including nesting and object mode.** + +## `serialize` helper always returns array + +This method used to return a single object conditionally based on its inputs, but the solution broke intellisense for the `populate` option. The method signature still accepts single object or an array of objects, but always returns an array. + +To serialize single entity, you can use array destructing, or use `wrap(entity).serialize()` which handles a single entity only. + +```ts +const dtos = serialize([user1, user, ...], { exclude: ['id', 'email'], forceObject: true }); +const [dto1] = serialize(user, { exclude: ['id', 'email'], forceObject: true }); +const dto2 = wrap(user).serialize({ exclude: ['id', 'email'], forceObject: true }); +``` diff --git a/packages/cli/src/commands/MigrationCommandFactory.ts b/packages/cli/src/commands/MigrationCommandFactory.ts index 1652188964bc..3d6dbb88e9d3 100644 --- a/packages/cli/src/commands/MigrationCommandFactory.ts +++ b/packages/cli/src/commands/MigrationCommandFactory.ts @@ -1,5 +1,5 @@ import type { ArgumentsCamelCase, Argv, CommandModule } from 'yargs'; -import { Utils, colors, type Configuration, type Dictionary, type MikroORM, type Options, type IMigrator, MigrateOptions } from '@mikro-orm/core'; +import { Utils, colors, type Configuration, type Dictionary, type MikroORM, type Options, type IMigrator, type MigrateOptions } from '@mikro-orm/core'; import { CLIHelper } from '../CLIHelper'; export class MigrationCommandFactory { diff --git a/packages/core/src/EntityManager.ts b/packages/core/src/EntityManager.ts index 973ae539da22..217be4b563a6 100644 --- a/packages/core/src/EntityManager.ts +++ b/packages/core/src/EntityManager.ts @@ -59,6 +59,7 @@ import type { RequiredEntityData, Ref, EntityKey, + AnyString, } from './typings'; import { EventType, @@ -173,7 +174,7 @@ export class EntityManager { async find< Entity extends object, Hint extends string = never, - Fields extends string = '*', + Fields extends string = never, >(entityName: EntityName, where: FilterQuery, options: FindOptions = {}): Promise[]> { if (options.disableIdentityMap ?? this.config.get('disableIdentityMap')) { const em = this.getContext(false); @@ -320,17 +321,17 @@ export class EntityManager { protected async processWhere< Entity extends object, Hint extends string = never, - Fields extends string = '*', + Fields extends string = never, >(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, options); + where = (await this.applyFilters(entityName, where, options.filters ?? {}, type, options))!; where = await this.applyDiscriminatorCondition(entityName, where); return where; @@ -377,10 +378,10 @@ export class EntityManager { continue; } - const where = await this.applyFilters(prop.type, {}, options.filters ?? {}, 'read', { ...options, populate: hint.children }); + const where = await this.applyFilters(prop.type, {}, options.filters ?? {}, 'read', { ...options, populate: hint.children }); const where2 = await this.applyJoinedFilters(prop.targetMeta!, {} as ObjectQuery, { ...options, populate: hint.children as any, populateWhere: PopulateHint.ALL }); - if (Utils.hasObjectKeys(where)) { + if (Utils.hasObjectKeys(where!)) { ret[hint.field] = ret[hint.field] ? { $and: [where, ret[hint.field]] } : where as any; } @@ -400,7 +401,7 @@ export class EntityManager { /** * @internal */ - async applyFilters(entityName: string, where: FilterQuery, options: Dictionary | string[] | boolean, type: 'read' | 'update' | 'delete', findOptions?: FindOptions | FindOneOptions): Promise> { + async applyFilters(entityName: string, where: FilterQuery | undefined, options: Dictionary | string[] | boolean, type: 'read' | 'update' | 'delete', findOptions?: FindOptions | FindOneOptions): Promise | undefined> { const meta = this.metadata.find(entityName); const filters: FilterDef[] = []; const ret: Dictionary[] = []; @@ -462,7 +463,7 @@ export class EntityManager { async findAndCount< Entity extends object, Hint extends string = never, - Fields extends string = '*', + Fields extends string = never, >(entityName: EntityName, where: FilterQuery, options: FindOptions = {}): Promise<[Loaded[], number]> { const em = this.getContext(false); const [entities, count] = await Promise.all([ @@ -528,7 +529,7 @@ export class EntityManager { async findByCursor< Entity extends object, Hint extends string = never, - Fields extends string = '*', + Fields extends string = never, >(entityName: EntityName, where: FilterQuery, options: FindByCursorOptions = {}): Promise> { const em = this.getContext(false); entityName = Utils.className(entityName); @@ -549,7 +550,7 @@ export class EntityManager { async refresh< Entity extends object, Hint extends string = never, - Fields extends string = '*', + Fields extends string = never, >(entity: Entity, options: FindOneOptions = {}): Promise | null> { const fork = this.fork(); const entityName = entity.constructor.name; @@ -574,7 +575,7 @@ export class EntityManager { async findOne< Entity extends object, Hint extends string = never, - Fields extends string = '*', + Fields extends string = never, >(entityName: EntityName, where: FilterQuery, options: FindOneOptions = {}): Promise | null> { if (options.disableIdentityMap ?? this.config.get('disableIdentityMap')) { const em = this.getContext(false); @@ -654,7 +655,7 @@ export class EntityManager { async findOneOrFail< Entity extends object, Hint extends string = never, - Fields extends string = '*', + Fields extends string = never, >(entityName: EntityName, where: FilterQuery, options: FindOneOrFailOptions = {}): Promise> { let entity: Loaded | null; let isStrictViolation = false; @@ -794,7 +795,7 @@ export class EntityManager { const where = {} as FilterQuery; uniqueFields.forEach(prop => where[prop as EntityKey] = data![prop as EntityKey]); const data2 = await this.driver.findOne(meta.className, where, { - fields: returning as EntityField[], + fields: returning as any[], ctx: em.transactionContext, convertCustomTypes: true, }); @@ -1000,7 +1001,7 @@ export class EntityManager { }); const data2 = await this.driver.find(meta.className, where, { - fields: returning.concat(...add).concat(...uniqueFields as string[]), + fields: returning.concat(...add).concat(...uniqueFields as string[]) as any, ctx: em.transactionContext, convertCustomTypes: true, }); @@ -1537,7 +1538,7 @@ export class EntityManager { /** * @internal */ - async tryFlush(entityName: EntityName, options: { flushMode?: FlushMode }): Promise { + async tryFlush(entityName: EntityName, options: { flushMode?: FlushMode | AnyString }): Promise { const em = this.getContext(); const flushMode = options.flushMode ?? em.flushMode ?? em.config.get('flushMode'); entityName = Utils.className(entityName); @@ -1793,7 +1794,7 @@ export class EntityManager { return entity as Loaded; } - private buildFields(fields: readonly EntityField[]): readonly AutoPath[] { + private buildFields(fields: readonly EntityField[]): string[] { return fields.reduce((ret, f) => { if (Utils.isPlainObject(f)) { Utils.keys(f).forEach(ff => ret.push(...this.buildFields(f[ff]!).map(field => `${ff as string}.${field}` as never))); @@ -1808,15 +1809,42 @@ export class EntityManager { private preparePopulate< Entity extends object, Hint extends string = never, - Fields extends string = '*', + Fields extends string = never, >(entityName: string, options: Pick, 'populate' | 'strategy' | 'fields'>): PopulateOptions[] { // infer populate hint if only `fields` are available if (!options.populate && options.fields) { - options.populate = this.buildFields(options.fields) as any; + const meta = this.metadata.find(entityName)!; + // we need to prune the `populate` hint from to-one relations, as partially loading them does not require their population, we want just the FK + const pruneToOneRelations = (meta: EntityMetadata, fields: string[]): string[] => { + return fields.filter(field => { + if (!field.includes('.')) { + if (field === '*') { + return true; + } + + return ![ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(meta.properties[field].kind); + } + + const parts = field.split('.'); + const key = parts.shift()!; + + /* istanbul ignore next */ + if (key === '*') { + return true; + } + + const prop = meta.properties[key]; + const ret = pruneToOneRelations(prop.targetMeta!, [parts.join('.')]); + + return ret.length > 0; + }); + }; + + options.populate = pruneToOneRelations(meta, this.buildFields(options.fields)) as any; } if (!options.populate) { - return this.entityLoader.normalizePopulate(entityName, [], options.strategy); + return this.entityLoader.normalizePopulate(entityName, [], options.strategy as LoadStrategy); } if (Array.isArray(options.populate)) { @@ -1829,7 +1857,7 @@ export class EntityManager { }) as unknown as Populate; } - const ret: PopulateOptions[] = this.entityLoader.normalizePopulate(entityName, options.populate as true, options.strategy); + const ret: PopulateOptions[] = this.entityLoader.normalizePopulate(entityName, options.populate as true, options.strategy as LoadStrategy); const invalid = ret.find(({ field }) => !this.canPopulate(entityName, field)); if (invalid) { @@ -1838,7 +1866,7 @@ export class EntityManager { return ret.map(field => { // force select-in strategy when populating all relations as otherwise we could cause infinite loops when self-referencing - field.strategy = options.populate === true ? LoadStrategy.SELECT_IN : (options.strategy ?? field.strategy); + field.strategy = options.populate === true ? LoadStrategy.SELECT_IN : (options.strategy ?? field.strategy) as LoadStrategy; return field; }); } diff --git a/packages/core/src/decorators/Entity.ts b/packages/core/src/decorators/Entity.ts index c58f421986d3..c2eeb47e015c 100644 --- a/packages/core/src/decorators/Entity.ts +++ b/packages/core/src/decorators/Entity.ts @@ -31,6 +31,6 @@ export type EntityOptions = { virtual?: boolean; // we need to use `em: any` here otherwise an expression would not be assignable with more narrow type like `SqlEntityManager` // also return type is unknown as it can be either QB instance (which we cannot type here) or array of POJOs (e.g. for mongodb) - expression?: string | ((em: any, where: FilterQuery, options: FindOptions) => object); + expression?: string | ((em: any, where: FilterQuery, options: FindOptions) => object); repository?: () => Constructor; }; diff --git a/packages/core/src/drivers/DatabaseDriver.ts b/packages/core/src/drivers/DatabaseDriver.ts index eaabb7c5fabb..c2d56a377280 100644 --- a/packages/core/src/drivers/DatabaseDriver.ts +++ b/packages/core/src/drivers/DatabaseDriver.ts @@ -76,7 +76,7 @@ export abstract class DatabaseDriver implements IDatabaseD } /* istanbul ignore next */ - async findVirtual(entityName: string, where: FilterQuery, options: FindOptions): Promise[]> { + async findVirtual(entityName: string, where: FilterQuery, options: FindOptions): Promise[]> { throw new Error(`Virtual entities are not supported by ${this.constructor.name} driver.`); } @@ -89,7 +89,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`); } @@ -315,9 +315,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 dedebb1a4a5c..7eb925d570fd 100644 --- a/packages/core/src/drivers/IDatabaseDriver.ts +++ b/packages/core/src/drivers/IDatabaseDriver.ts @@ -40,7 +40,7 @@ export interface IDatabaseDriver { */ findOne(entityName: string, where: FilterQuery, options?: FindOneOptions): Promise | null>; - findVirtual(entityName: string, where: FilterQuery, options: FindOptions): Promise[]>; + findVirtual(entityName: string, where: FilterQuery, options: FindOptions): Promise[]>; nativeInsert(entityName: string, data: EntityDictionary, options?: NativeInsertUpdateOptions): Promise>; @@ -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; @@ -95,10 +95,11 @@ export type EntityField = keyof T | '*' | AutoPath< export type OrderDefinition = (QueryOrderMap & { 0?: never }) | QueryOrderMap[]; -export interface FindOptions { +export interface FindOptions { where?: FilterQuery; populate?: readonly AutoPath[] | boolean; populateWhere?: ObjectQuery | PopulateHint; + fields?: readonly AutoPath[]; orderBy?: OrderDefinition; cache?: boolean | number | [string, number]; limit?: number; @@ -116,15 +117,14 @@ export interface FindOptions[]; schema?: string; flags?: QueryFlag[]; /** sql only */ groupBy?: string | string[]; having?: QBFilterQuery; /** sql only */ - strategy?: LoadStrategy; - flushMode?: FlushMode; + strategy?: LoadStrategy | 'select-in' | 'joined'; + flushMode?: FlushMode | 'commit' | 'auto' | 'always'; filters?: Dictionary | string[] | boolean; /** sql only */ lockMode?: Exclude; diff --git a/packages/core/src/entity/BaseEntity.ts b/packages/core/src/entity/BaseEntity.ts index 4dd3c93db6db..35f54b0afe8f 100644 --- a/packages/core/src/entity/BaseEntity.ts +++ b/packages/core/src/entity/BaseEntity.ts @@ -29,10 +29,11 @@ export abstract class BaseEntity { return Reference.create(this) as unknown as Ref & LoadedReference>>; } + toObject(): EntityDTO; + toObject(ignoreFields: never[]): EntityDTO; toObject = never>(ignoreFields: Ignored[]): Omit, Ignored>; - toObject(...args: unknown[]): EntityDTO; toObject = never>(ignoreFields?: Ignored[]): Omit, Ignored> { - return helper(this as unknown as Entity).toObject(ignoreFields); + return helper(this as unknown as Entity).toObject(ignoreFields!); } toPOJO(): EntityDTO { diff --git a/packages/core/src/entity/Collection.ts b/packages/core/src/entity/Collection.ts index c138dccf6750..21716539bbe4 100644 --- a/packages/core/src/entity/Collection.ts +++ b/packages/core/src/entity/Collection.ts @@ -8,7 +8,10 @@ import type { LoadedCollection, Populate, Primary, - ConnectionType, Dictionary, FilterKey, EntityKey, + ConnectionType, + Dictionary, + FilterKey, + EntityKey, } from '../typings'; import { ArrayCollection } from './ArrayCollection'; import { Utils } from '../utils/Utils'; @@ -29,8 +32,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 +79,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 +113,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,7 +219,7 @@ export class Collection extends Arr return super.count(); } - isEmpty(): boolean { + override isEmpty(): boolean { this.checkInitialized(); return super.isEmpty(); } @@ -217,7 +227,7 @@ export class Collection extends Arr /** * @inheritDoc */ - slice(start?: number, end?: number): T[] { + override slice(start?: number, end?: number): T[] { this.checkInitialized(); return super.slice(start, end); } @@ -225,7 +235,7 @@ export class Collection extends Arr /** * @inheritDoc */ - exists(cb: (item: T) => boolean): boolean { + override exists(cb: (item: T) => boolean): boolean { this.checkInitialized(); return super.exists(cb); } @@ -233,7 +243,7 @@ export class Collection extends Arr /** * @inheritDoc */ - find(cb: (item: T, index: number) => boolean): T | undefined { + override find(cb: (item: T, index: number) => boolean): T | undefined { this.checkInitialized(); return super.find(cb); } @@ -241,7 +251,7 @@ export class Collection extends Arr /** * @inheritDoc */ - filter(cb: (item: T, index: number) => boolean): T[] { + override filter(cb: (item: T, index: number) => boolean): T[] { this.checkInitialized(); return super.filter(cb); } @@ -249,7 +259,7 @@ export class Collection extends Arr /** * @inheritDoc */ - map(mapper: (item: T, index: number) => R): R[] { + override map(mapper: (item: T, index: number) => R): R[] { this.checkInitialized(); return super.map(mapper); } @@ -258,28 +268,35 @@ export class Collection extends Arr /** * @inheritDoc */ - indexBy(key: K1): Record; + override indexBy(key: K1): Record; /** * @inheritDoc */ - indexBy(key: K1, valueKey: K2): Record; + override indexBy(key: K1, valueKey: K2): Record; /** * @inheritDoc */ - indexBy(key: K1, valueKey?: K2): Record | Record { + override indexBy(key: K1, valueKey?: K2): Record | Record { this.checkInitialized(); return super.indexBy(key, valueKey as never); } - 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>> { @@ -298,7 +315,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>; } @@ -307,18 +323,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 @@ -338,7 +353,6 @@ export class Collection extends Arr this.initialized = true; this.dirty = false; - this._lazyInitialized = true; return this as unknown as LoadedCollection>; } @@ -387,9 +401,9 @@ export class Collection extends Arr return em; } - 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); } @@ -397,18 +411,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/EntityFactory.ts b/packages/core/src/entity/EntityFactory.ts index b46c12fadd2e..f7c15504ec7b 100644 --- a/packages/core/src/entity/EntityFactory.ts +++ b/packages/core/src/entity/EntityFactory.ts @@ -5,7 +5,8 @@ import type { EntityKey, EntityMetadata, EntityName, - EntityProperty, EntityValue, + EntityProperty, + EntityValue, New, Primary, } from '../typings'; @@ -127,7 +128,15 @@ export class EntityFactory { // do not override values changed by user Utils.keys(diff).forEach(key => delete diff2[key]); - Utils.keys(diff2).filter(key => diff2[key] === undefined).forEach(key => delete diff2[key]); + + Utils.keys(diff2).filter(key => { + // ignore null values if there is already present non-null value + if (existsData[key] != null) { + return diff2[key] == null; + } + + return diff2[key] === undefined; + }).forEach(key => delete diff2[key]); // but always add collection properties and formulas if they are part of the `data` Utils.keys(data) diff --git a/packages/core/src/entity/EntityHelper.ts b/packages/core/src/entity/EntityHelper.ts index 12c12a0bf885..e2b058069ca0 100644 --- a/packages/core/src/entity/EntityHelper.ts +++ b/packages/core/src/entity/EntityHelper.ts @@ -52,6 +52,7 @@ export class EntityHelper { Object.defineProperties(prototype, { __entity: { value: !meta.embeddable }, __meta: { value: meta }, + __config: { value: em.config }, __platform: { value: em.getPlatform() }, __factory: { value: em.getEntityFactory() }, __helper: { diff --git a/packages/core/src/entity/EntityLoader.ts b/packages/core/src/entity/EntityLoader.ts index e0d169bfb366..02b2e5fee68f 100644 --- a/packages/core/src/entity/EntityLoader.ts +++ b/packages/core/src/entity/EntityLoader.ts @@ -77,7 +77,11 @@ export class EntityLoader { entities = entities.filter(e => !visited.has(e)); entities.forEach(e => visited.add(e)); - entities.forEach(e => helper(e).__serializationContext.populate ??= populate as PopulateOptions[]); + entities.forEach(e => { + const context = helper(e).__serializationContext; + context.populate ??= populate as PopulateOptions[]; + context.fields ??= options.fields ? [...options.fields as string[]] : undefined; + }); for (const pop of populate) { await this.populateField(entityName, entities, pop, options as Required>); @@ -194,19 +198,7 @@ export class EntityLoader { if (prop.kind === ReferenceKind.EMBEDDED) { 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 filtered = this.filterCollections(entities, field, options); const innerOrderBy = Utils.asArray(options.orderBy) .filter(orderBy => Utils.isObject(orderBy[prop.name])) .map(orderBy => orderBy[prop.name]); @@ -275,7 +267,7 @@ export class EntityLoader { } private async findChildren(entities: Entity[], prop: EntityProperty, populate: PopulateOptions, options: Required>): Promise { - const children = this.getChildReferences(entities, prop, options.refresh); + const children = this.getChildReferences(entities, prop, options); const meta = this.metadata.find(prop.type)!; let fk = Utils.getPrimaryKeyHash(meta.primaryKeys); let schema: string | undefined = options.schema; @@ -300,7 +292,7 @@ export class EntityLoader { const ids = Utils.unique(children.map(e => e.__helper.getPrimaryKey())); const where = this.mergePrimaryCondition(ids, fk as FilterKey, options, meta, this.metadata, this.driver.getPlatform()); - const fields = this.buildFields(options.fields, prop); + const fields = this.buildFields(options.fields, prop) as any; const { refresh, filters, convertCustomTypes, lockMode, strategy, populateWhere, connectionType } = options; return this.em.find(prop.type, where, { @@ -383,10 +375,10 @@ export class EntityLoader { const refresh = options.refresh; const where = await this.extractChildCondition(options, prop, true); const fields = this.buildFields(options.fields, prop); - const options2 = { ...options } as FindOptions; + const options2 = { ...options } as unknown as FindOptions; delete options2.limit; delete options2.offset; - options2.fields = fields; + options2.fields = fields as any; options2.populate = (populate?.children ?? []) as never; if (prop.customType) { @@ -491,8 +483,8 @@ export class EntityLoader { return ret; } - private getChildReferences(entities: Entity[], prop: EntityProperty, refresh: boolean): AnyEntity[] { - const filtered = this.filterCollections(entities, prop.name, refresh); + private getChildReferences(entities: Entity[], prop: EntityProperty, options: Required>): AnyEntity[] { + const filtered = this.filterCollections(entities, prop.name, options); const children: AnyEntity[] = []; if (prop.kind === ReferenceKind.ONE_TO_MANY) { @@ -502,31 +494,47 @@ export class EntityLoader { } else if (prop.kind === ReferenceKind.MANY_TO_MANY) { // inverse side children.push(...filtered as AnyEntity[]); } else { // MANY_TO_ONE or ONE_TO_ONE - children.push(...this.filterReferences(entities, prop.name, refresh) as AnyEntity[]); + children.push(...this.filterReferences(entities, prop.name, options) as AnyEntity[]); } return children; } - private filterCollections(entities: Entity[], field: keyof Entity, refresh: boolean): Entity[] { - if (refresh) { + private filterCollections(entities: Entity[], field: keyof Entity, options: Required>): Entity[] { + if (options.refresh) { return entities.filter(e => e[field]); } return entities.filter(e => Utils.isCollection(e[field]) && !(e[field] as unknown as Collection).isInitialized(true)); } - private filterReferences(entities: Entity[], field: keyof Entity, refresh: boolean): Entity[keyof Entity][] { + private filterReferences(entities: Entity[], field: keyof Entity & string, options: Required>): Entity[keyof Entity][] { const children = entities.filter(e => Utils.isEntity(e[field], true)); - if (refresh) { + if (options.refresh) { return children.map(e => Reference.unwrapReference(e[field] as AnyEntity)) as Entity[keyof Entity][]; } - return children.filter(e => !(e[field] as AnyEntity).__helper!.__initialized).map(e => Reference.unwrapReference(e[field] as AnyEntity)) as Entity[keyof Entity][]; + if (options.fields) { + return children + .filter(e => { + const wrapped = helper(e[field] as object); + + const childFields = (options.fields as string[]) + .filter(f => f.startsWith(`${field}.`)) + .map(f => f.substring(field.length + 1)); + + return !wrapped.__initialized || !childFields.every(field => wrapped.__loadedProperties.has(field)); + }) + .map(e => Reference.unwrapReference(e[field] as AnyEntity)) as Entity[keyof Entity][]; + } + + return children + .filter(e => !(e[field] as AnyEntity).__helper!.__initialized) + .map(e => Reference.unwrapReference(e[field] as AnyEntity)) as Entity[keyof Entity][]; } - private filterByReferences(entities: Entity[], field: keyof Entity, refresh: boolean): Entity[] { + private filterByReferences(entities: Entity[], field: keyof Entity, refresh: boolean): Entity[] { /* istanbul ignore next */ if (refresh) { return entities; diff --git a/packages/core/src/entity/EntityRepository.ts b/packages/core/src/entity/EntityRepository.ts index 7c14c048c2ef..43a5c474eb79 100644 --- a/packages/core/src/entity/EntityRepository.ts +++ b/packages/core/src/entity/EntityRepository.ts @@ -48,7 +48,7 @@ export class EntityRepository { */ async findOne< Hint extends string = never, - Fields extends string = '*', + Fields extends string = never, >(where: FilterQuery, options?: FindOneOptions): Promise | null> { return this.getEntityManager().findOne(this.entityName, where, options); } @@ -60,7 +60,7 @@ export class EntityRepository { */ async findOneOrFail< Hint extends string = never, - Fields extends string = '*', + Fields extends string = never, >(where: FilterQuery, options?: FindOneOrFailOptions): Promise> { return this.getEntityManager().findOneOrFail(this.entityName, where, options); } @@ -125,7 +125,7 @@ export class EntityRepository { */ async find< Hint extends string = never, - Fields extends string = '*', + Fields extends string = never, >(where: FilterQuery, options?: FindOptions): Promise[]> { return this.getEntityManager().find(this.entityName, where as FilterQuery, options); } @@ -136,7 +136,7 @@ export class EntityRepository { */ async findAndCount< Hint extends string = never, - Fields extends string = '*', + Fields extends string = never, >(where: FilterQuery, options?: FindOptions): Promise<[Loaded[], number]> { return this.getEntityManager().findAndCount(this.entityName, where, options); } @@ -146,7 +146,7 @@ export class EntityRepository { */ async findByCursor< Hint extends string = never, - Fields extends string = '*', + Fields extends string = never, >(where: FilterQuery, options?: FindByCursorOptions): Promise> { return this.getEntityManager().findByCursor(this.entityName, where, options); } @@ -156,7 +156,7 @@ export class EntityRepository { */ async findAll< Hint extends string = never, - Fields extends string = '*', + Fields extends string = never, >(options?: FindOptions): Promise[]> { return this.getEntityManager().find(this.entityName, {} as FilterQuery, options); } @@ -264,7 +264,7 @@ export class EntityRepository { */ async populate< Hint extends string = never, - Fields extends string = '*', + Fields extends string = never, >(entities: Entity | Entity[], populate: AutoPath[] | boolean, options?: EntityLoaderOptions): Promise[]> { this.validateRepositoryType(entities, 'populate'); return this.getEntityManager().populate(entities as Entity, populate, options); diff --git a/packages/core/src/entity/WrappedEntity.ts b/packages/core/src/entity/WrappedEntity.ts index 2ffcb43bd11f..fa0e7705c196 100644 --- a/packages/core/src/entity/WrappedEntity.ts +++ b/packages/core/src/entity/WrappedEntity.ts @@ -2,7 +2,7 @@ import { inspect } from 'util'; import type { EntityManager } from '../EntityManager'; import type { AnyEntity, ConnectionType, Dictionary, EntityData, EntityDictionary, EntityMetadata, IHydrator, EntityValue, EntityKey, - IWrappedEntityInternal, Populate, PopulateOptions, Primary, AutoPath, Loaded, Ref, AddEager, Loaded, LoadedReference, EntityDTO, + IWrappedEntityInternal, Populate, PopulateOptions, Primary, AutoPath, Loaded, Ref, AddEager, LoadedReference, EntityDTO, } from '../typings'; import { Reference } from './Reference'; import { EntityTransformer } from '../serialization/EntityTransformer'; @@ -14,18 +14,18 @@ import { ValidationError } from '../errors'; import type { EntityIdentifier } from './EntityIdentifier'; import { helper } from './wrap'; import type { SerializationContext } from '../serialization/SerializationContext'; +import { EntitySerializer, type SerializeOptions } from '../serialization/EntitySerializer'; export class WrappedEntity { __initialized = true; __touched = false; __populated?: boolean; - __lazyInitialized?: boolean; __managed?: boolean; __onLoadFired?: boolean; __schema?: string; __em?: EntityManager; - __serializationContext: { root?: SerializationContext; populate?: PopulateOptions[] } = {}; + __serializationContext: { root?: SerializationContext; populate?: PopulateOptions[]; fields?: string[] } = {}; __loadedProperties = new Set(); __data: Dictionary = {}; __processing = false; @@ -56,9 +56,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>> { @@ -70,6 +69,10 @@ export class WrappedEntity { return EntityTransformer.toObject(this.entity, ignoreFields); } + serialize(options?: SerializeOptions): EntityDTO> { + return EntitySerializer.serialize(this.entity, options); + } + toPOJO(): EntityDTO { return EntityTransformer.toObject(this.entity, [], true); } @@ -93,23 +96,21 @@ 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; } async populate( - populate: AutoPath[] | boolean, - options: EntityLoaderOptions = {}, - ): Promise> { + populate: AutoPath[] | boolean, + options: EntityLoaderOptions = {}, + ): Promise> { if (!this.__em) { throw ValidationError.entityNotManaged(this.entity); } await this.__em.populate(this.entity, populate, options); - return this.entity as Loaded; + return this.entity as Loaded; } hasPrimaryKey(): boolean { @@ -186,6 +187,10 @@ export class WrappedEntity { return (this.entity as IWrappedEntityInternal).__platform!; } + get __config() { + return (this.entity as IWrappedEntityInternal).__config!; + } + get __primaryKeys(): Primary[] { return Utils.getPrimaryKeyValues(this.entity, this.__meta!.primaryKeys) as Primary[]; } diff --git a/packages/core/src/enums.ts b/packages/core/src/enums.ts index aafb821bed7f..3b098dbe906f 100644 --- a/packages/core/src/enums.ts +++ b/packages/core/src/enums.ts @@ -1,13 +1,13 @@ -import type { Dictionary, EntityKey, ExcludeFunctions, ExpandProperty } from './typings'; +import type { Dictionary, EntityKey, ExpandProperty } from './typings'; import type { Transaction } from './connections'; export enum FlushMode { - /** The `EntityManager` tries to delay the flush until the current Transaction is committed, although it might flush prematurely too. */ - COMMIT, + /** The `EntityManager` delays the flush until the current Transaction is committed. */ + COMMIT = 'commit', /** This is the default mode, and it flushes the `EntityManager` only if necessary. */ - AUTO, + AUTO = 'auto', /** Flushes the `EntityManager` before every query. */ - ALWAYS, + ALWAYS = 'always', } export enum PopulateHint { diff --git a/packages/core/src/serialization/EntitySerializer.ts b/packages/core/src/serialization/EntitySerializer.ts index 67035d0ebd95..8d04b9b00e85 100644 --- a/packages/core/src/serialization/EntitySerializer.ts +++ b/packages/core/src/serialization/EntitySerializer.ts @@ -16,7 +16,7 @@ import { ReferenceKind } from '../enums'; import { Reference } from '../entity/Reference'; import { SerializationContext } from './SerializationContext'; -function isVisible(meta: EntityMetadata, propName: EntityKey, options: SerializeOptions): boolean { +function isVisible(meta: EntityMetadata, propName: EntityKey, options: SerializeOptions): boolean { if (options.populate === true) { return options.populate; } @@ -36,7 +36,7 @@ function isVisible(meta: EntityMetadata, propName: EntityKe return visible && !prefixed; } -function isPopulated(entity: T, propName: string, options: SerializeOptions): boolean { +function isPopulated(entity: T, propName: string, options: SerializeOptions): boolean { if (typeof options.populate !== 'boolean' && options.populate?.find(item => item === propName || item.startsWith(propName + '.'))) { return true; } @@ -50,13 +50,13 @@ function isPopulated(entity: T, propName: string, options: Ser export class EntitySerializer { - static serialize(entity: T, options: SerializeOptions = {}): EntityDTO> { + static serialize(entity: T, options: SerializeOptions = {}): EntityDTO> { const wrapped = helper(entity); const meta = wrapped.__meta; let contextCreated = false; if (!wrapped.__serializationContext.root) { - const root = new SerializationContext(); + const root = new SerializationContext(wrapped.__config); SerializationContext.propagate(root, entity, (meta, prop) => meta.properties[prop]?.kind !== ReferenceKind.SCALAR); contextCreated = true; } @@ -131,7 +131,7 @@ export class EntitySerializer { return prop; } - private static processProperty(prop: EntityKey, entity: T, options: SerializeOptions): EntityValue | undefined { + private static processProperty(prop: EntityKey, entity: T, options: SerializeOptions): EntityValue | undefined { const parts = prop.split('.'); prop = parts[0] as EntityKey; const wrapped = helper(entity); @@ -171,7 +171,7 @@ export class EntitySerializer { return wrapped.__platform.normalizePrimaryKey(entity[prop] as unknown as IPrimaryKey) as unknown as EntityValue; } - private static extractChildOptions(options: SerializeOptions, prop: EntityKey): SerializeOptions { + private static extractChildOptions(options: SerializeOptions, prop: EntityKey): SerializeOptions { const extractChildElements = (items: string[]) => { return items .filter(field => field.startsWith(`${prop}.`)) @@ -185,7 +185,7 @@ export class EntitySerializer { } as SerializeOptions; } - private static processEntity(prop: EntityKey, entity: T, platform: Platform, options: SerializeOptions): EntityValue | undefined { + private static processEntity(prop: EntityKey, entity: T, platform: Platform, options: SerializeOptions): EntityValue | undefined { const child = Reference.unwrapReference(entity[prop] as T); const wrapped = helper(child); const populated = isPopulated(child, prop, options) && wrapped.isInitialized(); @@ -198,7 +198,7 @@ export class EntitySerializer { return platform.normalizePrimaryKey(wrapped.getPrimaryKey() as IPrimaryKey) as EntityValue; } - private static processCollection(prop: EntityKey, entity: T, options: SerializeOptions): EntityValue | undefined { + private static processCollection(prop: EntityKey, entity: T, options: SerializeOptions): EntityValue | undefined { const col = entity[prop] as unknown as Collection; if (!col.isInitialized()) { @@ -216,12 +216,12 @@ export class EntitySerializer { } -export interface SerializeOptions { +export interface SerializeOptions { /** Specify which relation should be serialized as populated and which as a FK. */ - populate?: AutoPath[] | boolean; + populate?: readonly AutoPath[] | boolean; /** Specify which properties should be omitted. */ - exclude?: AutoPath[]; + exclude?: readonly AutoPath[]; /** Enforce unpopulated references to be returned as objects, e.g. `{ author: { id: 1 } }` instead of `{ author: 1 }`. */ forceObject?: boolean; @@ -235,23 +235,16 @@ export interface SerializeOptions { /** * Converts entity instance to POJO, converting the `Collection`s to arrays and unwrapping the `Reference` wrapper, while respecting the serialization options. + * This method accepts either a single entity or array of entities, and always returns an array of entities. To serialize single entity, you can use array destructing, + * or use `wrap(entity).serialize()` which handles a single entity only. + * + * ```ts + * const dtos = serialize([user1, user, ...], { exclude: ['id', 'email'], forceObject: true }); + * const [dto1] = serialize(user, { exclude: ['id', 'email'], forceObject: true }); + * const dto2 = wrap(user).serialize({ exclude: ['id', 'email'], forceObject: true }); + * ``` + * */ -export function serialize(entities: T[], options?: SerializeOptions): EntityDTO>[]; - -/** - * Converts entity instance to POJO, converting the `Collection`s to arrays and unwrapping the `Reference` wrapper, while respecting the serialization options. - */ -export function serialize(entity: T, options?: SerializeOptions): EntityDTO>; - -/** - * Converts entity instance to POJO, converting the `Collection`s to arrays and unwrapping the `Reference` wrapper, while respecting the serialization options. - */ -export function serialize(entities: T | T[], options?: SerializeOptions): EntityDTO> | EntityDTO>[] { - const ret = Utils.asArray(entities).map(e => EntitySerializer.serialize(e, options)); - - if (Array.isArray(entities)) { - return ret; - } - - return ret[0]; +export function serialize(entities: T | T[], options?: SerializeOptions): EntityDTO>[] { + return Utils.asArray(entities).map(e => EntitySerializer.serialize(e, options)); } diff --git a/packages/core/src/serialization/EntityTransformer.ts b/packages/core/src/serialization/EntityTransformer.ts index e7b08d8ae839..dea6d712e6f5 100644 --- a/packages/core/src/serialization/EntityTransformer.ts +++ b/packages/core/src/serialization/EntityTransformer.ts @@ -26,7 +26,7 @@ export class EntityTransformer { let contextCreated = false; if (!wrapped.__serializationContext.root) { - const root = new SerializationContext(wrapped.__serializationContext.populate ?? []); + const root = new SerializationContext(wrapped.__config, wrapped.__serializationContext.populate, wrapped.__serializationContext.fields); SerializationContext.propagate(root, entity, isVisible); contextCreated = true; } @@ -55,13 +55,21 @@ export class EntityTransformer { [...keys] .filter(prop => raw ? meta.properties[prop] : isVisible(meta, prop, ignoreFields)) .map(prop => { + const populated = root.isMarkedAsPopulated(meta.className, prop); + const partiallyLoaded = root.isPartiallyLoaded(meta.className, prop); + const isPrimary = wrapped.__config.get('serialization').includePrimaryKeys && meta.properties[prop].primary; + + if (!partiallyLoaded && !populated && !isPrimary) { + return [prop, undefined]; + } + 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 +117,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 +127,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 +155,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,22 +163,33 @@ export class EntityTransformer { return wrapped.toPOJO() as unknown as EntityValue; } - if (wrapped.isInitialized() && (wrapped.__populated || !wrapped.__managed) && child !== entity && !wrapped.__lazyInitialized) { - const args = [...wrapped.__meta.toJsonParams.map(() => undefined)]; - return wrap(child).toJSON(...args) as EntityValue; + function isPopulated() { + if (wrapped.__populated != null) { + return wrapped.__populated; + } + + if (populated) { + return true; + } + + return !wrapped.__managed; + } + + if (wrapped.isInitialized() && isPopulated() && child !== entity) { + return wrap(child).toJSON() as EntityValue; } 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..02c2f0d8f56c 100644 --- a/packages/core/src/serialization/SerializationContext.ts +++ b/packages/core/src/serialization/SerializationContext.ts @@ -2,6 +2,7 @@ import type { AnyEntity, EntityMetadata, PopulateOptions } from '../typings'; import type { Collection } from '../entity/Collection'; import { Utils } from '../utils/Utils'; import { helper } from '../entity/wrap'; +import type { Configuration } from '../utils/Configuration'; /** * Helper that allows to keep track of where we are currently at when serializing complex entity graph with cycles. @@ -14,8 +15,13 @@ export class SerializationContext { readonly visited = new Set(); private entities = new Set(); - constructor(private readonly populate: PopulateOptions[] = []) {} + constructor(private readonly config: Configuration, + private readonly populate: PopulateOptions[] = [], + private readonly fields?: string[]) {} + /** + * 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 +29,7 @@ export class SerializationContext { } // check if the path is explicitly populated - if (!this.isMarkedAsPopulated(prop)) { + if (!this.isMarkedAsPopulated(entityName, prop)) { return true; } @@ -69,7 +75,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,11 +86,36 @@ 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[]; } } - return !!populate?.find(p => p.field === prop); + return !!populate?.some(p => p.field === prop); + } + + isPartiallyLoaded(entityName: string, prop: string): boolean { + if (!this.fields) { + return true; + } + + let fields: string[] = this.fields; + + for (const segment of this.path) { + if (fields.length === 0) { + return true; + } + + fields = fields + .filter(field => field.startsWith(`${segment[1]}.`)) + .map(field => field.substring(segment[1].length + 1)); + } + + return fields.some(p => p === prop); } private register(entity: AnyEntity) { diff --git a/packages/core/src/types/Uint8ArrayType.ts b/packages/core/src/types/Uint8ArrayType.ts index 0b5e2c5dfa2d..b30079a8a3f1 100644 --- a/packages/core/src/types/Uint8ArrayType.ts +++ b/packages/core/src/types/Uint8ArrayType.ts @@ -4,7 +4,7 @@ import type { EntityProperty } from '../typings'; export class Uint8ArrayType extends Type { - convertToDatabaseValue(value: Uint8Array): Buffer { + override convertToDatabaseValue(value: Uint8Array): Buffer { if (!value) { return value; } @@ -12,7 +12,7 @@ export class Uint8ArrayType extends Type { return Buffer.from(value); } - convertToJSValue(value: Buffer): Uint8Array | null { + override convertToJSValue(value: Buffer): Uint8Array | null { if (!value) { return value; } @@ -31,15 +31,15 @@ export class Uint8ArrayType extends Type { return new Uint8Array(Buffer.from(value)); } - compareAsType(): string { + override compareAsType(): string { return 'Buffer'; } - ensureComparable(): boolean { + override ensureComparable(): boolean { return false; } - getColumnType(prop: EntityProperty, platform: Platform): string { + override getColumnType(prop: EntityProperty, platform: Platform): string { return platform.getBlobDeclarationSQL(); } diff --git a/packages/core/src/typings.ts b/packages/core/src/typings.ts index 68fdef54efdd..c0624d5e1787 100644 --- a/packages/core/src/typings.ts +++ b/packages/core/src/typings.ts @@ -10,7 +10,7 @@ import { type EntityLoaderOptions, type EntityRepository, } from './entity'; -import type { SerializationContext } from './serialization'; +import type { SerializationContext, SerializeOptions } from './serialization'; import type { EntitySchema, MetadataStorage } from './metadata'; import type { Type, types } from './types'; import type { Platform } from './platforms'; @@ -119,43 +119,40 @@ export type FilterQuery = | FilterQuery[]; export type QBFilterQuery = ObjectQuery | Dictionary; -export interface IWrappedEntity< - Entity extends object, - Hint extends string = string, -> { +export interface IWrappedEntity { isInitialized(): boolean; isTouched(): boolean; populated(populated?: boolean): void; - populate(populate: AutoPath[] | boolean, options?: EntityLoaderOptions): Promise>; - init

(populated?: boolean, populate?: Populate, lockMode?: LockMode, connectionType?: ConnectionType): Promise>; + populate(populate: AutoPath[] | boolean, options?: EntityLoaderOptions): Promise>; + init(populated?: boolean, populate?: Populate, lockMode?: LockMode, connectionType?: ConnectionType): Promise>; toReference(): Ref & LoadedReference>>; + toObject(): EntityDTO; + toObject(ignoreFields: never[]): EntityDTO; toObject>(ignoreFields: Ignored[]): Omit, Ignored>; - toObject(...args: unknown[]): EntityDTO; toJSON(...args: any[]): EntityDTO; toPOJO(): EntityDTO; + serialize(options?: SerializeOptions): EntityDTO>; assign(data: EntityData | Partial>, options?: AssignOptions | boolean): Entity; getSchema(): string | undefined; setSchema(schema?: string): void; } -export interface IWrappedEntityInternal< - T extends object, - P extends string = string, -> extends IWrappedEntity { +export interface IWrappedEntityInternal extends IWrappedEntity { hasPrimaryKey(): boolean; - getPrimaryKey(convertCustomTypes?: boolean): Primary | null; - getPrimaryKeys(convertCustomTypes?: boolean): Primary[] | null; - setPrimaryKey(val: Primary): void; - getSerializedPrimaryKey(): string & keyof T; - __meta: EntityMetadata; + getPrimaryKey(convertCustomTypes?: boolean): Primary | null; + getPrimaryKeys(convertCustomTypes?: boolean): Primary[] | null; + setPrimaryKey(val: Primary): void; + getSerializedPrimaryKey(): string & keyof Entity; + __meta: EntityMetadata; __data: Dictionary; __em?: any; // we cannot have `EntityManager` here as that causes a cycle __platform: Platform; + __config: Configuration; __factory: EntityFactory; // internal factory instance that has its own global fork __hydrator: IHydrator; __initialized: boolean; __touched: boolean; - __originalEntityData?: EntityData; + __originalEntityData?: EntityData; __loadedProperties: Set; __identifier?: EntityIdentifier; __managed: boolean; @@ -163,11 +160,10 @@ export interface IWrappedEntityInternal< __schema?: string; __populated: boolean; __onLoadFired: boolean; - __reference?: Ref; - __lazyInitialized: boolean; - __pk?: Primary; - __primaryKeys: Primary[]; - __serializationContext: { root?: SerializationContext; populate?: PopulateOptions[] }; + __reference?: Ref; + __pk?: Primary; + __primaryKeys: Primary[]; + __serializationContext: { root?: SerializationContext; populate?: PopulateOptions[]; fields?: string[] }; } export type AnyEntity = Partial; @@ -537,7 +533,7 @@ export interface EntityMetadata { virtual?: boolean; // we need to use `em: any` here otherwise an expression would not be assignable with more narrow type like `SqlEntityManager` // also return type is unknown as it can be either QB instance (which we cannot type here) or array of POJOs (e.g. for mongodb) - expression?: string | ((em: any, where: FilterQuery, options: FindOptions) => object | string); + expression?: string | ((em: any, where: FilterQuery, options: FindOptions) => object | string); discriminatorColumn?: EntityKey; discriminatorValue?: number | string; discriminatorMap?: Dictionary; @@ -710,7 +706,7 @@ export interface MigrationObject { export type FilterDef = { name: string; - cond: Dictionary | ((args: Dictionary, type: 'read' | 'update' | 'delete', em: any, options?: FindOptions | FindOneOptions) => Dictionary | Promise); + cond: Dictionary | ((args: Dictionary, type: 'read' | 'update' | 'delete', em: any, options?: FindOptions | FindOneOptions) => Dictionary | Promise); default?: boolean; entity?: string[]; args?: boolean; @@ -741,9 +737,10 @@ type GetStringKey, E extends string> = K exte type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; export type AutoPath = - [D] extends [never] ? string : + [D] extends [never] ? any : P extends any ? (P & `${string}.` extends never ? P : P & `${string}.`) extends infer Q + // P extends infer Q ? Q extends `${infer A}.${infer B}` ? A extends StringKeys ? `${A}.${AutoPath>, B, E, Prev[D]>}` @@ -777,23 +774,23 @@ type IsPrefixed = K extends Prefix ? K : (K extend // filter by prefix and map to suffix type Suffix = Hint extends `${infer Pref}.${infer Suf}` ? (Pref extends Key ? Suf : never) - : (Hint extends '*' ? '*' : never); + : never; type Defined = T & {}; type AddOptional = undefined | null extends T ? null | undefined : null extends T ? null : undefined extends T ? undefined : never; -type LoadedProp = LoadedLoadable, Loaded>, L, F>> | AddOptional; +type LoadedProp = LoadedLoadable, L, F>>; export type AddEager = ExtractEagerProps & string; export type Selected = { - [K in keyof T as IsPrefixed>]: LoadedProp, Suffix>; + [K in keyof T as IsPrefixed>]: LoadedProp, Suffix, Suffix> | AddOptional; }; /** * Represents entity with its loaded relations (`populate` hint) and selected properties (`fields` hint). */ export type Loaded = [F] extends ['*'] ? (T & { - [K in keyof T as IsPrefixed>]: LoadedProp>; + [K in keyof T as IsPrefixed>]: LoadedProp, Suffix> | AddOptional; }) : Selected; export interface LoadedReference extends Reference> { diff --git a/packages/core/src/unit-of-work/IdentityMap.ts b/packages/core/src/unit-of-work/IdentityMap.ts index 717fb05cc82d..3949afcb690e 100644 --- a/packages/core/src/unit-of-work/IdentityMap.ts +++ b/packages/core/src/unit-of-work/IdentityMap.ts @@ -80,7 +80,7 @@ export class IdentityMap { private getPkHash(item: T): string { const wrapped = (item as AnyEntity).__helper; - const meta = wrapped.__meta; + const meta = wrapped.__meta as EntityMetadata; const pk = wrapped!.getPrimaryKey(true); if (pk == null) { diff --git a/packages/core/src/unit-of-work/UnitOfWork.ts b/packages/core/src/unit-of-work/UnitOfWork.ts index 270a35bc8d10..34e369a501c5 100644 --- a/packages/core/src/unit-of-work/UnitOfWork.ts +++ b/packages/core/src/unit-of-work/UnitOfWork.ts @@ -3,6 +3,7 @@ import type { AnyEntity, Dictionary, EntityData, + EntityKey, EntityMetadata, EntityProperty, EntityValue, @@ -107,12 +108,12 @@ export class UnitOfWork { }); wrapped.__meta.props.forEach(prop => { - if (prop.kind === ReferenceKind.EMBEDDED && !prop.object && Utils.isPlainObject(data[prop.name as string])) { + if (prop.kind === ReferenceKind.EMBEDDED && !prop.object && Utils.isPlainObject(data[prop.name as EntityKey])) { prop.targetMeta?.props.forEach(p => { const prefix = prop.prefix === false ? '' : prop.prefix === true ? prop.name + '_' : prop.prefix; - data[prefix + p.name] = data[prop.name as string][p.name]; + data[prefix + p.name as EntityKey] = data[prop.name as EntityKey][p.name]; }); - data[prop.name as string] = Utils.getPrimaryKeyValues(data[prop.name as string], prop.targetMeta!.primaryKeys, true); + data[prop.name as EntityKey] = Utils.getPrimaryKeyValues(data[prop.name as EntityKey], prop.targetMeta!.primaryKeys, true); } }); @@ -610,13 +611,13 @@ export class UnitOfWork { if (props.every(prop => entity[prop] != null)) { return Utils.getPrimaryKeyHash(props.map(p => { const prop = wrapped.__meta.properties[p]; - return prop.kind === ReferenceKind.SCALAR || prop.mapToPk ? entity[prop.name] : helper(entity[prop.name]!).getSerializedPrimaryKey(); + return prop.kind === ReferenceKind.SCALAR || prop.mapToPk ? entity[prop.name] : helper(entity[prop.name as EntityKey]!).getSerializedPrimaryKey(); }) as any); } - if (props.every(prop => wrapped.__originalEntityData?.[prop as string] != null)) { + if (props.every(prop => wrapped.__originalEntityData?.[prop as EntityKey] != null)) { return Utils.getPrimaryKeyHash(props.map(p => { - return wrapped.__originalEntityData![p as string]; + return wrapped.__originalEntityData![p as EntityKey]; })); } @@ -674,7 +675,7 @@ export class UnitOfWork { if (this.isCollectionSelfReferenced(collection, processed)) { this.extraUpdates.add([parent, prop.name, collection, undefined]); const coll = new Collection(parent); - coll.property = prop; + coll.property = prop as EntityProperty; parent[prop.name as keyof T] = coll as unknown as T[keyof T]; return; @@ -767,10 +768,6 @@ export class UnitOfWork { const collection = kind as Collection; if ([ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(prop.kind) && collection) { - if (type === Cascade.MERGE && collection.isInitialized()) { - collection.populated(); - } - collection .getItems(false) .forEach(item => this.cascade(item, type, visited, options)); @@ -839,7 +836,7 @@ export class UnitOfWork { // perf: set the `Collection._property` to skip the getter, as it can be slow when there is a lot of relations if (Utils.isCollection(kind)) { - kind.property = prop; + kind.property = prop as EntityProperty; } const isCollection = [ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(prop.kind); @@ -943,6 +940,8 @@ export class UnitOfWork { this.scheduleExtraUpdate(changeSet, [prop]); return true; } + + return false; }); } diff --git a/packages/core/src/utils/Configuration.ts b/packages/core/src/utils/Configuration.ts index 1fe7c193ede6..5b18796b7cc7 100644 --- a/packages/core/src/utils/Configuration.ts +++ b/packages/core/src/utils/Configuration.ts @@ -75,6 +75,9 @@ export class Configuration { autoJoinOneToOneOwner: true, propagateToOneOwner: true, populateAfterFlush: true, + serialization: { + includePrimaryKeys: true, + }, persistOnCreate: true, forceEntityConstructor: false, forceUndefined: false, @@ -507,6 +510,9 @@ export interface MikroORMOptions ex autoJoinOneToOneOwner: boolean; propagateToOneOwner: boolean; populateAfterFlush: boolean; + serialization: { + includePrimaryKeys: boolean; + }; persistOnCreate: boolean; forceEntityConstructor: boolean | (Constructor | string)[]; forceUndefined: boolean; diff --git a/packages/core/src/utils/Cursor.ts b/packages/core/src/utils/Cursor.ts index 6bb7743f4a9f..098a9a0accbe 100644 --- a/packages/core/src/utils/Cursor.ts +++ b/packages/core/src/utils/Cursor.ts @@ -2,8 +2,7 @@ import { inspect } from 'util'; import type { Dictionary, EntityKey, EntityMetadata, FilterObject, Loaded } from '../typings'; import type { FindByCursorOptions, OrderDefinition } from '../drivers/IDatabaseDriver'; import { Utils } from './Utils'; -import type { QueryOrder, QueryOrderKeys } from '../enums'; -import { ReferenceKind } from '../enums'; +import { ReferenceKind, type QueryOrder, type QueryOrderKeys } from '../enums'; import { Reference } from '../entity/Reference'; /** diff --git a/packages/core/src/utils/QueryHelper.ts b/packages/core/src/utils/QueryHelper.ts index a22974960a94..b4f1895d2943 100644 --- a/packages/core/src/utils/QueryHelper.ts +++ b/packages/core/src/utils/QueryHelper.ts @@ -120,7 +120,7 @@ export class QueryHelper { let cond = { [rootPrimaryKey]: { $in: where } } as FilterQuery; // detect tuple comparison, use `$or` in case the number of constituents don't match - if (meta && !where.every(c => Utils.isPrimaryKey(c as unknown) || Array.isArray(c) && c.length === meta.primaryKeys.length && c.every(i => Utils.isPrimaryKey(i)))) { + if (meta && !where.every(c => Utils.isPrimaryKey(c as unknown) || (Array.isArray(c) && c.length === meta.primaryKeys.length && c.every(i => Utils.isPrimaryKey(i))))) { cond = { $or: where } as FilterQuery; } diff --git a/packages/core/src/utils/Utils.ts b/packages/core/src/utils/Utils.ts index df8a899f4cc0..7a6673e7ecab 100644 --- a/packages/core/src/utils/Utils.ts +++ b/packages/core/src/utils/Utils.ts @@ -118,7 +118,7 @@ export function compareBuffers(a: ArrayBufferView, b: ArrayBufferView): boolean } for (let i = length; i-- !== 0;) { - if (a[i] !== b[i]) { + if ((a as unknown as unknown[])[i] !== (b as unknown as unknown[])[i]) { return false; } } diff --git a/packages/knex/src/AbstractSqlDriver.ts b/packages/knex/src/AbstractSqlDriver.ts index 042102f9f07f..e835a11e3615 100644 --- a/packages/knex/src/AbstractSqlDriver.ts +++ b/packages/knex/src/AbstractSqlDriver.ts @@ -18,7 +18,8 @@ import { EntityManagerType, type EntityMetadata, type EntityName, - type EntityProperty, EntityValue, + type EntityProperty, + type EntityValue, type FilterQuery, type FindByCursorOptions, type FindOneOptions, @@ -45,6 +46,7 @@ import { type UpsertManyOptions, type UpsertOptions, Utils, + type OrderDefinition, } from '@mikro-orm/core'; import type { AbstractSqlConnection } from './AbstractSqlConnection'; import type { AbstractSqlPlatform } from './AbstractSqlPlatform'; @@ -86,7 +88,7 @@ export abstract class AbstractSqlDriver[], options.fields); const joinedProps = this.joinedProps(meta, populate); const qb = this.createQueryBuilder(entityName, options.ctx, options.connectionType, false); - const fields = this.buildFields(meta, populate, joinedProps, qb, options.fields as Field[]); + const fields = this.buildFields(meta, populate, joinedProps, qb, options.fields as unknown as Field[]); const joinedPropsOrderBy = this.buildJoinedPropsOrderBy(entityName, qb, meta, joinedProps); const orderBy = [...Utils.asArray(options.orderBy), ...joinedPropsOrderBy]; @@ -703,7 +705,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)!; @@ -727,7 +729,7 @@ export abstract class AbstractSqlDriver[], [], qb, options.fields as Field[]); + const fields = this.buildFields(prop.targetMeta!, (options.populate ?? []) as unknown as PopulateOptions[], [], qb, options.fields as unknown as Field[]); qb.select(fields).populate(populate).where(where).orderBy(orderBy!).setLockMode(options.lockMode, options.lockTableAliases); if (owners.length === 1 && (options.offset != null || options.limit != null)) { diff --git a/packages/knex/src/schema/SqlSchemaGenerator.ts b/packages/knex/src/schema/SqlSchemaGenerator.ts index 879a416e5505..b3c74bcf4363 100644 --- a/packages/knex/src/schema/SqlSchemaGenerator.ts +++ b/packages/knex/src/schema/SqlSchemaGenerator.ts @@ -1,5 +1,5 @@ import type { Knex } from 'knex'; -import { AbstractSchemaGenerator, Utils, type Dictionary, type EntityMetadata, type MikroORM, ISchemaGenerator } from '@mikro-orm/core'; +import { AbstractSchemaGenerator, Utils, type Dictionary, type EntityMetadata, type MikroORM, type ISchemaGenerator } from '@mikro-orm/core'; import type { CheckDef, ForeignKey, IndexDef, SchemaDifference, TableDifference } from '../typings'; import { DatabaseSchema } from './DatabaseSchema'; import type { DatabaseTable } from './DatabaseTable'; diff --git a/packages/mongodb/src/MongoDriver.ts b/packages/mongodb/src/MongoDriver.ts index 139f58b3b79b..8d6bcacdd5ea 100644 --- a/packages/mongodb/src/MongoDriver.ts +++ b/packages/mongodb/src/MongoDriver.ts @@ -90,7 +90,7 @@ export class MongoDriver extends DatabaseDriver { async findOne(entityName: string, where: FilterQuery, options: FindOneOptions = { populate: [], orderBy: {} }): Promise | null> { if (this.metadata.find(entityName)?.virtual) { - const [item] = await this.findVirtual(entityName, where, options as FindOptions); + const [item] = await this.findVirtual(entityName, where, options as FindOptions); /* istanbul ignore next */ return item ?? null; } diff --git a/tests/EntityHelper.mongo.test.ts b/tests/EntityHelper.mongo.test.ts index e29182776f88..9563d9c1ef0c 100644 --- a/tests/EntityHelper.mongo.test.ts +++ b/tests/EntityHelper.mongo.test.ts @@ -66,7 +66,9 @@ describe('EntityHelperMongo', () => { orm.em.clear(); const author = await orm.em.findOneOrFail(Author, god.id, { populate: ['favouriteAuthor', 'books.author.books', 'books.publisher'] }); - const json = wrap(author).toObject(); + const json = wrap(author).toObject(['name']); + // @ts-expect-error + expect(json.name).toBeUndefined(); expect(json.termsAccepted).toBe(false); expect(json.favouriteAuthor).toBe(god.id); // self reference will be ignored even when explicitly populated expect(json.books![0]).toMatchObject({ @@ -274,7 +276,7 @@ describe('EntityHelperMongo', () => { orm.em.clear(); const jon = await orm.em.findOneOrFail(Author, god, { populate: true }); - const o = serialize(jon, { populate: true }); + const [o] = serialize([jon], { populate: true }); expect(o).toMatchObject({ id: jon.id, createdAt: jon.createdAt, diff --git a/tests/EntityManager.mongo.test.ts b/tests/EntityManager.mongo.test.ts index d7d80ed9d12f..ba09119d97a0 100644 --- a/tests/EntityManager.mongo.test.ts +++ b/tests/EntityManager.mongo.test.ts @@ -1899,14 +1899,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/EntityManager.postgre.test.ts b/tests/EntityManager.postgre.test.ts index bf7def438a1e..b6345c0df383 100644 --- a/tests/EntityManager.postgre.test.ts +++ b/tests/EntityManager.postgre.test.ts @@ -272,7 +272,7 @@ describe('EntityManagerPostgre', () => { }, { readOnly: true, isolationLevel: IsolationLevel.READ_COMMITTED })).rejects.toThrowError(/cannot execute INSERT in a read-only transaction/); expect(mock.mock.calls[0][0]).toMatch('begin transaction isolation level read committed read only'); - expect(mock.mock.calls[1][0]).toMatch('insert into "author2" ("created_at", "updated_at", "name", "email", "terms_accepted") values ($1, $2, $3, $4, $5) returning "id", "created_at", "updated_at", "age", "terms_accepted"'); + expect(mock.mock.calls[1][0]).toMatch('insert into "author2" ("created_at", "updated_at", "name", "email", "terms_accepted") values ($1, $2, $3, $4, $5) returning "id", "age"'); expect(mock.mock.calls[2][0]).toMatch('rollback'); }); @@ -933,8 +933,8 @@ describe('EntityManagerPostgre', () => { fields: ['books.title'], }); const json = wrap(newGod).toJSON(); - // @ts-expect-error - expect(json.books[0].author).toBe(newGod.id); + // @ts-expect-error since v6, automatically selected FKs are no longer part of the serialized entity + expect(json.books[0].author).toBeUndefined(); }); test('findOne by id', async () => { diff --git a/tests/Utils.test.ts b/tests/Utils.test.ts index ceb568f1e299..5cdc28494e8a 100644 --- a/tests/Utils.test.ts +++ b/tests/Utils.test.ts @@ -157,7 +157,7 @@ describe('Utils', () => { test('getParamNames', () => { expect(Utils.getParamNames(Test, 'constructor')).toEqual([]); expect(Utils.getParamNames(FooBar, 'constructor')).toEqual([]); - expect(Utils.getParamNames(Author, 'toJSON')).toEqual(['strict', 'strip', '...args']); + expect(Utils.getParamNames(Author, 'toJSON')).toEqual(['strict', 'strip']); expect(Utils.getParamNames('')).toEqual([]); const func = `function (email, organization, role=(cov_1a0rd1emyt.b[13][0]++, Test.TEST)) {}`; diff --git a/tests/entities/Author.ts b/tests/entities/Author.ts index 8ba60a74c073..fd8242586229 100644 --- a/tests/entities/Author.ts +++ b/tests/entities/Author.ts @@ -127,8 +127,8 @@ export class Author extends BaseEntity { - const o = this.toObject(...args); + toJSON(strict = true, strip: (keyof this)[] = ['id', 'email']): EntityDTO { + const o = this.toObject(); (o as Dictionary).fooBar = 123; if (strict) { diff --git a/tests/entities/Book.ts b/tests/entities/Book.ts index 4d94508f84d5..d01295824488 100644 --- a/tests/entities/Book.ts +++ b/tests/entities/Book.ts @@ -1,5 +1,20 @@ -import { ObjectId } from 'bson'; -import { EntityDTO, Ref, Dictionary, Collection, Cascade, Entity, Index, ManyToMany, ManyToOne, PrimaryKey, Property, Unique, wrap, Filter, OptionalProps } from '@mikro-orm/core'; +import { + EntityDTO, + Ref, + Dictionary, + Collection, + Cascade, + Entity, + Index, + ManyToMany, + ManyToOne, + Property, + Unique, + wrap, + Filter, + OptionalProps, + EntityKey, +} from '@mikro-orm/core'; import { Publisher } from './Publisher'; import { Author } from './Author'; import { BookTag } from './book-tag'; @@ -56,14 +71,12 @@ export class Book extends BaseEntity3 { this.author = author!; } - toJSON(strict = true, strip: string[] = ['metaObject', 'metaArray', 'metaArrayOfStrings'], ...args: any[]): EntityDTO { - const o = wrap(this as T).toObject(...args); - + toJSON>(strict = true, strip: Ignored[] = ['metaObject', 'metaArray', 'metaArrayOfStrings'] as Ignored[]): Omit, Ignored> { if (strict) { - strip.forEach(k => delete o[k as keyof typeof o]); + return wrap(this).toObject(strip); } - return o; + return wrap(this).toObject(); } } diff --git a/tests/entities/FooBar.ts b/tests/entities/FooBar.ts index be5769d9a002..75dabd806a23 100644 --- a/tests/entities/FooBar.ts +++ b/tests/entities/FooBar.ts @@ -60,6 +60,9 @@ export default class FooBar { @Property({ onCreate: (bar: FooBar) => bar.meta.onUpdateCalled = true }) onUpdateTest?: boolean; + @Property({ nullable: true }) + tenant?: number; + readonly meta = { onCreateCalled: false, onUpdateCalled: false }; static create(name: string) { diff --git a/tests/features/custom-types/json-properties.test.ts b/tests/features/custom-types/json-properties.test.ts index 47841c16f6a3..0795567adba0 100644 --- a/tests/features/custom-types/json-properties.test.ts +++ b/tests/features/custom-types/json-properties.test.ts @@ -1,4 +1,14 @@ -import { MikroORM, Entity, PrimaryKey, Property, SimpleLogger, Utils, IDatabaseDriver, sql } from '@mikro-orm/core'; +import { + MikroORM, + Entity, + PrimaryKey, + Property, + SimpleLogger, + Utils, + IDatabaseDriver, + sql, + wrap, +} from '@mikro-orm/core'; import { mockLogger } from '../../helpers'; import { PLATFORMS } from '../../bootstrap'; diff --git a/tests/features/embeddables/nested-embeddables.postgres.test.ts b/tests/features/embeddables/nested-embeddables.postgres.test.ts index 2c8168dde00b..fb4b0ffeec1e 100644 --- a/tests/features/embeddables/nested-embeddables.postgres.test.ts +++ b/tests/features/embeddables/nested-embeddables.postgres.test.ts @@ -1,4 +1,4 @@ -import { Embeddable, Embedded, Entity, PrimaryKey, Property } from '@mikro-orm/core'; +import { Embeddable, Embedded, Entity, PrimaryKey, Property, wrap } from '@mikro-orm/core'; import { MikroORM, PostgreSqlDriver } from '@mikro-orm/postgresql'; import { mockLogger } from '../../helpers'; @@ -298,7 +298,11 @@ describe('embedded entities in postgres', () => { await orm.em.fork().find(User, {}, { fields: ['profile2.identity.email'] }); await orm.em.fork().find(User, {}, { fields: ['profile1.identity.meta.foo'] }); - await orm.em.fork().find(User, {}, { fields: ['profile2.identity.meta.foo'] }); + + // partial loading works also for object mode embeddables, including nesting (GH #4199) + const u = await orm.em.fork().find(User, {}, { fields: ['profile2.identity.meta.foo'] }); + expect(wrap(u[0]).toObject()).toEqual({ id: 1, profile2: { identity: { meta: { foo: 'f2' } } } }); + expect(wrap(u[1]).toObject()).toEqual({ id: 2, profile2: { identity: { meta: { foo: 'f4' } } } }); // @ts-expect-error old syntax is still technically supported, but not on type level await orm.em.fork().find(User, {}, { fields: [{ profile1: ['identity'] }] }); diff --git a/tests/features/entity-assigner/assign-collection-items.test.ts b/tests/features/entity-assigner/assign-collection-items.test.ts index 27a518b8b012..56e90a248f5d 100644 --- a/tests/features/entity-assigner/assign-collection-items.test.ts +++ b/tests/features/entity-assigner/assign-collection-items.test.ts @@ -82,7 +82,7 @@ test('assigning collection items with updateByPrimaryKey: false', async () => { await orm.em.flush(); expect(mock.mock.calls).toEqual([ ['[query] begin'], - ['[query] insert into `product` (`id`, `name`, `store_id`) values (?, ?, ?) returning `id`'], + ['[query] insert into `product` (`id`, `name`, `store_id`) values (?, ?, ?)'], ['[query] update `product` set `name` = ? where `id` = ?'], ['[query] commit'], ]); @@ -104,7 +104,7 @@ test('assigning collection items with updateNestedEntities: false', async () => await orm.em.flush(); expect(mock.mock.calls).toEqual([ ['[query] begin'], - ['[query] insert into `product` (`id`, `name`, `store_id`) values (?, ?, ?), (?, ?, ?) returning `id`'], + ['[query] insert into `product` (`id`, `name`, `store_id`) values (?, ?, ?), (?, ?, ?)'], ['[query] delete from `product` where `id` in (?)'], ['[query] commit'], ]); diff --git a/tests/features/entity-generator/__snapshots__/EntityGenerator.test.ts.snap b/tests/features/entity-generator/__snapshots__/EntityGenerator.test.ts.snap index a111a43c9887..08d7287dd851 100644 --- a/tests/features/entity-generator/__snapshots__/EntityGenerator.test.ts.snap +++ b/tests/features/entity-generator/__snapshots__/EntityGenerator.test.ts.snap @@ -4037,22 +4037,24 @@ export class FooParam2 { } ", - "import { Entity, Enum, PrimaryKey, Property } from '@mikro-orm/core'; + "import { Entity, Enum, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core'; @Entity() export class Publisher2 { + [OptionalProps]?: 'name' | 'type' | 'type2'; + @PrimaryKey() id!: number; - @Property({ length: 255 }) + @Property({ length: 255, default: 'asd' }) name!: string; - @Enum({ items: () => Publisher2Type }) - type!: Publisher2Type; + @Enum({ items: () => Publisher2Type, default: 'local' }) + type: Publisher2Type = Publisher2Type.LOCAL; - @Enum({ items: () => Publisher2Type2 }) - type2!: Publisher2Type2; + @Enum({ items: () => Publisher2Type2, default: 'LOCAL' }) + type2: Publisher2Type2 = Publisher2Type2.LOCAL; @Property({ columnType: 'tinyint', nullable: true }) enum1?: number; diff --git a/tests/features/filters/filters.postgres.test.ts b/tests/features/filters/filters.postgres.test.ts index 27b1c808b6fe..8eb13b28a74c 100644 --- a/tests/features/filters/filters.postgres.test.ts +++ b/tests/features/filters/filters.postgres.test.ts @@ -154,7 +154,7 @@ describe('filters [postgres]', () => { expect(mock.mock.calls[0][0]).toMatch(`begin`); expect(mock.mock.calls[1][0]).toMatch(`insert into "employee" ("id") values (default) returning "id"`); expect(mock.mock.calls[2][0]).toMatch(`insert into "benefit" ("benefit_status", "name") values ($1, $2), ($3, $4) returning "id"`); - expect(mock.mock.calls[3][0]).toMatch(`insert into "benefit_detail" ("description", "benefit_id", "active") values ($1, $2, $3), ($4, $5, $6), ($7, $8, $9), ($10, $11, $12), ($13, $14, $15), ($16, $17, $18) returning "id", "active"`); + expect(mock.mock.calls[3][0]).toMatch(`insert into "benefit_detail" ("description", "benefit_id", "active") values ($1, $2, $3), ($4, $5, $6), ($7, $8, $9), ($10, $11, $12), ($13, $14, $15), ($16, $17, $18) returning "id"`); expect(mock.mock.calls[4][0]).toMatch(`insert into "employee_benefits" ("employee_id", "benefit_id") values ($1, $2)`); expect(mock.mock.calls[5][0]).toMatch(`commit`); orm.em.clear(); diff --git a/tests/features/partial-loading/partial-loading.mysql.test.ts b/tests/features/partial-loading/partial-loading.mysql.test.ts index 0c92807e625d..7179d32c4bf5 100644 --- a/tests/features/partial-loading/partial-loading.mysql.test.ts +++ b/tests/features/partial-loading/partial-loading.mysql.test.ts @@ -310,7 +310,6 @@ describe('partial loading (mysql)', () => { const r2 = await orm.em.find(BookTag2, {}, { fields: ['name', 'books.title', 'books.author', 'books.author.email'], - populate: ['books.author'], filters: false, }); expect(r2).toHaveLength(6); @@ -334,7 +333,6 @@ describe('partial loading (mysql)', () => { const r3 = await orm.em.find(BookTag2, {}, { fields: ['name', 'books.title', 'books.author.email'], - populate: ['books.author'], filters: false, strategy: LoadStrategy.JOINED, }); diff --git a/tests/features/schema-generator/check-constraint.mysql.test.ts b/tests/features/schema-generator/check-constraint.mysql.test.ts index efe1028a8be1..20a24f551043 100644 --- a/tests/features/schema-generator/check-constraint.mysql.test.ts +++ b/tests/features/schema-generator/check-constraint.mysql.test.ts @@ -47,7 +47,7 @@ describe('check constraint [mysql8]', () => { driver: MySqlDriver, port: 3308, metadataProvider: TsMorphMetadataProvider, - cache: { options: { cacheDir: `${__dirname}/temp` } }, + metadataCache: { options: { cacheDir: `${__dirname}/temp` } }, }); const meta0 = orm0.getMetadata().get(FooEntity.name); expect(meta0.checks).toEqual([ @@ -75,7 +75,7 @@ describe('check constraint [mysql8]', () => { driver: MySqlDriver, port: 3308, metadataProvider: TsMorphMetadataProvider, - cache: { options: { cacheDir: `${__dirname}/temp` } }, + metadataCache: { options: { cacheDir: `${__dirname}/temp` } }, }); const meta = orm.getMetadata().get(FooEntity.name); expect(meta.checks).toEqual([ diff --git a/tests/features/serialization/GH3788.test.ts b/tests/features/serialization/GH3788.test.ts index c3a79ffaf8d1..7e63d8dc85fd 100644 --- a/tests/features/serialization/GH3788.test.ts +++ b/tests/features/serialization/GH3788.test.ts @@ -50,6 +50,6 @@ test('serialization of not managed relations (#3788)', async () => { }, }); expect(JSON.stringify(mainItem)).toBe(`{"name":"yyyy","coverImage":{"url":"xxxx","itemEntity":{"name":"yyyy"}}}`); - expect(JSON.stringify(serialize(mainItem))).toBe(`{"name":"yyyy","coverImage":{"url":"xxxx","itemEntity":{"name":"yyyy"}}}`); - expect(JSON.stringify(serialize(mainItem, { populate: ['coverImage'] }))).toBe(`{"name":"yyyy","coverImage":{"url":"xxxx","itemEntity":{"name":"yyyy"}}}`); + expect(JSON.stringify(serialize(mainItem)[0])).toBe(`{"name":"yyyy","coverImage":{"url":"xxxx","itemEntity":{"name":"yyyy"}}}`); + expect(JSON.stringify(serialize(mainItem, { populate: ['coverImage'] })[0])).toBe(`{"name":"yyyy","coverImage":{"url":"xxxx","itemEntity":{"name":"yyyy"}}}`); }); diff --git a/tests/features/serialize.test.ts b/tests/features/serialization/explicit-serialization.test.ts similarity index 90% rename from tests/features/serialize.test.ts rename to tests/features/serialization/explicit-serialization.test.ts index b80ba4847289..be64402b54b5 100644 --- a/tests/features/serialize.test.ts +++ b/tests/features/serialization/explicit-serialization.test.ts @@ -1,7 +1,7 @@ import { wrap, serialize } from '@mikro-orm/core'; import { MikroORM } from '@mikro-orm/postgresql'; -import { initORMPostgreSql } from '../bootstrap'; -import { Author2, Book2, Publisher2, PublisherType } from '../entities-sql'; +import { initORMPostgreSql } from '../../bootstrap'; +import { Author2, Book2, Publisher2, PublisherType } from '../../entities-sql'; let orm: MikroORM; @@ -36,7 +36,7 @@ test('explicit serialization', async () => { const { god, author, publisher, book1, book2, book3 } = await createEntities(); const jon = await orm.em.findOneOrFail(Author2, author, { populate: true })!; - const o1 = serialize(jon); + const [o1] = serialize(jon); expect(o1).toMatchObject({ id: jon.id, createdAt: jon.createdAt, @@ -62,7 +62,7 @@ test('explicit serialization', async () => { name: 'Jon Snow', }); - const o2 = serialize(jon, { populate: ['books'], skipNull: true }); + const [o2] = serialize([jon], { populate: ['books'], skipNull: true }); expect(o2).toMatchObject({ id: jon.id, createdAt: jon.createdAt, @@ -79,7 +79,7 @@ test('explicit serialization', async () => { }); expect('age' in o2).toBe(false); - const o3 = serialize(jon, { populate: ['books', 'favouriteBook'] }); + const [o3] = serialize(jon, { populate: ['books', 'favouriteBook'] }); expect(o3).toMatchObject({ id: jon.id, createdAt: jon.createdAt, @@ -95,7 +95,7 @@ test('explicit serialization', async () => { name: 'Jon Snow', }); - const o4 = serialize(jon, { populate: ['books.author', 'favouriteBook'] }); + const [o4] = serialize(jon, { populate: ['books.author', 'favouriteBook'] }); expect(o4).toMatchObject({ id: jon.id, createdAt: jon.createdAt, @@ -111,7 +111,7 @@ test('explicit serialization', async () => { name: 'Jon Snow', }); - const o5 = serialize(jon, { populate: ['books.author', 'favouriteBook'], forceObject: true }); + const [o5] = serialize(jon, { populate: ['books.author', 'favouriteBook'], forceObject: true }); expect(o5).toMatchObject({ id: jon.id, createdAt: jon.createdAt, @@ -127,7 +127,7 @@ test('explicit serialization', async () => { name: 'Jon Snow', }); - const o6 = serialize(jon, { populate: ['books.author', 'books.publisher', 'favouriteBook'] }); + const [o6] = serialize(jon, { populate: ['books.author', 'books.publisher', 'favouriteBook'] }); expect(o6).toMatchObject({ id: jon.id, createdAt: jon.createdAt, @@ -143,7 +143,7 @@ test('explicit serialization', async () => { name: 'Jon Snow', }); - const o7 = serialize(jon, { + const [o7] = serialize(jon, { populate: ['books.author', 'books.publisher', 'favouriteBook'], exclude: ['books.author.email'], }); @@ -170,7 +170,7 @@ test('explicit serialization with populate: true', async () => { const { god, author, publisher } = await createEntities(); const jon = await orm.em.findOneOrFail(Author2, author, { populate: true })!; - const o8 = serialize(jon, { populate: true }); + const [o8] = serialize(jon, { populate: true }); expect(o8).toMatchObject({ id: jon.id, createdAt: jon.createdAt, @@ -191,7 +191,7 @@ test('explicit serialization with not initialized properties', async () => { const { author } = await createEntities(); const jon = await orm.em.findOneOrFail(Author2, author)!; - const o = serialize(jon, { populate: true }); + const [o] = serialize(jon, { populate: true }); expect(o).toMatchObject({ id: jon.id, createdAt: jon.createdAt, @@ -202,7 +202,7 @@ test('explicit serialization with not initialized properties', async () => { name: 'Jon Snow', }); - const o2 = serialize(jon.favouriteBook!, { populate: true }); + const [o2] = serialize(jon.favouriteBook!, { populate: true }); expect(o2).toEqual({ uuid: jon.favouriteBook!.uuid, }); diff --git a/tests/features/serialization/implicit-serialization.test.ts b/tests/features/serialization/implicit-serialization.test.ts new file mode 100644 index 000000000000..4303d3e88fab --- /dev/null +++ b/tests/features/serialization/implicit-serialization.test.ts @@ -0,0 +1,227 @@ +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(); + + 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(); +}); + +afterAll(() => orm.close()); + +beforeEach(() => orm.em.clear()); + +test('serialization works based on populate hint', 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' }, + }); +}); + +test('serialization respects partial loading hints 1', async () => { + // populate hint is inferred and `products.owner` is skipped from it as we don't need to populate it for its FK + const [shop1] = await orm.em.find(Shop, {}, { + fields: ['name', 'products.name', 'products.owner', 'owner.name'], + }); + expect(wrap(shop1).toObject()).toEqual({ + id: 1, + name: 'shop-1', + products: [ + { id: 1, name: 'product-1', owner: 1 }, + { id: 2, name: 'product-2', owner: 2 }, + ], + owner: { id: 1, name: 's1' }, + }); + + orm.config.get('serialization').includePrimaryKeys = false; + const [shop2] = await orm.em.find(Shop, {}, { + fields: ['name', 'products.name', 'products.owner', 'owner.name'], + }); + expect(wrap(shop2).toObject()).toEqual({ + name: 'shop-1', + products: [ + { name: 'product-1', owner: 1 }, + { name: 'product-2', owner: 2 }, + ], + owner: { name: 's1' }, + }); + orm.config.get('serialization').includePrimaryKeys = true; +}); + +test('serialization respects partial loading hints 2', async () => { + // but it gets populated if we select some of its properties + const [shop] = await orm.em.find(Shop, {}, { + fields: ['name', 'products.name', 'products.owner.email', 'owner.name'], + }); + expect(wrap(shop).toObject()).toEqual({ + id: 1, + name: 'shop-1', + products: [ + // products level owner has only email + { id: 1, name: 'product-1', owner: { id: 1, email: 'sp-1@yopmail.com' } }, + { id: 2, name: 'product-2', owner: { id: 2, email: 'sp-2@yopmail.com' } }, + ], + // top level owner has only name + owner: { id: 1, name: 's1' }, + }); +}); + +test('serialization respects partial loading hints 3', async () => { + // same result with joined strategy + const [shop] = await orm.em.find(Shop, {}, { + fields: ['name', 'products.name', 'products.owner.email', 'owner.name'], + strategy: 'joined', + }); + expect(wrap(shop).toObject()).toEqual({ + id: 1, + name: 'shop-1', + products: [ + // products level owner has only email + { id: 1, name: 'product-1', owner: { id: 1, email: 'sp-1@yopmail.com' } }, + { id: 2, name: 'product-2', owner: { id: 2, email: 'sp-2@yopmail.com' } }, + ], + // top level owner has only name + owner: { id: 1, name: 's1' }, + }); +}); + +test('serialization respects partial loading hints 4', async () => { + const [shop] = await orm.em.find(Shop, {}, { + fields: ['name', 'products.name', 'owner.name'], + }); + expect(wrap(shop).toObject()).toEqual({ + id: 1, + name: 'shop-1', + products: [ + { id: 1, name: 'product-1' }, + { id: 2, name: 'product-2' }, + ], + owner: { id: 1, name: 's1' }, + }); +}); + +// TODO test em.populate and add way to set populate hint on already loaded entities +// TODO test partial loading on (nested) embeddables (both inlined and object mode) diff --git a/tests/features/sharing-column-in-composite-pk-fk-2.test.ts b/tests/features/sharing-column-in-composite-pk-fk-2.test.ts index 94e9f302b624..b1a046d87deb 100644 --- a/tests/features/sharing-column-in-composite-pk-fk-2.test.ts +++ b/tests/features/sharing-column-in-composite-pk-fk-2.test.ts @@ -65,7 +65,7 @@ class Reader { @Entity() class Book { - [PrimaryKeyProp]?: ['id', 'company_id']; + [PrimaryKeyProp]?: ['id', 'company']; @Unique({ name: 'book_id_unique' }) @PrimaryKey({ columnType: 'uuid' }) diff --git a/tests/features/unit-of-work/GH4284.test.ts b/tests/features/unit-of-work/GH4284.test.ts index 00bf8f175b42..d235df91353d 100644 --- a/tests/features/unit-of-work/GH4284.test.ts +++ b/tests/features/unit-of-work/GH4284.test.ts @@ -35,7 +35,7 @@ test(`GH issue 4284`, async () => { await orm.em.flush(); expect(mock.mock.calls).toEqual([ ['[query] begin'], - ['[query] insert into `aentity` (`id`) values (\'1\') returning `id`, `name`'], + ['[query] insert into `aentity` (`id`) values (\'1\') returning `name`'], ['[query] commit'], ]); }); diff --git a/tests/features/upsert/upsert.test.ts b/tests/features/upsert/upsert.test.ts index fa8753ace5e7..f6153cfd64e7 100644 --- a/tests/features/upsert/upsert.test.ts +++ b/tests/features/upsert/upsert.test.ts @@ -1,6 +1,22 @@ import { - MikroORM, Entity, PrimaryKey, ManyToOne, Property, SimpleLogger, - Unique, Ref, ref, EventSubscriber, EventArgs, OneToMany, Collection, Embeddable, Embedded, OptionalProps, + MikroORM, + Entity, + PrimaryKey, + ManyToOne, + Property, + SimpleLogger, + Unique, + Ref, + ref, + EventSubscriber, + EventArgs, + OneToMany, + Collection, + Embeddable, + Embedded, + OptionalProps, + Utils, + IDatabaseDriver, } from '@mikro-orm/core'; import { mockLogger } from '../../helpers'; import { PLATFORMS } from '../../bootstrap'; 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(); diff --git a/tests/issues/GH4377.test.ts b/tests/issues/GH4377.test.ts index b4d51af81957..2f1846992126 100644 --- a/tests/issues/GH4377.test.ts +++ b/tests/issues/GH4377.test.ts @@ -1,4 +1,4 @@ -import { Cascade, Entity, OneToOne, PrimaryKey, PrimaryKeyType, Property, Ref } from '@mikro-orm/core'; +import { Cascade, Entity, OneToOne, PrimaryKey, PrimaryKeyProp, Property, Ref } from '@mikro-orm/core'; import { MikroORM } from '@mikro-orm/mysql'; import { randomUUID } from 'crypto'; @@ -15,7 +15,7 @@ class NonRoot { }) root!: Ref; - [PrimaryKeyType]?: [string, string]; + [PrimaryKeyProp]?: ['id', 'root']; } diff --git a/tests/issues/GH4533.test.ts b/tests/issues/GH4533.test.ts index 514edc26567a..6a9f3117f498 100644 --- a/tests/issues/GH4533.test.ts +++ b/tests/issues/GH4533.test.ts @@ -101,7 +101,7 @@ beforeAll(async () => { afterAll(() => orm.close(true)); test('updating composite key entity', async () => { - const permission = await orm.em.findOne( + const permission = await orm.em.findOneOrFail( RoleResourcePermission, { role: orm.em.getReference(Role, 1), diff --git a/tests/issues/GH4720.test.ts b/tests/issues/GH4720.test.ts index 9c9b1473fade..4aca218ac4df 100644 --- a/tests/issues/GH4720.test.ts +++ b/tests/issues/GH4720.test.ts @@ -6,7 +6,6 @@ import { OneToMany, PrimaryKey, PrimaryKeyProp, - PrimaryKeyType, Property, Unique, } from '@mikro-orm/core'; @@ -32,8 +31,7 @@ class Parent { @Entity() class ChildType { - [PrimaryKeyProp]?: 'foo' | 'boo'; - [PrimaryKeyType]?: [string, string]; + [PrimaryKeyProp]?: ['foo', 'boo']; @PrimaryKey() foo!: string; @@ -49,8 +47,7 @@ class ChildType { @Entity() class Child { - [PrimaryKeyProp]?: 'parent' | 'type'; - [PrimaryKeyType]?: [number, [string, string]]; + [PrimaryKeyProp]?: ['parent', 'type']; @ManyToOne({ entity: () => Parent, primary: true }) parent!: Parent;