diff --git a/packages/core/src/EntityManager.ts b/packages/core/src/EntityManager.ts index a03b8f91787b..d2254cd287c3 100644 --- a/packages/core/src/EntityManager.ts +++ b/packages/core/src/EntityManager.ts @@ -3,7 +3,7 @@ import { v4 as uuid } from 'uuid'; import { Configuration, RequestContext, Utils, ValidationError, SmartQueryHelper } from './utils'; import { EntityAssigner, EntityFactory, EntityLoader, EntityRepository, EntityValidator, IdentifiedReference, Reference, ReferenceType, wrap } from './entity'; import { LockMode, UnitOfWork } from './unit-of-work'; -import { IDatabaseDriver, FindOneOptions, FindOptions, EntityManagerType } from './drivers'; +import { IDatabaseDriver, FindOneOptions, FindOptions, EntityManagerType, Populate, PopulateOptions } from './drivers'; import { EntityData, EntityMetadata, EntityName, AnyEntity, IPrimaryKey, FilterQuery, Primary, Dictionary } from './typings'; import { QueryOrderMap } from './enums'; import { MetadataStorage } from './metadata'; @@ -83,7 +83,9 @@ export class EntityManager { this.validator.validateParams(where); const options = Utils.isObject>(populate) ? populate : { populate, orderBy, limit, offset }; options.orderBy = options.orderBy || {}; - const results = await this.driver.find(entityName, where, { ...options, populate: this.preparePopulate(options.populate) }, this.transactionContext); + const preparedPopulate = this.preparePopulate(entityName, options.populate); + const opts = { ...options, populate: preparedPopulate }; + const results = await this.driver.find(entityName, where, opts, this.transactionContext); if (results.length === 0) { return []; @@ -97,7 +99,7 @@ export class EntityManager { } const unique = Utils.unique(ret); - await this.entityLoader.populate(entityName, unique, options.populate || [], where, options.orderBy, options.refresh); + await this.entityLoader.populate(entityName, unique, preparedPopulate, where, options.orderBy, options.refresh); return unique; } @@ -155,7 +157,10 @@ export class EntityManager { } this.validator.validateParams(where); - const data = await this.driver.findOne(entityName, where, { ...options, populate: this.preparePopulate(options.populate) }, this.transactionContext); + const preparedPopulate = this.preparePopulate(entityName, options.populate); + options.populate = preparedPopulate; + + const data = await this.driver.findOne(entityName, where, { ...options, populate: preparedPopulate }, this.transactionContext); if (!data) { return null; @@ -494,15 +499,17 @@ export class EntityManager { return ret; } - async populate, K extends T | T[]>(entities: K, populate: string | string[] | boolean, where: FilterQuery = {}, orderBy: QueryOrderMap = {}, refresh = false, validate = true): Promise { + async populate, K extends T | T[]>(entities: K, populate: string | Populate, where: FilterQuery = {}, orderBy: QueryOrderMap = {}, refresh = false, validate = true): Promise { const entitiesArray = Utils.asArray(entities); if (entitiesArray.length === 0) { return entities; } + populate = typeof populate === 'string' ? Utils.asArray(populate) : populate; const entityName = entitiesArray[0].constructor.name; - await this.entityLoader.populate(entityName, entitiesArray, populate, where, orderBy, refresh, validate); + const preparedPopulate = this.preparePopulate(entityName, populate); + await this.entityLoader.populate(entityName, entitiesArray, preparedPopulate, where, orderBy, refresh, validate); return entities; } @@ -579,13 +586,32 @@ export class EntityManager { await this.lock(entity, options.lockMode, options.lockVersion); } - await this.entityLoader.populate(entityName, [entity], options.populate || [], where, options.orderBy || {}, options.refresh); + const preparedPopulate = this.preparePopulate(entityName, options.populate); + + await this.entityLoader.populate(entityName, [entity], preparedPopulate, where, options.orderBy || {}, options.refresh); return entity; } - private preparePopulate(populate?: string[] | boolean) { - return Array.isArray(populate) ? populate : []; + private preparePopulate(entityName: string, populate?: Populate): PopulateOptions[] { + if (!populate) { + return []; + } + + if (populate === true) { + return [{ field: '*', all: true }]; + } + + const meta = this.metadata.get(entityName); + + return populate.map(field => { + if (Utils.isString(field)) { + const strategy = meta.properties[field]?.strategy; + return { field, strategy }; + } + + return field; + }); } } diff --git a/packages/core/src/decorators/Property.ts b/packages/core/src/decorators/Property.ts index 14cbf250e194..e0b982860096 100644 --- a/packages/core/src/decorators/Property.ts +++ b/packages/core/src/decorators/Property.ts @@ -1,6 +1,6 @@ import { MetadataStorage } from '../metadata'; import { Utils } from '../utils'; -import { Cascade, EntityValidator, ReferenceType } from '../entity'; +import { Cascade, EntityValidator, ReferenceType, LoadStrategy } from '../entity'; import { EntityName, EntityProperty, AnyEntity, Constructor } from '../typings'; import { Type } from '../types'; @@ -60,4 +60,5 @@ export interface ReferenceOptions, O extends AnyEntity entity?: string | (() => EntityName); cascade?: Cascade[]; eager?: boolean; + strategy?: LoadStrategy; } diff --git a/packages/core/src/drivers/DatabaseDriver.ts b/packages/core/src/drivers/DatabaseDriver.ts index 5abd3da6b48d..e8c12b0999e5 100644 --- a/packages/core/src/drivers/DatabaseDriver.ts +++ b/packages/core/src/drivers/DatabaseDriver.ts @@ -1,4 +1,4 @@ -import { EntityManagerType, FindOneOptions, FindOptions, IDatabaseDriver } from './IDatabaseDriver'; +import { EntityManagerType, FindOneOptions, FindOptions, IDatabaseDriver, PopulateOptions } from './IDatabaseDriver'; import { EntityData, EntityMetadata, EntityProperty, FilterQuery, AnyEntity, Dictionary, Primary } from '../typings'; import { MetadataStorage } from '../metadata'; import { Connection, QueryResult, Transaction } from '../connections'; @@ -51,7 +51,7 @@ export abstract class DatabaseDriver implements IDatabaseD await this.nativeUpdate(coll.owner.constructor.name, wrap(coll.owner, true).__primaryKey, data, ctx); } - mapResult>(result: EntityData, meta: EntityMetadata): T | null { + mapResult>(result: EntityData, meta: EntityMetadata, populate: PopulateOptions[] = []): T | null { if (!result || !meta) { return null; } diff --git a/packages/core/src/drivers/IDatabaseDriver.ts b/packages/core/src/drivers/IDatabaseDriver.ts index 4fc48a02fa03..f7a0932946f7 100644 --- a/packages/core/src/drivers/IDatabaseDriver.ts +++ b/packages/core/src/drivers/IDatabaseDriver.ts @@ -4,7 +4,7 @@ import { QueryOrderMap, QueryFlag } from '../enums'; import { Platform } from '../platforms'; import { MetadataStorage } from '../metadata'; import { LockMode } from '../unit-of-work'; -import { Collection } from '../entity'; +import { Collection, LoadStrategy } from '../entity'; import { EntityManager } from '../index'; import { DriverException } from '../exceptions'; @@ -46,7 +46,7 @@ export interface IDatabaseDriver { aggregate(entityName: string, pipeline: any[]): Promise; - mapResult>(result: EntityData, meta: EntityMetadata): T | null; + mapResult>(result: EntityData, meta: EntityMetadata, populate?: PopulateOptions[]): T | null; /** * When driver uses pivot tables for M:N, this method will load identifiers for given collections from them @@ -75,7 +75,7 @@ export interface IDatabaseDriver { } export interface FindOptions { - populate?: string[] | boolean; + populate?: Populate; orderBy?: QueryOrderMap; limit?: number; offset?: number; @@ -88,7 +88,7 @@ export interface FindOptions { } export interface FindOneOptions { - populate?: string[] | boolean; + populate?: Populate; orderBy?: QueryOrderMap; groupBy?: string | string[]; having?: QBFilterQuery; @@ -99,3 +99,11 @@ export interface FindOneOptions { schema?: string; flags?: QueryFlag[]; } + +export type Populate = (string | PopulateOptions)[] | boolean; + +export type PopulateOptions = { + field: string; + strategy?: LoadStrategy; + all?: boolean; +}; diff --git a/packages/core/src/entity/EntityLoader.ts b/packages/core/src/entity/EntityLoader.ts index d99ac7d4bd54..915ead94087b 100644 --- a/packages/core/src/entity/EntityLoader.ts +++ b/packages/core/src/entity/EntityLoader.ts @@ -1,11 +1,12 @@ import { AnyEntity, EntityProperty, FilterQuery } from '../typings'; import { EntityManager } from '../index'; -import { ReferenceType } from './enums'; +import { ReferenceType, LoadStrategy } from './enums'; import { Utils, ValidationError } from '../utils'; import { Collection } from './Collection'; import { QueryOrder, QueryOrderMap } from '../enums'; import { Reference } from './Reference'; import { wrap } from './wrap'; +import { PopulateOptions } from '../drivers'; export class EntityLoader { @@ -14,26 +15,26 @@ export class EntityLoader { constructor(private readonly em: EntityManager) { } - async populate>(entityName: string, entities: T[], populate: string | string[] | boolean, where: FilterQuery = {}, orderBy: QueryOrderMap = {}, refresh = false, validate = true, lookup = true): Promise { + async populate>(entityName: string, entities: T[], populate: PopulateOptions[] | boolean, where: FilterQuery = {}, orderBy: QueryOrderMap = {}, refresh = false, validate = true, lookup = true): Promise { if (entities.length === 0 || populate === false) { return; } populate = this.normalizePopulate(entityName, populate, lookup); - const invalid = populate.find(field => !this.em.canPopulate(entityName, field)); + const invalid = populate.find(({ field }) => !this.em.canPopulate(entityName, field)); if (validate && invalid) { - throw ValidationError.invalidPropertyName(entityName, invalid); + throw ValidationError.invalidPropertyName(entityName, invalid.field); } - for (const field of populate) { - await this.populateField(entityName, entities, field, where, orderBy, refresh); + for (const pop of populate) { + await this.populateField(entityName, entities, pop.field, where, orderBy, refresh); } } - private normalizePopulate(entityName: string, populate: string | string[] | true, lookup: boolean): string[] { - if (populate === true) { + private normalizePopulate(entityName: string, populate: PopulateOptions[] | true, lookup: boolean): PopulateOptions[] { + if (populate === true || populate.some(p => p.all)) { populate = this.lookupAllRelationships(entityName); } else { populate = Utils.asArray(populate); @@ -144,7 +145,20 @@ export class EntityLoader { const filtered = Utils.unique(children); const prop = this.metadata.get(entityName).properties[f]; - await this.populate(prop.type, filtered, [parts.join('.')], where[prop.name], orderBy[prop.name] as QueryOrderMap, refresh, false, false); + + await this.populate( + prop.type, + filtered, + [{ + field: parts.join('.'), + strategy: LoadStrategy.SELECT_IN, + }], + where[prop.name], + orderBy[prop.name] as QueryOrderMap, + refresh, + false, + false + ); } private async findChildrenFromPivotTable>(filtered: T[], prop: EntityProperty, field: keyof T, refresh: boolean, where?: FilterQuery, orderBy?: QueryOrderMap): Promise { @@ -195,13 +209,13 @@ export class EntityLoader { return children.filter(e => !wrap(e[field], true).isInitialized()).map(e => Utils.unwrapReference(e[field])); } - private lookupAllRelationships(entityName: string, prefix = '', visited: string[] = []): string[] { + private lookupAllRelationships(entityName: string, prefix = '', visited: string[] = []): PopulateOptions[] { if (visited.includes(entityName)) { return []; } visited.push(entityName); - const ret: string[] = []; + const ret: PopulateOptions[] = []; const meta = this.metadata.get(entityName); Object.values(meta.properties) @@ -213,14 +227,17 @@ export class EntityLoader { if (nested.length > 0) { ret.push(...nested); } else { - ret.push(prefixed); + ret.push({ + field: prefixed, + strategy: LoadStrategy.SELECT_IN, + }); } }); return ret; } - private lookupEagerLoadedRelationships(entityName: string, populate: string[], prefix = '', visited: string[] = []): string[] { + private lookupEagerLoadedRelationships(entityName: string, populate: PopulateOptions[], prefix = '', visited: string[] = []): PopulateOptions[] { if (visited.includes(entityName)) { return []; } @@ -237,7 +254,10 @@ export class EntityLoader { if (nested.length > 0) { populate.push(...nested); } else { - populate.push(prefixed); + populate.push({ + field: prefixed, + strategy: LoadStrategy.SELECT_IN, + }); } }); diff --git a/packages/core/src/entity/enums.ts b/packages/core/src/entity/enums.ts index 821a3c164b9b..f11177d8045e 100644 --- a/packages/core/src/entity/enums.ts +++ b/packages/core/src/entity/enums.ts @@ -13,3 +13,8 @@ export enum Cascade { REMOVE = 'remove', ALL = 'all', } + +export enum LoadStrategy { + SELECT_IN = 'select-in', + JOINED = 'joined' +} diff --git a/packages/core/src/metadata/EntitySchema.ts b/packages/core/src/metadata/EntitySchema.ts index 8c2c58a99427..26e5884ae359 100644 --- a/packages/core/src/metadata/EntitySchema.ts +++ b/packages/core/src/metadata/EntitySchema.ts @@ -3,7 +3,7 @@ import { EmbeddedOptions, EnumOptions, IndexOptions, ManyToManyOptions, ManyToOneOptions, OneToManyOptions, OneToOneOptions, PrimaryKeyOptions, PropertyOptions, SerializedPrimaryKeyOptions, UniqueOptions, } from '../decorators'; -import { BaseEntity, Cascade, Collection, EntityRepository, ReferenceType } from '../entity'; +import { BaseEntity, Cascade, Collection, EntityRepository, ReferenceType, LoadStrategy } from '../entity'; import { Type } from '../types'; import { Utils } from '../utils'; @@ -113,7 +113,7 @@ export class EntitySchema = AnyEntity, U extends AnyEntit } addManyToOne(name: string & keyof T, type: TypeType, options: ManyToOneOptions): void { - const prop = { reference: ReferenceType.MANY_TO_ONE, cascade: [Cascade.PERSIST, Cascade.MERGE], ...options } as unknown as EntityProperty; + const prop = this.createProperty(ReferenceType.MANY_TO_ONE, options); Utils.defaultValue(prop, 'nullable', prop.cascade.includes(Cascade.REMOVE) || prop.cascade.includes(Cascade.ALL)); if (prop.joinColumns && !prop.fieldNames) { @@ -138,17 +138,17 @@ export class EntitySchema = AnyEntity, U extends AnyEntit Utils.renameKey(options, 'mappedBy', 'inversedBy'); } - const prop = { reference: ReferenceType.MANY_TO_MANY, cascade: [Cascade.PERSIST, Cascade.MERGE], ...options } as PropertyOptions; + const prop = this.createProperty(ReferenceType.MANY_TO_MANY, options); this.addProperty(name, type, prop); } addOneToMany(name: string & keyof T, type: TypeType, options: OneToManyOptions): void { - const prop = { reference: ReferenceType.ONE_TO_MANY, cascade: [Cascade.PERSIST, Cascade.MERGE], ...options } as PropertyOptions; + const prop = this.createProperty(ReferenceType.ONE_TO_MANY, options); this.addProperty(name, type, prop); } addOneToOne(name: string & keyof T, type: TypeType, options: OneToOneOptions): void { - const prop = { reference: ReferenceType.ONE_TO_ONE, cascade: [Cascade.PERSIST, Cascade.MERGE], ...options } as unknown as EntityProperty; + const prop = this.createProperty(ReferenceType.ONE_TO_ONE, options) as EntityProperty; Utils.defaultValue(prop, 'nullable', prop.cascade.includes(Cascade.REMOVE) || prop.cascade.includes(Cascade.ALL)); Utils.defaultValue(prop, 'owner', !!prop.inversedBy || !prop.mappedBy); Utils.defaultValue(prop, 'unique', prop.owner); @@ -305,4 +305,13 @@ export class EntitySchema = AnyEntity, U extends AnyEntit return type; } + private createProperty(reference: ReferenceType, options: PropertyOptions | EntityProperty) { + return { + reference, + cascade: [Cascade.PERSIST, Cascade.MERGE], + strategy: LoadStrategy.SELECT_IN, + ...options, + }; + } + } diff --git a/packages/core/src/typings.ts b/packages/core/src/typings.ts index 7ad36d81f6e1..52ee4d458b42 100644 --- a/packages/core/src/typings.ts +++ b/packages/core/src/typings.ts @@ -1,5 +1,5 @@ import { QueryOrder } from './enums'; -import { AssignOptions, Cascade, Collection, EntityRepository, EntityValidator, IdentifiedReference, Reference, ReferenceType } from './entity'; +import { AssignOptions, Cascade, Collection, EntityRepository, EntityValidator, IdentifiedReference, Reference, ReferenceType, LoadStrategy } from './entity'; import { EntityManager } from './EntityManager'; import { LockMode } from './unit-of-work'; import { Platform } from './platforms'; @@ -135,6 +135,7 @@ export interface EntityProperty = any> { onUpdate?: (entity: T) => any; onDelete?: 'cascade' | 'no action' | 'set null' | 'set default' | string; onUpdateIntegrity?: 'cascade' | 'no action' | 'set null' | 'set default' | string; + strategy?: LoadStrategy; owner: boolean; inversedBy: string; mappedBy: string; diff --git a/packages/core/src/unit-of-work/ChangeSetPersister.ts b/packages/core/src/unit-of-work/ChangeSetPersister.ts index b79a55b03e56..6913de178253 100644 --- a/packages/core/src/unit-of-work/ChangeSetPersister.ts +++ b/packages/core/src/unit-of-work/ChangeSetPersister.ts @@ -75,7 +75,11 @@ export class ChangeSetPersister { } if (meta.versionProperty && [ChangeSetType.CREATE, ChangeSetType.UPDATE].includes(changeSet.type)) { - const e = await this.driver.findOne(meta.name, wrap(changeSet.entity, true).__primaryKey, { populate: [meta.versionProperty] }, ctx); + const e = await this.driver.findOne(meta.name, wrap(changeSet.entity, true).__primaryKey, { + populate: [{ + field: meta.versionProperty, + }], + }, ctx); (changeSet.entity as T)[meta.versionProperty] = e![meta.versionProperty]; } } diff --git a/packages/knex/src/AbstractSqlDriver.ts b/packages/knex/src/AbstractSqlDriver.ts index b3aa46057589..85e5c78c797c 100644 --- a/packages/knex/src/AbstractSqlDriver.ts +++ b/packages/knex/src/AbstractSqlDriver.ts @@ -1,9 +1,9 @@ import { QueryBuilder as KnexQueryBuilder, Raw, Transaction as KnexTransaction, Value } from 'knex'; import { AnyEntity, Collection, Configuration, Constructor, DatabaseDriver, Dictionary, EntityData, EntityManager, EntityManagerType, EntityMetadata, EntityProperty, - FilterQuery, FindOneOptions, FindOptions, IDatabaseDriver, LockMode, Primary, QueryOrderMap, QueryResult, ReferenceType, Transaction, Utils, wrap, + FilterQuery, FindOneOptions, FindOptions, IDatabaseDriver, LockMode, Primary, QueryOrderMap, QueryResult, ReferenceType, Transaction, Utils, wrap, PopulateOptions, LoadStrategy, } from '@mikro-orm/core'; -import { AbstractSqlConnection, AbstractSqlPlatform, QueryBuilder } from './index'; +import { AbstractSqlConnection, AbstractSqlPlatform, QueryBuilder, Field } from './index'; import { SqlEntityManager } from './SqlEntityManager'; export abstract class AbstractSqlDriver extends DatabaseDriver { @@ -32,7 +32,8 @@ export abstract class AbstractSqlDriver>(entityName: string, where: FilterQuery, options?: FindOptions, ctx?: Transaction): Promise { const meta = this.metadata.get(entityName); options = { populate: [], orderBy: {}, ...(options || {}) }; - options.populate = this.autoJoinOneToOneOwner(meta, options.populate as string[]); + + const populate = this.autoJoinOneToOneOwner(meta, options.populate as PopulateOptions[]); if (options.fields) { options.fields.unshift(...meta.primaryKeys.filter(pk => !options!.fields!.includes(pk))); @@ -40,7 +41,7 @@ export abstract class AbstractSqlDriver>(entityName: string, where: FilterQuery, options?: FindOneOptions, ctx?: Transaction): Promise { options = { populate: [], orderBy: {}, ...(options || {}) }; + const meta = this.metadata.get(entityName); - options.populate = this.autoJoinOneToOneOwner(meta, options.populate as string[]); + + const populate = this.autoJoinOneToOneOwner(meta, options.populate as PopulateOptions[]); const pk = meta.primaryKeys[0]; if (Utils.isPrimaryKey(where)) { @@ -70,20 +73,83 @@ export abstract class AbstractSqlDriver 0) { + selects.push(...this.getSelectForJoinedLoad(qb, meta, joinedLoads)); + } else { + const defaultSelect = options.fields || ['*']; + selects.push(...defaultSelect); + qb.limit(1); + } + + const method = joinedLoads.length > 0 ? 'all' : 'get'; + + qb.select(selects) + .populate(populate) .where(where as Dictionary) .orderBy(options.orderBy!) .groupBy(options.groupBy!) .having(options.having!) - .limit(1) .setLockMode(options.lockMode) .withSchema(options.schema); Utils.asArray(options.flags).forEach(flag => qb.setFlag(flag)); - return this.rethrow(qb.execute('get')); + const result = await this.rethrow(qb.execute(method)); + + if (Array.isArray(result)) { + return this.processJoinedLoads(result, joinedLoads) as unknown as T; + } + + return result; + } + + mapResult>(result: EntityData, meta: EntityMetadata, populate: PopulateOptions[] = [], aliasMap: Dictionary = {}): T | null { + const ret = super.mapResult(result, meta); + + if (!ret) { + return null; + } + + const joinedLoads = this.joinedLoads(meta, populate); + + joinedLoads.forEach((relationName) => { + const relation = meta.properties[relationName]; + const properties = this.metadata.get(relation.type).properties; + const found = Object.entries(aliasMap).find(([,r]) => r === relation.type)!; + const relationAlias = found[0]; + + ret[relationName] = ret[relationName] || []; + const relationPojo = {}; + + let relationExists = true; + Object.values(properties) + .filter(({ reference }) => reference === ReferenceType.SCALAR) + .forEach(prop => { + const alias = `${relationAlias}_${prop.fieldNames[0]}`; + const value = ret[alias]; + + // If the primary key value for the relation is null, we know we haven't joined to anything + // and therefore we don't return any record (since all values would be null) + if (prop.primary && value === null) { + relationExists = false; + } + + if (alias in ret) { + relationPojo[prop.name] = ret[alias]; + delete ret[alias]; + } + }); + + if (relationExists) { + ret[relationName].push(relationPojo); + } + }); + + return ret as T; } async count(entityName: string, where: any, ctx?: Transaction): Promise { @@ -188,7 +254,9 @@ export abstract class AbstractSqlDriver prop.reference === ReferenceType.ONE_TO_ONE && !prop.owner && !populate.includes(prop.name)) - .map(prop => prop.name); + const relationsToPopulate = populate.map(({ field }) => field); + + const toPopulate: PopulateOptions[] = Object.values(meta.properties) + .filter(prop => { + return prop.reference === ReferenceType.ONE_TO_ONE && !prop.owner && !relationsToPopulate.includes(prop.name); + }) + .map(prop => ({ field: prop.name, strategy: prop.strategy })); return [...populate, ...toPopulate]; } + protected joinedLoads(meta: EntityMetadata, populate: PopulateOptions[]): string[] { + return populate + .filter(({ field }) => meta.properties[field]?.strategy === LoadStrategy.JOINED) + .map(({ field }) => field); + } + + protected processJoinedLoads>(rawResults: object[], joinedLoads: string[]): T | null { + if (rawResults.length === 0) { + return null; + } + + return rawResults.reduce((result, value) => { + joinedLoads.forEach(relationName => { + const relation = value[relationName]; + const existing = result[relationName] || []; + + result[relationName] = [...existing, ...relation]; + }); + + return { ...value, ...result }; + }, {}) as unknown as T; + } + + getRefForField(field: string, schema: string, alias: string) { + return this.connection.getKnex().ref(field).withSchema(schema).as(alias); + } + + protected getSelectForJoinedLoad(queryBuilder: QueryBuilder, meta: EntityMetadata, joinedLoads: string[]): Field[] { + const selects: Field[] = []; + + // alias all fields in the primary table + Object.values(meta.properties) + .filter(prop => prop.reference === ReferenceType.SCALAR && prop.persist !== false) + .forEach(prop => { + selects.push(prop.fieldNames[0]); + }); + + let previousRelationName: string; + joinedLoads.forEach((relationName) => { + previousRelationName = relationName; + + const prop = meta.properties[relationName]; + const properties = this.metadata.get(prop.type).properties; + + Object.values(properties) + .filter(prop => prop.reference === ReferenceType.SCALAR && prop.persist !== false) + .forEach(prop => { + const tableAlias = queryBuilder.getNextAlias(relationName, previousRelationName !== relationName); + const fieldAlias = `${tableAlias}_${prop.fieldNames[0]}`; + + selects.push(this.getRefForField(prop.fieldNames[0], tableAlias, fieldAlias)); + queryBuilder.join(relationName, tableAlias, {}, 'leftJoin'); + }); + }); + + return selects; + } + protected createQueryBuilder>(entityName: string, ctx?: Transaction, write?: boolean): QueryBuilder { return new QueryBuilder(entityName, this.metadata, this, ctx, undefined, write ? 'write' : 'read'); } diff --git a/packages/knex/src/query/QueryBuilder.ts b/packages/knex/src/query/QueryBuilder.ts index b49276216dec..31f9ebb68325 100644 --- a/packages/knex/src/query/QueryBuilder.ts +++ b/packages/knex/src/query/QueryBuilder.ts @@ -1,7 +1,7 @@ -import { QueryBuilder as KnexQueryBuilder, Raw, Transaction, Value } from 'knex'; +import { QueryBuilder as KnexQueryBuilder, Raw, Transaction, Value, Ref } from 'knex'; import { AnyEntity, Dictionary, EntityMetadata, EntityProperty, FlatQueryOrderMap, GroupOperator, LockMode, MetadataStorage, QBFilterQuery, QueryFlag, - QueryOrderMap, ReferenceType, SmartQueryHelper, Utils, ValidationError, + QueryOrderMap, ReferenceType, SmartQueryHelper, Utils, ValidationError, PopulateOptions, } from '@mikro-orm/core'; import { QueryType } from './enums'; import { AbstractSqlDriver, QueryBuilderHelper } from '../index'; @@ -14,8 +14,8 @@ import { SqlEntityManager } from '../SqlEntityManager'; export class QueryBuilder = AnyEntity> { type!: QueryType; - _fields?: (string | KnexQueryBuilder)[]; - _populate: string[] = []; + _fields?: Field[]; + _populate: PopulateOptions[] = []; _populateMap: Dictionary = {}; private aliasCounter = 1; @@ -47,7 +47,7 @@ export class QueryBuilder = AnyEntity> { this.select('*'); } - select(fields: string | KnexQueryBuilder | (string | KnexQueryBuilder)[], distinct = false): this { + select(fields: Field | Field[], distinct = false): this { this._fields = Utils.asArray(fields); if (distinct) { @@ -169,8 +169,9 @@ export class QueryBuilder = AnyEntity> { /** * @internal */ - populate(populate: string[]): this { + populate(populate: PopulateOptions[]): this { this._populate = populate; + return this; } @@ -253,8 +254,9 @@ export class QueryBuilder = AnyEntity> { return found ? found[0] : undefined; } - getNextAlias(): string { - return `e${this.aliasCounter++}`; + getNextAlias(prefix = 'e', increment = true): string { + // Take only the first letter of the prefix to keep character counts down since some engines have character limits + return `${prefix.charAt(0)}${increment ? this.aliasCounter++ : this.aliasCounter}`; } async execute(method: 'all' | 'get' | 'run' = 'all', mapResults = true): Promise { @@ -267,10 +269,10 @@ export class QueryBuilder = AnyEntity> { } if (method === 'all' && Array.isArray(res)) { - return res.map(r => this.driver.mapResult(r, meta)) as unknown as U; + return res.map(r => this.driver.mapResult(r, meta, this._populate, this._aliasMap)) as unknown as U; } - return this.driver.mapResult(res, meta) as unknown as U; + return this.driver.mapResult(res, meta, this._populate, this._aliasMap) as unknown as U; } async getResult(): Promise { @@ -351,8 +353,8 @@ export class QueryBuilder = AnyEntity> { return ret; } - private prepareFields(fields: (string | KnexQueryBuilder)[], type: 'where' | 'groupBy' | 'sub-query' = 'where'): T[] { - const ret: (string | KnexQueryBuilder)[] = []; + private prepareFields(fields: Field[], type: 'where' | 'groupBy' | 'sub-query' = 'where'): T[] { + const ret: Field[] = []; fields.forEach(f => { if (!Utils.isString(f)) { @@ -447,7 +449,7 @@ export class QueryBuilder = AnyEntity> { } const meta = this.metadata.get(this.entityName, false, false); - this._populate.forEach(field => { + this._populate.forEach(({ field }) => { const [fromAlias, fromField] = this.helper.splitField(field); const aliasedField = `${fromAlias}.${fromField}`; @@ -537,6 +539,12 @@ export class QueryBuilder = AnyEntity> { } +type KnexStringRef = Ref; + +export type Field = string | KnexStringRef | KnexQueryBuilder; + export interface JoinOptions { table: string; type: 'leftJoin' | 'innerJoin' | 'pivotJoin'; diff --git a/tests/JoinedLoads.test.ts b/tests/JoinedLoads.test.ts new file mode 100644 index 000000000000..1e0d006615cb --- /dev/null +++ b/tests/JoinedLoads.test.ts @@ -0,0 +1,105 @@ +import { MikroORM, Logger, LoadStrategy } from '@mikro-orm/core'; +import { PostgreSqlDriver } from '@mikro-orm/postgresql'; +import { initORMPostgreSql, wipeDatabasePostgreSql } from './bootstrap'; +import { Author2, Book2 } from './entities-sql'; + +describe('Joined loading', () => { + + let orm: MikroORM; + + beforeAll(async () => orm = await initORMPostgreSql()); + beforeEach(async () => wipeDatabasePostgreSql(orm.em)); + + afterAll(async () => orm.close(true)); + + test('populate OneToMany with joined strategy', async () => { + const author2 = new Author2('Albert Camus', 'albert.camus@email.com'); + const stranger = new Book2('The Stranger', author2); + const fall = new Book2('The Fall', author2); + + author2.books2.add(stranger, fall); + + await orm.em.persistAndFlush(author2); + orm.em.clear(); + + const a2 = await orm.em.findOneOrFail(Author2, { id: author2.id }, { populate: ['books2', 'following'] }); + + expect(a2.books2).toHaveLength(2); + expect(a2.books2[0].title).toEqual('The Stranger'); + expect(a2.books2[1].title).toEqual('The Fall'); + }); + + test('should only fire one query', async () => { + const author2 = new Author2('Albert Camus', 'albert.camus@email.com'); + const stranger = new Book2('The Stranger', author2); + const fall = new Book2('The Fall', author2); + + author2.books2.add(stranger, fall); + + await orm.em.persistAndFlush(author2); + orm.em.clear(); + + const mock = jest.fn(); + const logger = new Logger(mock, true); + Object.assign(orm.em.config, { logger }); + + await orm.em.findOneOrFail(Author2, { id: author2.id }, { populate: ['books2'] }); + + expect(mock.mock.calls.length).toBe(1); + expect(mock.mock.calls[0][0]).toMatch('select "e0"."id", "e0"."created_at", "e0"."updated_at", "e0"."name", "e0"."email", "e0"."age", "e0"."terms_accepted", "e0"."optional", "e0"."identities", "e0"."born", "e0"."born_time", "b1"."uuid_pk" as "b1_uuid_pk", "b1"."created_at" as "b1_created_at", "b1"."title" as "b1_title", "b1"."perex" as "b1_perex", "b1"."price" as "b1_price", "b1"."double" as "b1_double", "b1"."meta" as "b1_meta" from "author2" as "e0" left join "book2" as "b1" on "e0"."id" = "b1"."author_id" where "e0"."id" = $1'); + }); + + test('can populate all related entities', async () => { + const author2 = new Author2('Albert Camus', 'albert.camus@email.com'); + const stranger = new Book2('The Stranger', author2); + const fall = new Book2('The Fall', author2); + + author2.books2.add(stranger, fall); + + await orm.em.persistAndFlush(author2); + orm.em.clear(); + + const a2 = await orm.em.findOneOrFail(Author2, { id: author2.id }, { populate: true }); + + expect(a2.books2).toHaveLength(2); + expect(a2.books).toHaveLength(2); + }); + + test('when related records exist it still returns the root entity', async () => { + const author2 = new Author2('Albert Camus', 'albert.camus@email.com'); + + await orm.em.persistAndFlush(author2); + orm.em.clear(); + + const a2 = await orm.em.findOneOrFail(Author2, { id: author2.id }, { populate: ['books2'] }); + + expect(a2).toHaveProperty('id'); + expect(a2.books2).toHaveLength(0); + }); + + test('when the root entity does not exist', async () => { + const a2 = await orm.em.findOne(Author2, { id: 1 }, { populate: ['books2'] }); + + expect(a2).toBeNull(); + }); + + test('when populating only a single relation via em.populate', async () => { + const author2 = new Author2('Albert Camus', 'albert.camus@email.com'); + const stranger = new Book2('The Stranger', author2); + const fall = new Book2('The Fall', author2); + + author2.books2.add(stranger, fall); + + await orm.em.persistAndFlush(author2); + orm.em.clear(); + + const a2 = await orm.em.findOneOrFail(Author2, { id: 1 }); + await orm.em.populate(a2, 'books2'); + + expect(a2.books2).toHaveLength(2); + }); + + test.todo('populate OneToOne with joined strategy'); + test.todo('populate ManyToMany with joined strategy'); + test.todo('handles nested joinedLoads that map to the same entity, eg book.author.favouriteAuthor'); +}); diff --git a/tests/QueryBuilder.test.ts b/tests/QueryBuilder.test.ts index b9d02144e078..94ef86615f9d 100644 --- a/tests/QueryBuilder.test.ts +++ b/tests/QueryBuilder.test.ts @@ -395,7 +395,7 @@ describe('QueryBuilder', () => { test('select by 1:1 inversed', async () => { const qb = orm.em.createQueryBuilder(FooBaz2); - qb.select('*').where({ id: 123 }).populate(['bar']); + qb.select('*').where({ id: 123 }).populate([{ field: 'bar' }]); expect(qb.getQuery()).toEqual('select `e0`.*, `e1`.`id` as `bar_id` from `foo_baz2` as `e0` left join `foo_bar2` as `e1` on `e0`.`id` = `e1`.`baz_id` where `e0`.`id` = ?'); expect(qb.getParams()).toEqual([123]); }); @@ -409,7 +409,7 @@ describe('QueryBuilder', () => { test('select by 1:1 inversed with populate', async () => { const qb = orm.em.createQueryBuilder(FooBaz2); - qb.select('*').where({ id: 123 }).populate(['bar']); + qb.select('*').where({ id: 123 }).populate([{ field: 'bar' }]); expect(qb.getQuery()).toEqual('select `e0`.*, `e1`.`id` as `bar_id` from `foo_baz2` as `e0` left join `foo_bar2` as `e1` on `e0`.`id` = `e1`.`baz_id` where `e0`.`id` = ?'); expect(qb.getParams()).toEqual([123]); }); @@ -423,14 +423,14 @@ describe('QueryBuilder', () => { test('select by 1:1 inversed with populate (uuid pk)', async () => { const qb = orm.em.createQueryBuilder(Book2); - qb.select('*').where({ test: 123 }).populate(['test']); + qb.select('*').where({ test: 123 }).populate([{ field: 'test' }]); expect(qb.getQuery()).toEqual('select `e0`.*, `e1`.`id` as `test_id`, `e0`.price * 1.19 as `price_taxed` from `book2` as `e0` left join `test2` as `e1` on `e0`.`uuid_pk` = `e1`.`book_uuid_pk` where `e1`.`id` = ?'); expect(qb.getParams()).toEqual([123]); }); test('select by 1:1 inversed with populate() before where() (uuid pk)', async () => { const qb = orm.em.createQueryBuilder(Book2); - qb.select('*').populate(['test']).where({ test: 123 }); + qb.select('*').populate([{ field: 'test' }]).where({ test: 123 }); expect(qb.getQuery()).toEqual('select `e0`.*, `e1`.`id` as `test_id`, `e0`.price * 1.19 as `price_taxed` from `book2` as `e0` left join `test2` as `e1` on `e0`.`uuid_pk` = `e1`.`book_uuid_pk` where `e1`.`id` = ?'); expect(qb.getParams()).toEqual([123]); }); @@ -455,7 +455,7 @@ describe('QueryBuilder', () => { test('select by m:n inverse side (that is not defined as property) via populate', async () => { const qb = orm.em.createQueryBuilder(Test2); - qb.select('*').populate(['publisher2_tests']).where({ 'publisher2_tests.Publisher2_owner': { $in: [ 1, 2 ] } }).orderBy({ 'publisher2_tests.id': QueryOrder.ASC }); + qb.select('*').populate([{ field: 'publisher2_tests' }]).where({ 'publisher2_tests.Publisher2_owner': { $in: [ 1, 2 ] } }).orderBy({ 'publisher2_tests.id': QueryOrder.ASC }); let sql = 'select `e0`.*, `e1`.`test2_id`, `e1`.`publisher2_id` from `test2` as `e0` '; sql += 'left join `publisher2_tests` as `e1` on `e0`.`id` = `e1`.`test2_id` '; sql += 'where `e1`.`publisher2_id` in (?, ?) '; @@ -466,7 +466,7 @@ describe('QueryBuilder', () => { test('select by m:n self reference owner', async () => { const qb = orm.em.createQueryBuilder(Author2); - qb.select('*').populate(['author2_following']).where({ 'author2_following.Author2_owner': { $in: [ 1, 2 ] } }); + qb.select('*').populate([{ field: 'author2_following' }]).where({ 'author2_following.Author2_owner': { $in: [ 1, 2 ] } }); let sql = 'select `e0`.*, `e1`.`author2_2_id`, `e1`.`author2_1_id` from `author2` as `e0` '; sql += 'left join `author2_following` as `e1` on `e0`.`id` = `e1`.`author2_2_id` '; sql += 'where `e1`.`author2_1_id` in (?, ?)'; @@ -476,7 +476,7 @@ describe('QueryBuilder', () => { test('select by m:n self reference inverse', async () => { const qb = orm.em.createQueryBuilder(Author2); - qb.select('*').populate(['author2_following']).where({ 'author2_following.Author2_inverse': { $in: [ 1, 2 ] } }); + qb.select('*').populate([{ field: 'author2_following' }]).where({ 'author2_following.Author2_inverse': { $in: [ 1, 2 ] } }); let sql = 'select `e0`.*, `e1`.`author2_1_id`, `e1`.`author2_2_id` from `author2` as `e0` '; sql += 'left join `author2_following` as `e1` on `e0`.`id` = `e1`.`author2_1_id` '; sql += 'where `e1`.`author2_2_id` in (?, ?)'; @@ -486,7 +486,7 @@ describe('QueryBuilder', () => { test('select by m:n with composite keys', async () => { const qb = orm.em.createQueryBuilder(User2); - qb.select('*').populate(['user2_cars']).where({ 'user2_cars.Car2_inverse': { $in: [ [1, 2], [3, 4] ] } }); + qb.select('*').populate([{ field: 'user2_cars' }]).where({ 'user2_cars.Car2_inverse': { $in: [ [1, 2], [3, 4] ] } }); const sql = 'select `e0`.*, `e1`.`user2_first_name`, `e1`.`user2_last_name`, `e1`.`car2_name`, `e1`.`car2_year` ' + 'from `user2` as `e0` left join `user2_cars` as `e1` on `e0`.`first_name` = `e1`.`user2_first_name` and `e0`.`last_name` = `e1`.`user2_last_name` ' + 'where (`e1`.`car2_name`, `e1`.`car2_year`) in ((?, ?), (?, ?))'; @@ -496,7 +496,7 @@ describe('QueryBuilder', () => { test('select by m:n with unknown populate ignored', async () => { const qb = orm.em.createQueryBuilder(Test2); - qb.select('*').populate(['not_existing']); + qb.select('*').populate([{ field: 'not_existing' }]); expect(qb.getQuery()).toEqual('select `e0`.* from `test2` as `e0`'); expect(qb.getParams()).toEqual([]); }); @@ -888,7 +888,7 @@ describe('QueryBuilder', () => { test('select with populate and join of 1:m', async () => { const qb = orm.em.createQueryBuilder(Author2); - qb.select('*').populate(['books']).leftJoin('books', 'b'); + qb.select('*').populate([{ field: 'books' }]).leftJoin('books', 'b'); expect(qb.getQuery()).toEqual('select `e0`.* ' + 'from `author2` as `e0` ' + 'left join `book2` as `b` on `e0`.`id` = `b`.`author_id`'); @@ -896,7 +896,7 @@ describe('QueryBuilder', () => { test('select with populate and join of m:n', async () => { const qb = orm.em.createQueryBuilder(Book2); - qb.select('*').populate(['tags']).leftJoin('tags', 't'); + qb.select('*').populate([{ field: 'tags' }]).leftJoin('tags', 't'); expect(qb.getQuery()).toEqual('select `e0`.*, `e1`.`book2_uuid_pk`, `e1`.`book_tag2_id`, `e0`.price * 1.19 as `price_taxed` ' + 'from `book2` as `e0` ' + 'left join `book2_tags` as `e1` on `e0`.`uuid_pk` = `e1`.`book2_uuid_pk` ' + diff --git a/tests/entities-sql/Author2.ts b/tests/entities-sql/Author2.ts index 9a57feb3ad45..a00f155d0b96 100644 --- a/tests/entities-sql/Author2.ts +++ b/tests/entities-sql/Author2.ts @@ -1,6 +1,6 @@ import { AfterCreate, AfterDelete, AfterUpdate, BeforeCreate, BeforeDelete, BeforeUpdate, Collection, Entity, OneToMany, Property, ManyToOne, - QueryOrder, OnInit, ManyToMany, DateType, TimeType, Index, Unique, OneToOne, Cascade, + QueryOrder, OnInit, ManyToMany, DateType, TimeType, Index, Unique, OneToOne, Cascade, LoadStrategy, } from '@mikro-orm/core'; import { Book2 } from './Book2'; @@ -51,6 +51,9 @@ export class Author2 extends BaseEntity2 { @OneToMany({ entity: () => Book2, mappedBy: 'author', orderBy: { title: QueryOrder.ASC } }) books!: Collection; + @OneToMany({ entity: () => Book2, mappedBy: 'author', strategy: LoadStrategy.JOINED, orderBy: { title: QueryOrder.ASC } }) + books2!: Collection; + @OneToOne({ entity: () => Address2, mappedBy: address => address.author, cascade: [Cascade.ALL] }) address?: Address2;