diff --git a/docs/docs/virtual-entities.md b/docs/docs/virtual-entities.md new file mode 100644 index 000000000000..903b311a0bcf --- /dev/null +++ b/docs/docs/virtual-entities.md @@ -0,0 +1,242 @@ +--- +title: Virtual Entities +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +Virtual entities don't represent any database table. Instead, they dynamically resolve to an SQL query (or an aggregation in mongo), allowing to map any kind of results onto an entity. Such entities are mean for read purposes, they don't have a primary key and therefore cannot be tracked for changes. In a sense they are similar to (currently unsupported) database views. + +To define a virtual entity, provide an `expression`, either as a string (SQL query): + +> We need to use the virtual column names based on current naming strategy. Note the `authorName` property being represented as `author_name` column. + + + + +```ts title="./entities/BookWithAuthor.ts" +@Entity({ + expression: 'select name, age, ' + + '(select count(*) from book b where b.author_id = a.id) as total_books, ' + + '(select group_concat(distinct t.name) from book b ' + + 'join tags_ordered bt on bt.book_id = b.id ' + + 'join book_tag t on t.id = bt.book_tag_id ' + + 'where b.author_id = a.id ' + + 'group by b.author_id) as used_tags ' + + 'from author a group by a.id', +}) +export class BookWithAuthor { + + @Property() + title!: string; + + @Property() + authorName!: string; + + @Property() + tags!: string[]; + +} +``` + + + + +```ts title="./entities/BookWithAuthor.ts" +@Entity({ + expression: 'select name, age, ' + + '(select count(*) from book b where b.author_id = a.id) as total_books, ' + + '(select group_concat(distinct t.name) from book b ' + + 'join tags_ordered bt on bt.book_id = b.id ' + + 'join book_tag t on t.id = bt.book_tag_id ' + + 'where b.author_id = a.id ' + + 'group by b.author_id) as used_tags ' + + 'from author a group by a.id', +}) +export class BookWithAuthor { + + @Property() + title!: string; + + @Property() + authorName!: string; + + @Property() + tags!: string[]; + +} +``` + + + + +```ts title="./entities/BookWithAuthor.ts" +export interface IBookWithAuthor{ + title: string; + authorName: string; + tags: string[]; +} + +export const BookWithAuthor = new EntitySchema({ + name: 'BookWithAuthor', + expression: 'select name, age, ' + + '(select count(*) from book b where b.author_id = a.id) as total_books, ' + + '(select group_concat(distinct t.name) from book b ' + + 'join tags_ordered bt on bt.book_id = b.id ' + + 'join book_tag t on t.id = bt.book_tag_id ' + + 'where b.author_id = a.id ' + + 'group by b.author_id) as used_tags ' + + 'from author a group by a.id', + properties: { + title: { type: 'string' }, + authorName: { type: 'string' }, + tags: { type: 'string[]' }, + }, +}); +``` + + + + +Or as a callback: + + + + +```ts title="./entities/BookWithAuthor.ts" +@Entity({ + expression: (em: EntityManager) => { + return em.createQueryBuilder(Book, 'b') + .select(['b.title', 'a.name as author_name', 'group_concat(t.name) as tags']) + .join('b.author', 'a') + .join('b.tags', 't') + .groupBy('b.id'); + }, +}) +export class BookWithAuthor { + + @Property() + title!: string; + + @Property() + authorName!: string; + + @Property() + tags!: string[]; + +} +``` + + + + +```ts title="./entities/BookWithAuthor.ts" +@Entity({ + expression: (em: EntityManager) => { + return em.createQueryBuilder(Book, 'b') + .select(['b.title', 'a.name as author_name', 'group_concat(t.name) as tags']) + .join('b.author', 'a') + .join('b.tags', 't') + .groupBy('b.id'); + }, +}) +export class BookWithAuthor { + + @Property() + title!: string; + + @Property() + authorName!: string; + + @Property() + tags!: string[]; + +} +``` + + + + +```ts title="./entities/BookWithAuthor.ts" +export interface IBookWithAuthor{ + title: string; + authorName: string; + tags: string[]; +} + +export const BookWithAuthor = new EntitySchema({ + name: 'BookWithAuthor', + expression: (em: EntityManager) => { + return em.createQueryBuilder(Book, 'b') + .select(['b.title', 'a.name as author_name', 'group_concat(t.name) as tags']) + .join('b.author', 'a') + .join('b.tags', 't') + .groupBy('b.id'); + }, + properties: { + title: { type: 'string' }, + authorName: { type: 'string' }, + tags: { type: 'string[]' }, + }, +}); +``` + + + + +In MongoDB, we can use aggregations, although it is not very ergonomic due to their nature. Following example is a rough equivalent of the previous SQL ones. + +> The `where` query as well as the options like `orderBy`, `limit` and `offset` needs to be explicitly handled in your pipeline. + +```ts +@Entity({ + expression: (em: EntityManager, where, options) => { + const $sort = { ...options.orderBy } as Dictionary; + $sort._id = 1; + const pipeline: Dictionary[] = [ + { $project: { _id: 0, title: 1, author: 1 } }, + { $sort }, + { $match: where ?? {} }, + { $lookup: { from: 'author', localField: 'author', foreignField: '_id', as: 'author', pipeline: [{ $project: { name: 1 } }] } }, + { $unwind: '$author' }, + { $set: { authorName: '$author.name' } }, + { $unset: ['author'] }, + ]; + + if (options.offset != null) { + pipeline.push({ $skip: options.offset }); + } + + if (options.limit != null) { + pipeline.push({ $limit: options.limit }); + } + + return em.aggregate(Book, pipeline); + }, +}) +export class BookWithAuthor { + + @Property() + title!: string; + + @Property() + authorName!: string; + +} +``` diff --git a/docs/sidebars.js b/docs/sidebars.js index 5c9ceaaad450..d97c2e2f8aae 100755 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -54,6 +54,7 @@ module.exports = { 'events', 'composite-keys', 'custom-types', + 'virtual-entities', 'embeddables', 'entity-schema', 'json-properties', diff --git a/packages/core/src/EntityManager.ts b/packages/core/src/EntityManager.ts index 24a3f18c02c3..5247ca2ec5a7 100644 --- a/packages/core/src/EntityManager.ts +++ b/packages/core/src/EntityManager.ts @@ -132,6 +132,7 @@ export class EntityManager { return []; } + const meta = this.metadata.get(entityName); const ret: T[] = []; for (const data of results) { @@ -141,10 +142,21 @@ export class EntityManager { schema: options.schema, convertCustomTypes: true, }) as T; - em.unitOfWork.registerManaged(entity, data, { refresh: options.refresh, loaded: true }); + + if (!meta.virtual) { + em.unitOfWork.registerManaged(entity, data, { refresh: options.refresh, loaded: true }); + } + ret.push(entity); } + if (meta.virtual) { + await em.unitOfWork.dispatchOnLoadEvent(); + await em.storeCache(options.cache, cached!, () => ret.map(e => e.__helper!.toPOJO())); + + return ret as Loaded[]; + } + const unique = Utils.unique(ret); await em.entityLoader.populate(entityName, unique, populate, { ...options as Dictionary, @@ -385,8 +397,12 @@ export class EntityManager { schema: options.schema, convertCustomTypes: true, }); - em.unitOfWork.registerManaged(entity, data, { refresh: options.refresh, loaded: true }); - await em.lockAndPopulate(entityName, entity, where, options); + + if (!meta.virtual) { + em.unitOfWork.registerManaged(entity, data, { refresh: options.refresh, loaded: true }); + await em.lockAndPopulate(entityName, entity, where, options); + } + await em.unitOfWork.dispatchOnLoadEvent(); await em.storeCache(options.cache, cached!, () => entity!.__helper!.toPOJO()); diff --git a/packages/core/src/decorators/Entity.ts b/packages/core/src/decorators/Entity.ts index 545912bf02de..f895ff307a0f 100644 --- a/packages/core/src/decorators/Entity.ts +++ b/packages/core/src/decorators/Entity.ts @@ -1,6 +1,7 @@ import { MetadataStorage } from '../metadata'; import { Utils } from '../utils'; -import type { Constructor, Dictionary } from '../typings'; +import type { Constructor, Dictionary, FilterQuery } from '../typings'; +import type { FindOptions } from '../drivers/IDatabaseDriver'; export function Entity(options: EntityOptions = {}) { return function (target: T & Dictionary) { @@ -26,5 +27,9 @@ export type EntityOptions = { comment?: string; abstract?: boolean; readonly?: boolean; + 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); customRepository?: () => Constructor; }; diff --git a/packages/core/src/drivers/DatabaseDriver.ts b/packages/core/src/drivers/DatabaseDriver.ts index 8719e913d613..1be42c5120c8 100644 --- a/packages/core/src/drivers/DatabaseDriver.ts +++ b/packages/core/src/drivers/DatabaseDriver.ts @@ -49,6 +49,11 @@ export abstract class DatabaseDriver implements IDatabaseD return new EntityManager(this.config, this, this.metadata, useContext) as unknown as EntityManager; } + /* istanbul ignore next */ + async findVirtual(entityName: string, where: FilterQuery, options: FindOptions): Promise[]> { + throw new Error(`Virtual entities are not supported by ${this.constructor.name} driver.`); + } + async aggregate(entityName: string, pipeline: any[]): Promise { throw new Error(`Aggregations are not supported by ${this.constructor.name} driver`); } diff --git a/packages/core/src/drivers/IDatabaseDriver.ts b/packages/core/src/drivers/IDatabaseDriver.ts index e8d61522d720..f6633369eeff 100644 --- a/packages/core/src/drivers/IDatabaseDriver.ts +++ b/packages/core/src/drivers/IDatabaseDriver.ts @@ -38,6 +38,8 @@ export interface IDatabaseDriver { */ findOne, P extends string = never>(entityName: string, where: FilterQuery, options?: FindOneOptions): Promise | null>; + findVirtual(entityName: string, where: FilterQuery, options: FindOptions): Promise[]>; + nativeInsert>(entityName: string, data: EntityDictionary, options?: NativeInsertUpdateOptions): Promise>; nativeInsertMany>(entityName: string, data: EntityDictionary[], options?: NativeInsertUpdateManyOptions): Promise>; diff --git a/packages/core/src/entity/EntityFactory.ts b/packages/core/src/entity/EntityFactory.ts index f66a8f523844..677160dba233 100644 --- a/packages/core/src/entity/EntityFactory.ts +++ b/packages/core/src/entity/EntityFactory.ts @@ -36,6 +36,14 @@ export class EntityFactory { entityName = Utils.className(entityName); const meta = this.metadata.get(entityName); + if (meta.virtual) { + data = { ...data }; + const entity = this.createEntity(data, meta, options); + this.hydrate(entity, meta, data, options); + + return entity as New; + } + if (this.platform.usesDifferentSerializedPrimaryKey()) { meta.primaryKeys.forEach(pk => this.denormalizePrimaryKey(data, pk, meta.properties[pk])); } @@ -161,7 +169,7 @@ export class EntityFactory { } private createEntity>(data: EntityData, meta: EntityMetadata, options: FactoryOptions): T { - if (options.newEntity || meta.forceConstructor) { + if (options.newEntity || meta.forceConstructor || meta.virtual) { if (!meta.class) { throw new Error(`Cannot create entity ${meta.className}, class prototype is unknown`); } @@ -173,6 +181,11 @@ export class EntityFactory { // creates new instance via constructor as this is the new entity const entity = new Entity(...params); + + if (meta.virtual) { + return entity; + } + entity.__helper!.__schema = this.driver.getSchemaName(meta, options); if (!options.newEntity) { @@ -211,7 +224,7 @@ export class EntityFactory { } else { this.hydrator.hydrateReference(entity, meta, data, this, options.convertCustomTypes, this.driver.getSchemaName(meta, options)); } - Object.keys(data).forEach(key => entity.__helper!.__loadedProperties.add(key)); + Object.keys(data).forEach(key => entity.__helper?.__loadedProperties.add(key)); } private findEntity(data: EntityData, meta: EntityMetadata, options: FactoryOptions): T | undefined { diff --git a/packages/core/src/hydration/ObjectHydrator.ts b/packages/core/src/hydration/ObjectHydrator.ts index b85a2508121a..b05b1a4ff52c 100644 --- a/packages/core/src/hydration/ObjectHydrator.ts +++ b/packages/core/src/hydration/ObjectHydrator.ts @@ -316,7 +316,7 @@ export class ObjectHydrator extends Hydrator { } private createCollectionItemMapper(prop: EntityProperty): string[] { - const meta = this.metadata.find(prop.type)!; + const meta = this.metadata.get(prop.type); const lines: string[] = []; lines.push(` const createCollectionItem_${this.safeKey(prop.name)} = value => {`); diff --git a/packages/core/src/metadata/MetadataStorage.ts b/packages/core/src/metadata/MetadataStorage.ts index 8a522396e86d..8c4d60027e40 100644 --- a/packages/core/src/metadata/MetadataStorage.ts +++ b/packages/core/src/metadata/MetadataStorage.ts @@ -103,7 +103,7 @@ export class MetadataStorage { decorate(em: EntityManager): void { Object.values(this.metadata) - .filter(meta => meta.prototype && !meta.prototype.__meta) + .filter(meta => meta.prototype && !meta.prototype.__meta && !meta.virtual) .forEach(meta => EntityHelper.decorate(meta, em)); } diff --git a/packages/core/src/metadata/MetadataValidator.ts b/packages/core/src/metadata/MetadataValidator.ts index 570518f47678..dbe0d4e96277 100644 --- a/packages/core/src/metadata/MetadataValidator.ts +++ b/packages/core/src/metadata/MetadataValidator.ts @@ -23,6 +23,20 @@ export class MetadataValidator { validateEntityDefinition(metadata: MetadataStorage, name: string): void { const meta = metadata.get(name); + if (meta.virtual || meta.expression) { + for (const prop of Object.values(meta.properties)) { + if (prop.reference !== ReferenceType.SCALAR) { + throw new MetadataError(`Only scalar properties are allowed inside virtual entity. Found '${prop.reference}' in ${meta.className}.${prop.name}`); + } + + if (prop.primary) { + throw new MetadataError(`Virtual entity ${meta.className} cannot have primary key ${meta.className}.${prop.name}`); + } + } + + return; + } + // entities have PK if (!meta.embeddable && (!meta.primaryKeys || meta.primaryKeys.length === 0)) { throw MetadataError.fromMissingPrimaryKey(meta); diff --git a/packages/core/src/typings.ts b/packages/core/src/typings.ts index 1268efb5d1b8..30051ce35d44 100644 --- a/packages/core/src/typings.ts +++ b/packages/core/src/typings.ts @@ -10,6 +10,7 @@ import { Utils } from './utils/Utils'; import { EntityComparator } from './utils/EntityComparator'; import type { EntityManager } from './EntityManager'; import type { EventSubscriber } from './events'; +import type { FindOptions } from './drivers'; export type Constructor = new (...args: any[]) => T; export type Dictionary = { [k: string]: T }; @@ -362,6 +363,10 @@ export class EntityMetadata = any> { this.selfReferencing = this.relations.some(prop => [this.className, this.root.className].includes(prop.type)); this.virtual = !!this.expression; + if (this.virtual) { + this.readonly = true; + } + if (initIndexes && this.name) { this.props.forEach(prop => this.initIndexes(prop)); } @@ -433,7 +438,9 @@ export interface EntityMetadata = any> { schema?: string; pivotTable?: boolean; virtual?: boolean; - expression?: string | ((em: EntityManager) => Promise); + // 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); discriminatorColumn?: string; discriminatorValue?: number | string; discriminatorMap?: Dictionary; diff --git a/packages/core/src/utils/AbstractSchemaGenerator.ts b/packages/core/src/utils/AbstractSchemaGenerator.ts index a34679638b4c..771956dd18e5 100644 --- a/packages/core/src/utils/AbstractSchemaGenerator.ts +++ b/packages/core/src/utils/AbstractSchemaGenerator.ts @@ -103,7 +103,7 @@ export abstract class AbstractSchemaGenerator impleme protected getOrderedMetadata(schema?: string): EntityMetadata[] { const metadata = Object.values(this.metadata.getAll()).filter(meta => { const isRootEntity = meta.root.className === meta.className; - return isRootEntity && !meta.embeddable; + return isRootEntity && !meta.embeddable && !meta.virtual; }); const calc = new CommitOrderCalculator(); metadata.forEach(meta => calc.addNode(meta.root.className)); diff --git a/packages/entity-generator/src/EntitySchemaSourceFile.ts b/packages/entity-generator/src/EntitySchemaSourceFile.ts index 2c6548a0a492..1cc27153b143 100644 --- a/packages/entity-generator/src/EntitySchemaSourceFile.ts +++ b/packages/entity-generator/src/EntitySchemaSourceFile.ts @@ -140,6 +140,7 @@ export class EntitySchemaSourceFile extends SourceFile { } const defaultName = this.platform.getIndexName(this.meta.collection, prop.fieldNames, type); + /* istanbul ignore next */ options[type] = defaultName === prop[type] ? 'true' : `'${prop[type]}'`; const expected = { index: this.platform.indexForeignKeys(), diff --git a/packages/knex/src/AbstractSqlDriver.ts b/packages/knex/src/AbstractSqlDriver.ts index eabcd933cf12..65233accf1f8 100644 --- a/packages/knex/src/AbstractSqlDriver.ts +++ b/packages/knex/src/AbstractSqlDriver.ts @@ -38,6 +38,11 @@ export abstract class AbstractSqlDriver(entityName: string, where: FilterQuery, options: FindOptions = {}): Promise[]> { options = { populate: [], orderBy: [], ...options }; const meta = this.metadata.find(entityName)!; + + if (meta?.virtual) { + return this.findVirtual(entityName, where, options); + } + const populate = this.autoJoinOneToOneOwner(meta, options.populate as unknown as PopulateOptions[], options.fields); const joinedProps = this.joinedProps(meta, populate); const qb = this.createQueryBuilder(entityName, options.ctx, options.connectionType, false); @@ -94,6 +99,47 @@ export abstract class AbstractSqlDriver(entityName: string, where: FilterQuery, options: FindOptions): Promise[]> { + const meta = this.metadata.get(entityName); + + /* istanbul ignore next */ + if (!meta.expression) { + return []; + } + + if (typeof meta.expression === 'string') { + return this.wrapVirtualExpressionInSubquery(meta, meta.expression, where, options); + } + + const em = this.createEntityManager(false); + em.setTransactionContext(options.ctx); + const res = meta.expression(em, where, options); + + if (res instanceof QueryBuilder) { + return this.wrapVirtualExpressionInSubquery(meta, res.getFormattedQuery(), where, options); + } + + return res as EntityData[]; + } + + protected async wrapVirtualExpressionInSubquery(meta: EntityMetadata, expression: string, where: FilterQuery, options: FindOptions) { + const qb = this.createQueryBuilder(meta.className, options?.ctx, options.connectionType, options.convertCustomTypes) + .limit(options?.limit, options?.offset); + + if (options.orderBy) { + qb.orderBy(options.orderBy); + } + + qb.where(where); + + const kqb = qb.getKnexQuery(); + kqb.clear('select').select('*'); + kqb.fromRaw(`(${expression}) as ${this.platform.quoteIdentifier(qb.alias)}`); + + const res = await this.execute(kqb); + return res.map(row => this.mapResult(row, meta)!); + } + mapResult(result: EntityData, meta: EntityMetadata, populate: PopulateOptions[] = [], qb?: QueryBuilder, map: Dictionary = {}): EntityData | null { const ret = super.mapResult(result, meta); diff --git a/packages/mongodb/src/MongoDriver.ts b/packages/mongodb/src/MongoDriver.ts index ddc5f140eb29..cf121b757923 100644 --- a/packages/mongodb/src/MongoDriver.ts +++ b/packages/mongodb/src/MongoDriver.ts @@ -5,9 +5,7 @@ import type { QueryResult, Transaction, IDatabaseDriver, EntityManager, Dictionary, PopulateOptions, CountOptions, EntityDictionary, EntityField, NativeInsertUpdateOptions, NativeInsertUpdateManyOptions, } from '@mikro-orm/core'; -import { - DatabaseDriver, Utils, ReferenceType, EntityManagerType, -} from '@mikro-orm/core'; +import { DatabaseDriver, EntityManagerType, ReferenceType, Utils } from '@mikro-orm/core'; import { MongoConnection } from './MongoConnection'; import { MongoPlatform } from './MongoPlatform'; import { MongoEntityManager } from './MongoEntityManager'; @@ -29,6 +27,10 @@ export class MongoDriver extends DatabaseDriver { } async find, P extends string = never>(entityName: string, where: FilterQuery, options: FindOptions = {}): Promise[]> { + if (this.metadata.find(entityName)?.virtual) { + return this.findVirtual(entityName, where, options); + } + const fields = this.buildFields(entityName, options.populate as unknown as PopulateOptions[] || [], options.fields); where = this.renameFields(entityName, where, true); const res = await this.rethrow(this.getConnection('read').find(entityName, where, options.orderBy, options.limit, options.offset, fields, options.ctx)); @@ -37,6 +39,11 @@ export class MongoDriver extends DatabaseDriver { } async findOne, P extends string = never>(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); + return item ?? null; + } + if (Utils.isPrimaryKey(where)) { where = this.buildFilterById(entityName, where as string); } @@ -48,6 +55,18 @@ export class MongoDriver extends DatabaseDriver { return this.mapResult(res[0], this.metadata.find(entityName)!); } + async findVirtual(entityName: string, where: FilterQuery, options: FindOptions): Promise[]> { + const meta = this.metadata.find(entityName)!; + + if (meta.expression instanceof Function) { + const em = this.createEntityManager(false); + return meta.expression(em, where, options) as EntityData[]; + } + + /* istanbul ignore next */ + return super.findVirtual(entityName, where, options); + } + async count>(entityName: string, where: FilterQuery, options: CountOptions = {}, ctx?: Transaction): Promise { where = this.renameFields(entityName, where, true); return this.rethrow(this.getConnection('read').countDocuments(entityName, where, ctx)); diff --git a/tests/MetadataValidator.test.ts b/tests/MetadataValidator.test.ts index 4a5f400b64b0..2fe93015be5f 100644 --- a/tests/MetadataValidator.test.ts +++ b/tests/MetadataValidator.test.ts @@ -1,3 +1,4 @@ +import type { Dictionary } from '@mikro-orm/core'; import { ReferenceType, MetadataStorage, MetadataValidator } from '@mikro-orm/core'; describe('MetadataValidator', () => { @@ -118,6 +119,27 @@ describe('MetadataValidator', () => { expect(() => validator.validateEntityDefinition(new MetadataStorage(meta as any), 'Author')).not.toThrowError(); }); + test('validates virtual entity definition', async () => { + const properties: Dictionary = { + id: { reference: 'scalar', primary: true, name: 'id', type: 'Foo' }, + name: { reference: 'scalar', name: 'name', type: 'string' }, + age: { reference: 'scalar', name: 'age', type: 'string' }, + totalBooks: { reference: 'scalar', name: 'totalBooks', type: 'number' }, + usedTags: { reference: 'scalar', name: 'usedTags', type: 'string[]' }, + invalid1: { reference: 'embedded', name: 'invalid1', type: 'object' }, + invalid2: { reference: '1:m', name: 'invalid2', type: 'Foo' }, + }; + const meta = { AuthorProfile: { expression: '...', name: 'AuthorProfile', className: 'AuthorProfile', properties } } as any; + meta.AuthorProfile.root = meta.AuthorProfile; + expect(() => validator.validateEntityDefinition(new MetadataStorage(meta as any), 'AuthorProfile')).toThrowError(`Virtual entity AuthorProfile cannot have primary key AuthorProfile.id`); + delete properties.id.primary; + expect(() => validator.validateEntityDefinition(new MetadataStorage(meta as any), 'AuthorProfile')).toThrowError(`Only scalar properties are allowed inside virtual entity. Found 'embedded' in AuthorProfile.invalid1`); + delete properties.invalid1; + expect(() => validator.validateEntityDefinition(new MetadataStorage(meta as any), 'AuthorProfile')).toThrowError(`Only scalar properties are allowed inside virtual entity. Found '1:m' in AuthorProfile.invalid2`); + delete properties.invalid2; + expect(() => validator.validateEntityDefinition(new MetadataStorage(meta as any), 'AuthorProfile')).not.toThrowError(); + }); + test('MetadataStorage.get throws when no metadata found', async () => { const storage = new MetadataStorage({}); expect(() => storage.get('Test')).toThrowError('Metadata for entity Test not found'); diff --git a/tests/features/virtual-entities/__snapshots__/virtual-entities.sqlite.test.ts.snap b/tests/features/virtual-entities/__snapshots__/virtual-entities.sqlite.test.ts.snap new file mode 100644 index 000000000000..e70d21d95623 --- /dev/null +++ b/tests/features/virtual-entities/__snapshots__/virtual-entities.sqlite.test.ts.snap @@ -0,0 +1,54 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`virtual entities (sqlite) schema 1`] = ` +"create table \`book_tag4\` (\`id\` integer not null primary key autoincrement, \`created_at\` datetime null, \`updated_at\` datetime null, \`name\` text not null, \`version\` datetime not null default current_timestamp); + +create table \`foo_baz4\` (\`id\` integer not null primary key autoincrement, \`created_at\` datetime null, \`updated_at\` datetime null, \`name\` text not null, \`version\` datetime not null default current_timestamp); + +create table \`foo_bar4\` (\`id\` integer not null primary key autoincrement, \`created_at\` datetime null, \`updated_at\` datetime null, \`name\` text not null default 'asd', \`baz_id\` integer null, \`foo_bar_id\` integer null, \`version\` integer not null default 1, \`blob\` blob null, \`array\` text null, \`object\` json null, constraint \`foo_bar4_baz_id_foreign\` foreign key(\`baz_id\`) references \`foo_baz4\`(\`id\`) on delete set null on update cascade, constraint \`foo_bar4_foo_bar_id_foreign\` foreign key(\`foo_bar_id\`) references \`foo_bar4\`(\`id\`) on delete set null on update cascade); +create unique index \`foo_bar4_baz_id_unique\` on \`foo_bar4\` (\`baz_id\`); +create unique index \`foo_bar4_foo_bar_id_unique\` on \`foo_bar4\` (\`foo_bar_id\`); + +create table \`publisher4\` (\`id\` integer not null primary key autoincrement, \`created_at\` datetime null, \`updated_at\` datetime null, \`name\` text not null default 'asd', \`type\` text check (\`type\` in ('local', 'global')) not null default 'local', \`enum3\` integer null); + +create table \`book4\` (\`id\` integer not null primary key autoincrement, \`created_at\` datetime null, \`updated_at\` datetime null, \`title\` text not null, \`price\` real null, \`author_id\` integer null, \`publisher_id\` integer null, \`meta\` json null, constraint \`book4_author_id_foreign\` foreign key(\`author_id\`) references \`author4\`(\`id\`) on delete set null on update cascade, constraint \`book4_publisher_id_foreign\` foreign key(\`publisher_id\`) references \`publisher4\`(\`id\`) on delete set null on update cascade); +create index \`book4_author_id_index\` on \`book4\` (\`author_id\`); +create index \`book4_publisher_id_index\` on \`book4\` (\`publisher_id\`); + +create table \`author4\` (\`id\` integer not null primary key autoincrement, \`created_at\` datetime null, \`updated_at\` datetime null, \`name\` text not null, \`email\` text not null, \`age\` integer null, \`terms_accepted\` integer not null default 0, \`identities\` text null, \`born\` date(3) null, \`born_time\` time(3) null, \`favourite_book_id\` integer null, constraint \`author4_favourite_book_id_foreign\` foreign key(\`favourite_book_id\`) references \`book4\`(\`id\`) on delete set null on update cascade); +create unique index \`author4_email_unique\` on \`author4\` (\`email\`); +create index \`author4_favourite_book_id_index\` on \`author4\` (\`favourite_book_id\`); + +create table \`tags_ordered\` (\`id\` integer not null primary key autoincrement, \`book4_id\` integer not null, \`book_tag4_id\` integer not null, constraint \`tags_ordered_book4_id_foreign\` foreign key(\`book4_id\`) references \`book4\`(\`id\`) on delete cascade on update cascade, constraint \`tags_ordered_book_tag4_id_foreign\` foreign key(\`book_tag4_id\`) references \`book_tag4\`(\`id\`) on delete cascade on update cascade); +create index \`tags_ordered_book4_id_index\` on \`tags_ordered\` (\`book4_id\`); +create index \`tags_ordered_book_tag4_id_index\` on \`tags_ordered\` (\`book_tag4_id\`); + +create table \`tags_unordered\` (\`book4_id\` integer not null, \`book_tag4_id\` integer not null, constraint \`tags_unordered_book4_id_foreign\` foreign key(\`book4_id\`) references \`book4\`(\`id\`) on delete cascade on update cascade, constraint \`tags_unordered_book_tag4_id_foreign\` foreign key(\`book_tag4_id\`) references \`book_tag4\`(\`id\`) on delete cascade on update cascade, primary key (\`book4_id\`, \`book_tag4_id\`)); +create index \`tags_unordered_book4_id_index\` on \`tags_unordered\` (\`book4_id\`); +create index \`tags_unordered_book_tag4_id_index\` on \`tags_unordered\` (\`book_tag4_id\`); + +create table \`test4\` (\`id\` integer not null primary key autoincrement, \`created_at\` datetime null, \`updated_at\` datetime null, \`name\` text null, \`version\` integer not null default 1); + +create table \`publisher4_tests\` (\`id\` integer not null primary key autoincrement, \`publisher4_id\` integer not null, \`test4_id\` integer not null, constraint \`publisher4_tests_publisher4_id_foreign\` foreign key(\`publisher4_id\`) references \`publisher4\`(\`id\`) on delete cascade on update cascade, constraint \`publisher4_tests_test4_id_foreign\` foreign key(\`test4_id\`) references \`test4\`(\`id\`) on delete cascade on update cascade); +create index \`publisher4_tests_publisher4_id_index\` on \`publisher4_tests\` (\`publisher4_id\`); +create index \`publisher4_tests_test4_id_index\` on \`publisher4_tests\` (\`test4_id\`); + +" +`; + +exports[`virtual entities (sqlite) schema 2`] = `""`; + +exports[`virtual entities (sqlite) schema 3`] = ` +"drop table if exists \`publisher4_tests\`; +drop table if exists \`test4\`; +drop table if exists \`tags_unordered\`; +drop table if exists \`tags_ordered\`; +drop table if exists \`author4\`; +drop table if exists \`book4\`; +drop table if exists \`publisher4\`; +drop table if exists \`foo_bar4\`; +drop table if exists \`foo_baz4\`; +drop table if exists \`book_tag4\`; + +" +`; diff --git a/tests/features/virtual-entities/virtual-entities.mongo.test.ts b/tests/features/virtual-entities/virtual-entities.mongo.test.ts new file mode 100644 index 000000000000..fb19a2e6224a --- /dev/null +++ b/tests/features/virtual-entities/virtual-entities.mongo.test.ts @@ -0,0 +1,151 @@ +import type { Dictionary } from '@mikro-orm/core'; +import { Entity, MikroORM, Property } from '@mikro-orm/core'; +import type { EntityManager } from '@mikro-orm/mongodb'; +import { mockLogger } from '../../bootstrap'; +import { Author, Book, schema } from '../../entities'; + +@Entity({ + expression: (em: EntityManager, where, options) => { + const $sort = { ...options.orderBy } as Dictionary; + $sort._id = 1; + const pipeline: Dictionary[] = [ + { $project: { _id: 0, title: 1, author: 1 } }, + { $sort }, + { $match: where ?? {} }, + { $lookup: { from: 'author', localField: 'author', foreignField: '_id', as: 'author', pipeline: [{ $project: { name: 1 } }] } }, + { $unwind: '$author' }, + { $set: { authorName: '$author.name' } }, + { $unset: ['author'] }, + ]; + + if (options.offset != null) { + pipeline.push({ $skip: options.offset }); + } + + if (options.limit != null) { + pipeline.push({ $limit: options.limit }); + } + + return em.aggregate(Book, pipeline); + }, +}) +class BookWithAuthor { + + @Property() + title!: string; + + @Property() + authorName!: string; + +} + +describe('virtual entities (mongo)', () => { + + let orm: MikroORM; + + beforeAll(async () => { + orm = await MikroORM.init({ + type: 'mongo', + dbName: 'mikro_orm_virtual_entities', + entities: [Author, schema, BookWithAuthor], + }); + await orm.getSchemaGenerator().createSchema(); + }); + beforeEach(async () => orm.getSchemaGenerator().clearDatabase()); + afterAll(async () => orm.close(true)); + + async function createEntities(index: number): Promise { + const author = orm.em.create(Author, { name: 'Jon Snow ' + index, email: 'snow@wall.st-' + index, age: Math.floor(Math.random() * 100) }); + orm.em.create(Book, { title: 'My Life on the Wall, part 1/' + index, author }); + orm.em.create(Book, { title: 'My Life on the Wall, part 2/' + index, author }); + orm.em.create(Book, { title: 'My Life on the Wall, part 3/' + index, author }); + await orm.em.persist(author).flush(); + orm.em.clear(); + + return author; + } + + test('with callback', async () => { + await createEntities(1); + await createEntities(2); + await createEntities(3); + + const mock = mockLogger(orm); + const book = await orm.em.findOneOrFail(BookWithAuthor, { title: 'My Life on the Wall, part 3/1' }); + expect(book).toEqual({ + title: 'My Life on the Wall, part 3/1', + authorName: 'Jon Snow 1', + }); + expect(book).toBeInstanceOf(BookWithAuthor); + const books = await orm.em.find(BookWithAuthor, {}); + expect(books).toEqual([ + { + title: 'My Life on the Wall, part 1/1', + authorName: 'Jon Snow 1', + }, + { + title: 'My Life on the Wall, part 2/1', + authorName: 'Jon Snow 1', + }, + { + title: 'My Life on the Wall, part 3/1', + authorName: 'Jon Snow 1', + }, + { + title: 'My Life on the Wall, part 1/2', + authorName: 'Jon Snow 2', + }, + { + title: 'My Life on the Wall, part 2/2', + authorName: 'Jon Snow 2', + }, + { + title: 'My Life on the Wall, part 3/2', + authorName: 'Jon Snow 2', + }, + { + title: 'My Life on the Wall, part 1/3', + authorName: 'Jon Snow 3', + }, + { + title: 'My Life on the Wall, part 2/3', + authorName: 'Jon Snow 3', + }, + { + title: 'My Life on the Wall, part 3/3', + authorName: 'Jon Snow 3', + }, + ]); + + for (const book of books) { + expect(book).toBeInstanceOf(BookWithAuthor); + } + + const someBooks1 = await orm.em.find(BookWithAuthor, {}, { limit: 2, offset: 1, orderBy: { title: 1 } }); + expect(someBooks1).toHaveLength(2); + expect(someBooks1.map(p => p.title)).toEqual(['My Life on the Wall, part 1/2', 'My Life on the Wall, part 1/3']); + + const someBooks2 = await orm.em.find(BookWithAuthor, {}, { limit: 2, orderBy: { title: 1 } }); + expect(someBooks2).toHaveLength(2); + expect(someBooks2.map(p => p.title)).toEqual(['My Life on the Wall, part 1/1', 'My Life on the Wall, part 1/2']); + + const someBooks3 = await orm.em.find(BookWithAuthor, { title: /^My Life/ }, { limit: 2, orderBy: { title: 1 } }); + expect(someBooks3).toHaveLength(2); + expect(someBooks3.map(p => p.title)).toEqual(['My Life on the Wall, part 1/1', 'My Life on the Wall, part 1/2']); + + const someBooks4 = await orm.em.find(BookWithAuthor, { title: { $in: ['My Life on the Wall, part 1/2', 'My Life on the Wall, part 1/3'] } }); + expect(someBooks4).toHaveLength(2); + expect(someBooks4.map(p => p.title)).toEqual(['My Life on the Wall, part 1/2', 'My Life on the Wall, part 1/3']); + + expect(mock.mock.calls).toHaveLength(6); + expect(mock.mock.calls[0][0]).toMatch(`db.getCollection('books-table').aggregate([ { '$project': { _id: 0, title: 1, author: 1 } }, { '$sort': { _id: 1 } }, { '$match': { title: 'My Life on the Wall, part 3/1' } }, { '$lookup': { from: 'author', localField: 'author', foreignField: '_id', as: 'author', pipeline: [ { '$project': { name: 1 } } ] } }, { '$unwind': '$author' }, { '$set': { authorName: '$author.name' } }, { '$unset': [ 'author' ] } ], { session: undefined }).toArray()`); + expect(mock.mock.calls[1][0]).toMatch(`db.getCollection('books-table').aggregate([ { '$project': { _id: 0, title: 1, author: 1 } }, { '$sort': { _id: 1 } }, { '$match': {} }, { '$lookup': { from: 'author', localField: 'author', foreignField: '_id', as: 'author', pipeline: [ { '$project': { name: 1 } } ] } }, { '$unwind': '$author' }, { '$set': { authorName: '$author.name' } }, { '$unset': [ 'author' ] } ], { session: undefined }).toArray()`); + expect(mock.mock.calls[2][0]).toMatch(`db.getCollection('books-table').aggregate([ { '$project': { _id: 0, title: 1, author: 1 } }, { '$sort': { title: 1, _id: 1 } }, { '$match': {} }, { '$lookup': { from: 'author', localField: 'author', foreignField: '_id', as: 'author', pipeline: [ { '$project': { name: 1 } } ] } }, { '$unwind': '$author' }, { '$set': { authorName: '$author.name' } }, { '$unset': [ 'author' ] }, { '$skip': 1 }, { '$limit': 2 } ], { session: undefined }).toArray();`); + expect(mock.mock.calls[3][0]).toMatch(`db.getCollection('books-table').aggregate([ { '$project': { _id: 0, title: 1, author: 1 } }, { '$sort': { title: 1, _id: 1 } }, { '$match': {} }, { '$lookup': { from: 'author', localField: 'author', foreignField: '_id', as: 'author', pipeline: [ { '$project': { name: 1 } } ] } }, { '$unwind': '$author' }, { '$set': { authorName: '$author.name' } }, { '$unset': [ 'author' ] }, { '$limit': 2 } ], { session: undefined }).toArray();`); + expect(mock.mock.calls[4][0]).toMatch(`db.getCollection('books-table').aggregate([ { '$project': { _id: 0, title: 1, author: 1 } }, { '$sort': { title: 1, _id: 1 } }, { '$match': { title: /^My Life/ } }, { '$lookup': { from: 'author', localField: 'author', foreignField: '_id', as: 'author', pipeline: [ { '$project': { name: 1 } } ] } }, { '$unwind': '$author' }, { '$set': { authorName: '$author.name' } }, { '$unset': [ 'author' ] }, { '$limit': 2 } ], { session: undefined }).toArray();`); + expect(mock.mock.calls[5][0]).toMatch(`db.getCollection('books-table').aggregate([ { '$project': { _id: 0, title: 1, author: 1 } }, { '$sort': { _id: 1 } }, { '$match': { title: { '$in': [ 'My Life on the Wall, part 1/2', 'My Life on the Wall, part 1/3' ] } } }, { '$lookup': { from: 'author', localField: 'author', foreignField: '_id', as: 'author', pipeline: [ { '$project': { name: 1 } } ] } }, { '$unwind': '$author' }, { '$set': { authorName: '$author.name' } }, { '$unset': [ 'author' ] } ], { session: undefined }).toArray();`); + + expect(orm.em.getUnitOfWork().getIdentityMap().keys()).toHaveLength(0); + }); + +}); diff --git a/tests/features/virtual-entities/virtual-entities.sqlite.test.ts b/tests/features/virtual-entities/virtual-entities.sqlite.test.ts new file mode 100644 index 000000000000..050dca4779a7 --- /dev/null +++ b/tests/features/virtual-entities/virtual-entities.sqlite.test.ts @@ -0,0 +1,245 @@ +import { EntitySchema, MikroORM } from '@mikro-orm/core'; +import type { EntityManager } from '@mikro-orm/better-sqlite'; +import { mockLogger } from '../../bootstrap'; +import type { IAuthor4 } from '../../entities-schema'; +import { Author4, BaseEntity5, Book4, BookTag4, FooBar4, FooBaz4, Publisher4, Test4 } from '../../entities-schema'; + +class AuthorProfile { + + name!: string; + age!: number; + totalBooks!: number; + usedTags!: string[]; + +} + +const authorProfilesSQL = 'select name, age, ' + + '(select count(*) from book4 b where b.author_id = a.id) as total_books, ' + + '(select group_concat(distinct t.name) from book4 b join tags_ordered bt on bt.book4_id = b.id join book_tag4 t on t.id = bt.book_tag4_id where b.author_id = a.id group by b.author_id) as used_tags ' + + 'from author4 a group by a.id'; + +const AuthorProfileSchema = new EntitySchema({ + class: AuthorProfile, + expression: authorProfilesSQL, + properties: { + name: { type: 'string' }, + age: { type: 'string' }, + totalBooks: { type: 'number' }, + usedTags: { type: 'string[]' }, + }, +}); + +interface IBookWithAuthor{ + title: string; + authorName: string; + tags: string[]; +} + +const BookWithAuthor = new EntitySchema({ + name: 'BookWithAuthor', + expression: (em: EntityManager) => { + return em.createQueryBuilder(Book4, 'b') + .select(['b.title', 'a.name as author_name', 'group_concat(t.name) as tags']) + .join('b.author', 'a') + .join('b.tags', 't') + .groupBy('b.id'); + }, + properties: { + title: { type: 'string' }, + authorName: { type: 'string' }, + tags: { type: 'string[]' }, + }, +}); + +describe('virtual entities (sqlite)', () => { + + let orm: MikroORM; + + beforeAll(async () => { + orm = await MikroORM.init({ + type: 'better-sqlite', + dbName: ':memory:', + entities: [Author4, Book4, BookTag4, Publisher4, Test4, FooBar4, FooBaz4, BaseEntity5, AuthorProfileSchema, BookWithAuthor], + }); + await orm.getSchemaGenerator().createSchema(); + }); + beforeEach(async () => orm.getSchemaGenerator().clearDatabase()); + afterAll(async () => orm.close(true)); + + async function createEntities(index: number): Promise { + const author = orm.em.create(Author4, { name: 'Jon Snow ' + index, email: 'snow@wall.st-' + index, age: Math.floor(Math.random() * 100) }); + const book1 = orm.em.create(Book4, { title: 'My Life on the Wall, part 1/' + index, author }); + const book2 = orm.em.create(Book4, { title: 'My Life on the Wall, part 2/' + index, author }); + const book3 = orm.em.create(Book4, { title: 'My Life on the Wall, part 3/' + index, author }); + const tag1 = orm.em.create(BookTag4, { name: 'silly-' + index }); + const tag2 = orm.em.create(BookTag4, { name: 'funny-' + index }); + const tag3 = orm.em.create(BookTag4, { name: 'sick-' + index }); + const tag4 = orm.em.create(BookTag4, { name: 'strange-' + index }); + const tag5 = orm.em.create(BookTag4, { name: 'sexy-' + index }); + book1.tags.add(tag1, tag3); + book2.tags.add(tag1, tag2, tag5); + book3.tags.add(tag2, tag4, tag5); + + await orm.em.persist(author).flush(); + orm.em.clear(); + + return author; + } + + test('schema', async () => { + await expect(orm.getSchemaGenerator().getCreateSchemaSQL({ wrap: false })).resolves.toMatchSnapshot(); + await expect(orm.getSchemaGenerator().getUpdateSchemaSQL({ wrap: false })).resolves.toMatchSnapshot(); + await expect(orm.getSchemaGenerator().getDropSchemaSQL({ wrap: false })).resolves.toMatchSnapshot(); + }); + + test('with SQL expression', async () => { + await createEntities(1); + await createEntities(2); + await createEntities(3); + + const mock = mockLogger(orm); + const profiles = await orm.em.find(AuthorProfile, {}); + expect(profiles).toEqual([ + { + name: 'Jon Snow 1', + age: expect.any(Number), + totalBooks: 3, + usedTags: ['silly-1', 'sick-1', 'funny-1', 'sexy-1', 'strange-1'], + }, + { + name: 'Jon Snow 2', + age: expect.any(Number), + totalBooks: 3, + usedTags: ['silly-2', 'sick-2', 'funny-2', 'sexy-2', 'strange-2'], + }, + { + name: 'Jon Snow 3', + age: expect.any(Number), + totalBooks: 3, + usedTags: ['silly-3', 'sick-3', 'funny-3', 'sexy-3', 'strange-3'], + }, + ]); + + for (const profile of profiles) { + expect(profile).toBeInstanceOf(AuthorProfile); + } + + const someProfiles1 = await orm.em.find(AuthorProfile, {}, { limit: 2, offset: 1, orderBy: { name: 'asc' } }); + expect(someProfiles1).toHaveLength(2); + expect(someProfiles1.map(p => p.name)).toEqual(['Jon Snow 2', 'Jon Snow 3']); + + const someProfiles2 = await orm.em.find(AuthorProfile, {}, { limit: 2, orderBy: { name: 'asc' } }); + expect(someProfiles2).toHaveLength(2); + expect(someProfiles2.map(p => p.name)).toEqual(['Jon Snow 1', 'Jon Snow 2']); + + const someProfiles3 = await orm.em.find(AuthorProfile, { $and: [{ name: { $like: 'Jon%' } }, { age: { $gte: 0 } }] }, { limit: 2, orderBy: { name: 'asc' } }); + expect(someProfiles3).toHaveLength(2); + expect(someProfiles3.map(p => p.name)).toEqual(['Jon Snow 1', 'Jon Snow 2']); + + const someProfiles4 = await orm.em.find(AuthorProfile, { name: ['Jon Snow 2', 'Jon Snow 3'] }); + expect(someProfiles4).toHaveLength(2); + expect(someProfiles4.map(p => p.name)).toEqual(['Jon Snow 2', 'Jon Snow 3']); + + expect(mock.mock.calls).toHaveLength(5); + expect(mock.mock.calls[0][0]).toMatch(`select * from (${authorProfilesSQL}) as \`a0\``); + expect(mock.mock.calls[1][0]).toMatch(`select * from (${authorProfilesSQL}) as \`a0\` order by \`a0\`.\`name\` asc limit 2 offset 1`); + expect(mock.mock.calls[2][0]).toMatch(`select * from (${authorProfilesSQL}) as \`a0\` order by \`a0\`.\`name\` asc limit 2`); + expect(mock.mock.calls[3][0]).toMatch(`select * from (${authorProfilesSQL}) as \`a0\` where \`a0\`.\`name\` like 'Jon%' and \`a0\`.\`age\` >= 0 order by \`a0\`.\`name\` asc limit 2`); + expect(mock.mock.calls[4][0]).toMatch(`select * from (${authorProfilesSQL}) as \`a0\` where \`a0\`.\`name\` in ('Jon Snow 2', 'Jon Snow 3')`); + expect(orm.em.getUnitOfWork().getIdentityMap().keys()).toHaveLength(0); + }); + + test('with callback', async () => { + await createEntities(1); + await createEntities(2); + await createEntities(3); + + const mock = mockLogger(orm); + const books = await orm.em.find(BookWithAuthor, {}); + expect(books).toEqual([ + { + title: 'My Life on the Wall, part 1/1', + authorName: 'Jon Snow 1', + tags: ['silly-1', 'sick-1'], + }, + { + title: 'My Life on the Wall, part 2/1', + authorName: 'Jon Snow 1', + tags: ['silly-1', 'funny-1', 'sexy-1'], + }, + { + title: 'My Life on the Wall, part 3/1', + authorName: 'Jon Snow 1', + tags: ['funny-1', 'strange-1', 'sexy-1'], + }, + { + title: 'My Life on the Wall, part 1/2', + authorName: 'Jon Snow 2', + tags: ['silly-2', 'sick-2'], + }, + { + title: 'My Life on the Wall, part 2/2', + authorName: 'Jon Snow 2', + tags: ['silly-2', 'funny-2', 'sexy-2'], + }, + { + title: 'My Life on the Wall, part 3/2', + authorName: 'Jon Snow 2', + tags: ['funny-2', 'strange-2', 'sexy-2'], + }, + { + title: 'My Life on the Wall, part 1/3', + authorName: 'Jon Snow 3', + tags: ['silly-3', 'sick-3'], + }, + { + title: 'My Life on the Wall, part 2/3', + authorName: 'Jon Snow 3', + tags: ['silly-3', 'funny-3', 'sexy-3'], + }, + { + title: 'My Life on the Wall, part 3/3', + authorName: 'Jon Snow 3', + tags: ['funny-3', 'strange-3', 'sexy-3'], + }, + ]); + + for (const book of books) { + expect(book.constructor.name).toBe('BookWithAuthor'); + } + + const someBooks1 = await orm.em.find(BookWithAuthor, {}, { limit: 2, offset: 1, orderBy: { title: 'asc' } }); + expect(someBooks1).toHaveLength(2); + expect(someBooks1.map(p => p.title)).toEqual(['My Life on the Wall, part 1/2', 'My Life on the Wall, part 1/3']); + + const someBooks2 = await orm.em.find(BookWithAuthor, {}, { limit: 2, orderBy: { title: 'asc' } }); + expect(someBooks2).toHaveLength(2); + expect(someBooks2.map(p => p.title)).toEqual(['My Life on the Wall, part 1/1', 'My Life on the Wall, part 1/2']); + + const someBooks3 = await orm.em.find(BookWithAuthor, { $and: [{ title: { $like: 'My Life%' } }, { authorName: { $ne: null } }] }, { limit: 2, orderBy: { title: 'asc' } }); + expect(someBooks3).toHaveLength(2); + expect(someBooks3.map(p => p.title)).toEqual(['My Life on the Wall, part 1/1', 'My Life on the Wall, part 1/2']); + + const someBooks4 = await orm.em.find(BookWithAuthor, { title: ['My Life on the Wall, part 1/2', 'My Life on the Wall, part 1/3'] }); + expect(someBooks4).toHaveLength(2); + expect(someBooks4.map(p => p.title)).toEqual(['My Life on the Wall, part 1/2', 'My Life on the Wall, part 1/3']); + + const sql = 'select `b`.`title`, `a`.`name` as `author_name`, group_concat(t.name) as tags ' + + 'from `book4` as `b` ' + + 'inner join `author4` as `a` on `b`.`author_id` = `a`.`id` ' + + 'inner join `tags_ordered` as `t1` on `b`.`id` = `t1`.`book4_id` ' + + 'inner join `book_tag4` as `t` on `t1`.`book_tag4_id` = `t`.`id` ' + + 'group by `b`.`id`'; + expect(mock.mock.calls).toHaveLength(5); + expect(mock.mock.calls[0][0]).toMatch(`select * from (${sql}) as \`b0\``); + expect(mock.mock.calls[1][0]).toMatch(`select * from (${sql}) as \`b0\` order by \`b0\`.\`title\` asc limit 2 offset 1`); + expect(mock.mock.calls[2][0]).toMatch(`select * from (${sql}) as \`b0\` order by \`b0\`.\`title\` asc limit 2`); + expect(mock.mock.calls[3][0]).toMatch(`select * from (${sql}) as \`b0\` where \`b0\`.\`title\` like 'My Life%' and \`b0\`.\`author_name\` is not null order by \`b0\`.\`title\` asc limit 2`); + expect(mock.mock.calls[4][0]).toMatch(`select * from (${sql}) as \`b0\` where \`b0\`.\`title\` in ('My Life on the Wall, part 1/2', 'My Life on the Wall, part 1/3')`); + + expect(orm.em.getUnitOfWork().getIdentityMap().keys()).toHaveLength(0); + expect(mock.mock.calls[0][0]).toMatch(sql); + expect(orm.em.getUnitOfWork().getIdentityMap().keys()).toHaveLength(0); + }); + +});