diff --git a/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts b/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts index d8248c945048..2506c3cc8ff2 100644 --- a/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts +++ b/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts @@ -87,6 +87,9 @@ describe('HasMany relation', () => { }); const customer = await customerRepo.findById(existingCustomerId, { + fields: { + name: true, + }, include: [ { relation: 'orders', @@ -100,13 +103,18 @@ describe('HasMany relation', () => { }); withProtoCheck(false, () => { - expect(customer.orders).length(1); - expect(customer.orders).to.matchEach((v: Partial) => { - expect(v).to.deepEqual({ - id: undefined, - description: order.description, - customerId: undefined, - }); + expect(customer).to.deepEqual({ + id: undefined, + name: 'a customer', + orders: [ + { + id: undefined, + description: order.description, + customerId: undefined, + }, + ], + reviewsApproved: undefined, + reviewsAuthored: undefined, }); }); }); @@ -116,6 +124,10 @@ describe('HasMany relation', () => { description: 'an order desc', }); const customer = await customerRepo.findById(existingCustomerId, { + fields: { + id: false, + name: true, + }, include: [ { relation: 'orders', @@ -130,13 +142,18 @@ describe('HasMany relation', () => { }); withProtoCheck(false, () => { - expect(customer.orders).length(1); - expect(customer.orders).to.matchEach((v: Partial) => { - expect(v).to.deepEqual({ - id: undefined, - description: order.description, - customerId: undefined, - }); + expect(customer).to.deepEqual({ + id: undefined, + name: 'a customer', + orders: [ + { + id: undefined, + description: order.description, + customerId: undefined, + }, + ], + reviewsApproved: undefined, + reviewsAuthored: undefined, }); }); }); @@ -146,6 +163,9 @@ describe('HasMany relation', () => { description: 'an order desc', }); const customer = await customerRepo.findById(existingCustomerId, { + fields: { + id: false, + }, include: [ { relation: 'orders', @@ -159,13 +179,18 @@ describe('HasMany relation', () => { }); withProtoCheck(false, () => { - expect(customer.orders).length(1); - expect(customer.orders).to.matchEach((v: Partial) => { - expect(v).to.deepEqual({ - id: order.id, - description: order.description, - customerId: undefined, - }); + expect(customer).to.deepEqual({ + id: undefined, + name: 'a customer', + orders: [ + { + id: order.id, + description: order.description, + customerId: undefined, + }, + ], + reviewsApproved: undefined, + reviewsAuthored: undefined, }); }); }); @@ -175,6 +200,10 @@ describe('HasMany relation', () => { description: 'an order desc', }); const customer = await customerRepo.findById(existingCustomerId, { + fields: { + id: true, + name: true, + }, include: [ { relation: 'orders', @@ -189,22 +218,28 @@ describe('HasMany relation', () => { }); withProtoCheck(false, () => { - expect(customer.orders).length(1); - expect(customer.orders).to.matchEach((v: Partial) => { - expect(v).to.deepEqual({ - id: undefined, - description: order.description, - customerId: order.customerId, - }); + expect(customer).to.deepEqual({ + id: 1, + name: 'a customer', + orders: [ + { + id: undefined, + description: order.description, + customerId: order.customerId, + }, + ], + reviewsApproved: undefined, + reviewsAuthored: undefined, }); }); }); - it('includes only fields set in filter', async () => { + it('does not include fields not set in filter', async () => { await customerOrderRepo.create({ description: 'an order desc', }); const customer = await customerRepo.findById(existingCustomerId, { + fields: {}, include: [ { relation: 'orders', @@ -216,13 +251,18 @@ describe('HasMany relation', () => { }); withProtoCheck(false, () => { - expect(customer.orders).length(1); - expect(customer.orders).to.matchEach((v: Partial) => { - expect(v).to.deepEqual({ - id: undefined, - description: undefined, - customerId: undefined, - }); + expect(customer).to.deepEqual({ + id: undefined, + name: undefined, + orders: [ + { + id: undefined, + description: undefined, + customerId: undefined, + }, + ], + reviewsApproved: undefined, + reviewsAuthored: undefined, }); }); }); @@ -345,6 +385,9 @@ describe('BelongsTo relation', () => { it('can include the related model when the foreign key is omitted in filter', async () => { const orderWithRelations = (await orderRepo.findById(order.id, { + fields: { + description: true, + }, include: [ { relation: 'customer', @@ -358,18 +401,27 @@ describe('BelongsTo relation', () => { })) as OrderWithRelations; withProtoCheck(false, () => { - expect(orderWithRelations.customer).to.deepEqual({ + expect(orderWithRelations).to.deepEqual({ id: undefined, - name: customer.name, - orders: undefined, - reviewsApproved: undefined, - reviewsAuthored: undefined, + description: order.description, + customerId: undefined, + customer: { + id: undefined, + name: customer.name, + orders: undefined, + reviewsApproved: undefined, + reviewsAuthored: undefined, + }, }); }); }); it('can include the related model when the foreign key is disabled in filter', async () => { const orderWithRelations = (await orderRepo.findById(order.id, { + fields: { + description: true, + customerId: false, + }, include: [ { relation: 'customer', @@ -384,18 +436,26 @@ describe('BelongsTo relation', () => { })) as OrderWithRelations; withProtoCheck(false, () => { - expect(orderWithRelations.customer).to.deepEqual({ + expect(orderWithRelations).to.deepEqual({ id: undefined, - name: customer.name, - orders: undefined, - reviewsApproved: undefined, - reviewsAuthored: undefined, + description: order.description, + customerId: undefined, + customer: { + id: undefined, + name: customer.name, + orders: undefined, + reviewsApproved: undefined, + reviewsAuthored: undefined, + }, }); }); }); it('can include the related model when only the foreign key is disabled in filter', async () => { const orderWithRelations = (await orderRepo.findById(order.id, { + fields: { + customerId: false, + }, include: [ { relation: 'customer', @@ -409,18 +469,27 @@ describe('BelongsTo relation', () => { })) as OrderWithRelations; withProtoCheck(false, () => { - expect(orderWithRelations.customer).to.deepEqual({ - id: undefined, - name: customer.name, - orders: undefined, - reviewsApproved: undefined, - reviewsAuthored: undefined, + expect(orderWithRelations).to.deepEqual({ + id: order.id, + description: order.description, + customerId: undefined, + customer: { + id: undefined, + name: customer.name, + orders: undefined, + reviewsApproved: undefined, + reviewsAuthored: undefined, + }, }); }); }); it('preserves the foreign key value when set in filter', async () => { const orderWithRelations = (await orderRepo.findById(order.id, { + fields: { + description: true, + customerId: true, + }, include: [ { relation: 'customer', @@ -435,18 +504,24 @@ describe('BelongsTo relation', () => { })) as OrderWithRelations; withProtoCheck(false, () => { - expect(orderWithRelations.customer).to.deepEqual({ - id: customer.id, - name: customer.name, - orders: undefined, - reviewsApproved: undefined, - reviewsAuthored: undefined, + expect(orderWithRelations).to.deepEqual({ + id: undefined, + description: order.description, + customerId: order.customerId, + customer: { + id: customer.id, + name: customer.name, + orders: undefined, + reviewsApproved: undefined, + reviewsAuthored: undefined, + }, }); }); }); - it('includes only fields set in filter', async () => { + it('does not include fields not set in filter', async () => { const orderWithRelations = (await orderRepo.findById(order.id, { + fields: {}, include: [ { relation: 'customer', @@ -458,12 +533,17 @@ describe('BelongsTo relation', () => { })) as OrderWithRelations; withProtoCheck(false, () => { - expect(orderWithRelations.customer).to.deepEqual({ + expect(orderWithRelations).to.deepEqual({ id: undefined, - name: undefined, - orders: undefined, - reviewsApproved: undefined, - reviewsAuthored: undefined, + description: undefined, + customerId: undefined, + customer: { + id: undefined, + name: undefined, + orders: undefined, + reviewsApproved: undefined, + reviewsAuthored: undefined, + }, }); }); }); diff --git a/packages/repository/src/__tests__/unit/repositories/legacy-juggler-bridge.unit.ts b/packages/repository/src/__tests__/unit/repositories/legacy-juggler-bridge.unit.ts index b85922d4b23a..47d9aaa6be02 100644 --- a/packages/repository/src/__tests__/unit/repositories/legacy-juggler-bridge.unit.ts +++ b/packages/repository/src/__tests__/unit/repositories/legacy-juggler-bridge.unit.ts @@ -31,6 +31,7 @@ import { InclusionResolver, } from '../../../relations'; import {CrudConnectorStub} from '../crud-connector.stub'; + const TransactionClass = require('loopback-datasource-juggler').Transaction; describe('legacy loopback-datasource-juggler', () => { @@ -515,8 +516,9 @@ describe('DefaultCrudRepository', () => { const hasManyResolver: InclusionResolver< Folder, File - > = async entities => { + > = async resolveEntities => { const files = []; + const entities = await resolveEntities('id'); for (const entity of entities) { const file = await folderFiles(entity.id).find(); files.push(file); @@ -528,9 +530,9 @@ describe('DefaultCrudRepository', () => { const belongsToResolver: InclusionResolver< File, Folder - > = async entities => { + > = async resolveEntities => { const folders = []; - + const entities = await resolveEntities('id'); for (const file of entities) { const folder = await fileFolder(file.folderId); folders.push(folder); @@ -542,9 +544,10 @@ describe('DefaultCrudRepository', () => { const hasOneResolver: InclusionResolver< Folder, Author - > = async entities => { + > = async resolveEntities => { const authors = []; + const entities = await resolveEntities('id'); for (const folder of entities) { const author = await folderAuthor(folder.id).get(); authors.push(author); @@ -714,8 +717,8 @@ describe('DefaultCrudRepository', () => { it('implements Repository.registerInclusionResolver()', () => { const repo = new DefaultCrudRepository(Note, ds); - const resolver: InclusionResolver = async entities => { - return entities; + const resolver: InclusionResolver = async resolveEntities => { + return resolveEntities('id'); }; repo.registerInclusionResolver('notes', resolver); const setResolver = repo.inclusionResolvers.get('notes'); diff --git a/packages/repository/src/__tests__/unit/repositories/relations-helpers/include-related-models.unit.ts b/packages/repository/src/__tests__/unit/repositories/relations-helpers/include-related-models.unit.ts index 12b7693c1b18..3f3a94b9878c 100644 --- a/packages/repository/src/__tests__/unit/repositories/relations-helpers/include-related-models.unit.ts +++ b/packages/repository/src/__tests__/unit/repositories/relations-helpers/include-related-models.unit.ts @@ -35,14 +35,18 @@ describe('includeRelatedModels', () => { it('returns source model if no filter is passed in', async () => { const category = await categoryRepo.create({name: 'category 1'}); await categoryRepo.create({name: 'category 2'}); - const result = await includeRelatedModels(categoryRepo, [category]); + const result = await includeRelatedModels(categoryRepo, async () => [ + category, + ]); expect(result).to.eql([category]); }); it('throws error if the target repository does not have the registered resolver', async () => { const category = await categoryRepo.create({name: 'category 1'}); await expect( - includeRelatedModels(categoryRepo, [category], [{relation: 'products'}]), + includeRelatedModels(categoryRepo, async () => [category], { + include: [{relation: 'products'}], + }), ).to.be.rejectedWith( /Invalid "filter.include" entries: {"relation":"products"}/, ); @@ -55,8 +59,10 @@ describe('includeRelatedModels', () => { const categories = await includeRelatedModels( categoryRepo, - [category], - [{relation: 'products'}], + async () => [category], + { + include: [{relation: 'products'}], + }, ); expect(categories[0].products).to.be.empty(); @@ -73,8 +79,10 @@ describe('includeRelatedModels', () => { const productWithCategories = await includeRelatedModels( productRepo, - [product], - [{relation: 'category'}], + async () => [product], + { + include: [{relation: 'category'}], + }, ); expect(productWithCategories[0].toJSON()).to.deepEqual({ @@ -105,8 +113,10 @@ describe('includeRelatedModels', () => { const productWithCategories = await includeRelatedModels( productRepo, - [productOne, productTwo, productThree], - [{relation: 'category'}], + async () => [productOne, productTwo, productThree], + { + include: [{relation: 'category'}], + }, ); expect(toJSON(productWithCategories)).to.deepEqual([ @@ -132,8 +142,10 @@ describe('includeRelatedModels', () => { const categoryWithProducts = await includeRelatedModels( categoryRepo, - [category], - [{relation: 'products'}], + async () => [category], + { + include: [{relation: 'products'}], + }, ); expect(toJSON(categoryWithProducts)).to.deepEqual([ @@ -167,8 +179,10 @@ describe('includeRelatedModels', () => { const categoryWithProducts = await includeRelatedModels( categoryRepo, - [categoryOne, categoryTwo, categoryThree], - [{relation: 'products'}], + async () => [categoryOne, categoryTwo, categoryThree], + { + include: [{relation: 'products'}], + }, ); expect(toJSON(categoryWithProducts)).to.deepEqual([ @@ -186,9 +200,10 @@ describe('includeRelatedModels', () => { const belongsToResolver: InclusionResolver< Product, Category - > = async entities => { + > = async resolveEntities => { const categories = []; + const entities = await resolveEntities('id'); for (const product of entities) { const category = await categoryRepo.findById(product.categoryId); categories.push(category); @@ -200,7 +215,8 @@ describe('includeRelatedModels', () => { const hasManyResolver: InclusionResolver< Category, Product - > = async entities => { + > = async resolveEntities => { + const entities = await resolveEntities('id'); const products = []; for (const category of entities) { diff --git a/packages/repository/src/relations/belongs-to/belongs-to.inclusion-resolver.ts b/packages/repository/src/relations/belongs-to/belongs-to.inclusion-resolver.ts index 72d9adf29305..02ea6e3936c2 100644 --- a/packages/repository/src/relations/belongs-to/belongs-to.inclusion-resolver.ts +++ b/packages/repository/src/relations/belongs-to/belongs-to.inclusion-resolver.ts @@ -43,13 +43,14 @@ export function createBelongsToInclusionResolver< const relationMeta = resolveBelongsToMetadata(meta); return async function fetchIncludedModels( - entities: Entity[], + resolveEntities: (ensureKey: string) => Promise, inclusion: Inclusion, options?: Options, ): Promise<((Target & TargetRelations) | undefined)[]> { + const sourceKey = relationMeta.keyFrom; + const entities = await resolveEntities(sourceKey); if (!entities.length) return []; - const sourceKey = relationMeta.keyFrom; const sourceIds = entities.map(e => (e as AnyObject)[sourceKey]); const targetKey = relationMeta.keyTo as StringKeyOf; const dedupedSourceIds = deduplicate(sourceIds); diff --git a/packages/repository/src/relations/has-many/has-many.inclusion-resolver.ts b/packages/repository/src/relations/has-many/has-many.inclusion-resolver.ts index 45e01602ba5b..dfc96acc1f22 100644 --- a/packages/repository/src/relations/has-many/has-many.inclusion-resolver.ts +++ b/packages/repository/src/relations/has-many/has-many.inclusion-resolver.ts @@ -41,16 +41,17 @@ export function createHasManyInclusionResolver< const relationMeta = resolveHasManyMetadata(meta); return async function fetchHasManyModels( - entities: Entity[], + resolveEntities: (ensureKey: string) => Promise, inclusion: Inclusion, options?: Options, ): Promise<((Target & TargetRelations)[] | undefined)[]> { + const sourceKey = relationMeta.keyFrom; + const entities = await resolveEntities(sourceKey); if (!entities.length) return []; debug('Fetching target models for entities:', entities); debug('Relation metadata:', relationMeta); - const sourceKey = relationMeta.keyFrom; const sourceIds = entities.map(e => (e as AnyObject)[sourceKey]); const targetKey = relationMeta.keyTo as StringKeyOf; diff --git a/packages/repository/src/relations/has-one/has-one.inclusion-resolver.ts b/packages/repository/src/relations/has-one/has-one.inclusion-resolver.ts index edae32bf6632..d16b5c333ba9 100644 --- a/packages/repository/src/relations/has-one/has-one.inclusion-resolver.ts +++ b/packages/repository/src/relations/has-one/has-one.inclusion-resolver.ts @@ -38,13 +38,14 @@ export function createHasOneInclusionResolver< const relationMeta = resolveHasOneMetadata(meta); return async function fetchHasOneModel( - entities: Entity[], + resolveEntities: (ensureKey: string) => Promise, inclusion: Inclusion, options?: Options, ): Promise<((Target & TargetRelations) | undefined)[]> { + const sourceKey = relationMeta.keyFrom; + const entities = await resolveEntities(sourceKey); if (!entities.length) return []; - const sourceKey = relationMeta.keyFrom; const sourceIds = entities.map(e => (e as AnyObject)[sourceKey]); const targetKey = relationMeta.keyTo as StringKeyOf; diff --git a/packages/repository/src/relations/relation.helpers.ts b/packages/repository/src/relations/relation.helpers.ts index bba0ad441f89..f3b3b9b59356 100644 --- a/packages/repository/src/relations/relation.helpers.ts +++ b/packages/repository/src/relations/relation.helpers.ts @@ -8,6 +8,7 @@ import debugFactory from 'debug'; import _ from 'lodash'; import { AnyObject, + ensureFields, Entity, EntityCrudRepository, Filter, @@ -74,8 +75,8 @@ export type StringKeyOf = Extract; * resolver. * * @param targetRepository - The target repository where the model instances are found - * @param entities - An array of entity instances or data - * @param include -Inclusion filter + * @param resolveEntities - A function returning array of entity instances or data + * @param filter - A filter with inclusions * @param options - Options for the operations */ @@ -84,12 +85,14 @@ export async function includeRelatedModels< Relations extends object = {} >( targetRepository: EntityCrudRepository, - entities: T[], - include?: Inclusion[], + resolveEntities: (filter?: Filter) => Promise, + filter?: Filter, options?: Options, ): Promise<(T & Relations)[]> { - const result = entities as (T & Relations)[]; - if (!include) return result; + const include = filter?.include; + if (!include) { + return (await resolveEntities(filter)) as (T & Relations)[]; + } const invalidInclusions = include.filter( inclusionFilter => !isInclusionAllowed(targetRepository, inclusionFilter), @@ -107,20 +110,42 @@ export async function includeRelatedModels< throw err; } + let resolveRelationSource: (ensureKey: string) => Promise; + let pruneFields: PruningMask; + + const entityPromise = new Promise(resolve => { + const relationKeys = {} as {[k in keyof T]: true}; + let count = 0; + resolveRelationSource = (ensureKey: string) => { + relationKeys[ensureKey as keyof T] = true; + count++; + const fields = Object.keys(relationKeys) as (keyof T)[]; + if (count === include.length) { + const {filter: f, pruneFields: p} = ensureFields(fields, filter ?? {}); + pruneFields = p; + resolve(resolveEntities(f)); + } + return entityPromise; + }; + }); + const resolveTasks = include.map(async inclusionFilter => { const relationName = inclusionFilter.relation; const resolver = targetRepository.inclusionResolvers.get(relationName)!; - const targets = await resolver(entities, inclusionFilter, options); - - result.forEach((entity, ix) => { + const targets = await resolver( + resolveRelationSource, + inclusionFilter, + options, + ); + const entities = await entityPromise; + entities.forEach((entity, ix) => { const src = entity as AnyObject; src[relationName] = targets[ix]; }); }); await Promise.all(resolveTasks); - - return result; + return (await entityPromise).map(e => Object.assign(e, pruneFields)); } /** * Checks if the resolver of the inclusion relation is registered diff --git a/packages/repository/src/relations/relation.types.ts b/packages/repository/src/relations/relation.types.ts index 00658b609e28..3c8c13331ccc 100644 --- a/packages/repository/src/relations/relation.types.ts +++ b/packages/repository/src/relations/relation.types.ts @@ -168,7 +168,7 @@ export type InclusionResolver = ( /** * List of source models as returned by the first database query. */ - sourceEntities: S[], + resolveEntities: (ensureKey: string) => Promise, /** * Inclusion requested by the user (e.g. scope constraints to apply). */ diff --git a/packages/repository/src/repositories/legacy-juggler-bridge.ts b/packages/repository/src/repositories/legacy-juggler-bridge.ts index 1daa56b6910f..19ffd530be76 100644 --- a/packages/repository/src/repositories/legacy-juggler-bridge.ts +++ b/packages/repository/src/repositories/legacy-juggler-bridge.ts @@ -389,30 +389,34 @@ export class DefaultCrudRepository< filter?: Filter, options?: Options, ): Promise<(T & Relations)[]> { - const include = filter?.include; - const models = await ensurePromise( - this.modelClass.find(this.normalizeFilter(filter), options), - ); - const entities = this.toEntities(models); - return this.includeRelatedModels(entities, include, options); + const resolveEntities = async (updatedFilter?: Filter) => { + const models = await ensurePromise( + this.modelClass.find(this.normalizeFilter(updatedFilter), options), + ); + return this.toEntities(models); + }; + return this.includeRelatedModels(resolveEntities, filter, options); } async findOne( filter?: Filter, options?: Options, ): Promise<(T & Relations) | null> { - const model = await ensurePromise( - this.modelClass.findOne(this.normalizeFilter(filter), options), - ); - if (!model) return null; - const entity = this.toEntity(model); - const include = filter?.include; + const resolveEntities = async (updatedFilter?: Filter) => { + const model = await ensurePromise( + this.modelClass.findOne(this.normalizeFilter(updatedFilter), options), + ); + if (!model) { + return [] as (T & Relations)[]; + } + return [this.toEntity(model)]; + }; const resolved = await this.includeRelatedModels( - [entity], - include, + resolveEntities, + filter, options, ); - return resolved[0]; + return resolved[0] || null; } async findById( @@ -420,17 +424,22 @@ export class DefaultCrudRepository< filter?: FilterExcludingWhere, options?: Options, ): Promise { - const include = filter?.include; - const model = await ensurePromise( - this.modelClass.findById(id, this.normalizeFilter(filter), options), - ); - if (!model) { - throw new EntityNotFoundError(this.entityClass, id); - } - const entity = this.toEntity(model); + const resolveEntities = async (updatedFilter?: FilterExcludingWhere) => { + const model = await ensurePromise( + this.modelClass.findById( + id, + this.normalizeFilter(updatedFilter), + options, + ), + ); + if (!model) { + throw new EntityNotFoundError(this.entityClass, id); + } + return [this.toEntity(model)]; + }; const resolved = await this.includeRelatedModels( - [entity], - include, + resolveEntities, + filter, options, ); return resolved[0]; @@ -548,16 +557,21 @@ export class DefaultCrudRepository< * Returns model instances that include related models of this repository * that have a registered resolver. * - * @param entities - An array of entity instances or data - * @param include -Inclusion filter + * @param resolveEntities - A function returning array of entity instances or data + * @param filter - A filter with inclusions * @param options - Options for the operations */ protected async includeRelatedModels( - entities: T[], - include?: Inclusion[], + resolveEntities: (filter?: Filter) => Promise, + filter?: Filter, options?: Options, ): Promise<(T & Relations)[]> { - return includeRelatedModels(this, entities, include, options); + return includeRelatedModels( + this, + resolveEntities, + filter, + options, + ); } /**