diff --git a/packages/filter/package-lock.json b/packages/filter/package-lock.json index 79e5b83b2996..c5ccbdb64479 100644 --- a/packages/filter/package-lock.json +++ b/packages/filter/package-lock.json @@ -4,12 +4,23 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@types/lodash": { + "version": "4.14.161", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.161.tgz", + "integrity": "sha512-EP6O3Jkr7bXvZZSZYlsgt5DIjiGr0dXP1/jVEwVLTFgg0d+3lWVQkRavYVQszV7dYUwvg0B8R0MBDpcmXg7XIA==", + "dev": true + }, "@types/node": { "version": "10.17.35", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.35.tgz", "integrity": "sha512-gXx7jAWpMddu0f7a+L+txMplp3FnHl53OhQIF9puXKq3hDGY/GjH+MF04oWnV/adPSCrbtHumDCFwzq2VhltWA==", "dev": true }, + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" + }, "tslib": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.2.tgz", diff --git a/packages/filter/package.json b/packages/filter/package.json index e0712e1acff4..e83acdd3f12c 100644 --- a/packages/filter/package.json +++ b/packages/filter/package.json @@ -28,11 +28,13 @@ "!*/__tests__" ], "dependencies": { + "lodash": "^4.17.20", "tslib": "^2.0.2" }, "devDependencies": { "@loopback/build": "^6.2.4", "@loopback/testlab": "^3.2.6", + "@types/lodash": "^4.14.161", "@types/node": "^10.17.35", "typescript": "~4.0.3" }, diff --git a/packages/filter/src/__tests__/acceptance/ensure-fields.acceptance.ts b/packages/filter/src/__tests__/acceptance/ensure-fields.acceptance.ts new file mode 100644 index 000000000000..dfd527eb7bcc --- /dev/null +++ b/packages/filter/src/__tests__/acceptance/ensure-fields.acceptance.ts @@ -0,0 +1,57 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/filter +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {ensureFields, Filter} from '../..'; + +describe('ensureFields', () => { + it('does not modify a filter when it does not specify fields', () => { + const filter = {} as Filter; + const {filter: newFilter, fieldsAdded} = ensureFields(['a', 'b'], filter); + + expect(newFilter).to.eql({}); + expect(fieldsAdded).to.eql([]); + }); + + it('does not modify a filter when it does not exclude target fields', () => { + const filter = {fields: {a: false, b: false}} as Filter; + const {filter: newFilter, fieldsAdded} = ensureFields(['c'], filter); + + expect(newFilter).to.eql({fields: {a: false, b: false}}); + expect(fieldsAdded).to.eql([]); + }); + + it('does not modify a filter when target fields are not specified', () => { + const filter = {fields: {a: false, b: false}} as Filter; + const {filter: newFilter, fieldsAdded} = ensureFields([], filter); + + expect(newFilter).to.eql({fields: {a: false, b: false}}); + expect(fieldsAdded).to.eql([]); + }); + + it('adds omitted fields', () => { + const filter = {fields: {a: true}} as Filter; + const {filter: newFilter, fieldsAdded} = ensureFields(['b'], filter); + + expect(newFilter).to.eql({fields: {a: true, b: true}}); + expect(fieldsAdded).to.eql(['b']); + }); + + it('adds explicitly disabled fields', () => { + const filter = {fields: {a: true, b: false}} as Filter; + const {filter: newFilter, fieldsAdded} = ensureFields(['b'], filter); + + expect(newFilter).to.eql({fields: {a: true, b: true}}); + expect(fieldsAdded).to.eql(['b']); + }); + + it('removes fields clause when it only excludes fields', () => { + const filter = {fields: {a: false, b: false}} as Filter; + const {filter: newFilter, fieldsAdded} = ensureFields(['b'], filter); + + expect(newFilter).to.eql({}); + expect(fieldsAdded).to.eql(['a', 'b']); + }); +}); diff --git a/packages/filter/src/ensure-fields.ts b/packages/filter/src/ensure-fields.ts new file mode 100644 index 000000000000..5ca02d736830 --- /dev/null +++ b/packages/filter/src/ensure-fields.ts @@ -0,0 +1,75 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/filter +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import _ from 'lodash'; +import {Filter, FilterBuilder} from './query'; +import {AnyObject} from './types'; + +/** + * Ensures that queries which apply the returned filter would always include + * the target fields. To undo this effect later, fields that were disabled + * in the original filter will be added to the pruning mask. + * + * @param targetFields - An array of fields to include + * @param filter - A target filter + * @returns A tuple containing amended filter and pruning mask + */ +export function ensureFields( + targetFields: (keyof T)[], + filter: Filter, +) { + const builder = new FilterBuilder(filter); + const fields = builder.build().fields; + if (!fields || matchesFields(targetFields, filter)) { + return { + filter: builder.build(), + fieldsAdded: [] as (keyof T)[], + }; + } + const isDisablingOnly = _.size(fields) > 0 && !_.some(fields, Boolean); + const fieldsAdded = (isDisablingOnly ? _.keys(fields) : []) as (keyof T)[]; + targetFields.forEach(f => { + if (!fields[f]) { + fieldsAdded.push(f); + builder.fields(f); + } + }); + + const newFilter = builder.build(); + // if the filter only hides the fields, unset the entire fields clause + if (isDisablingOnly) { + delete filter.fields; + } + return { + filter: newFilter, + fieldsAdded: _.uniq(fieldsAdded) as (keyof T)[], + } as const; +} + +/** + * Checks whether fields array passed as an argument is a + * subset of fields picked by a target filter. + * + * @param fields - An array of fields to search in a filter + * @param filter - A target filter + */ +export function matchesFields( + fields: (keyof T)[], + filter?: Filter, +) { + const normalized = new FilterBuilder(filter).build(); + const targetFields = normalized.fields; + if (!targetFields) { + return true; + } + const isDisablingOnly = + _.size(targetFields) > 0 && !_.some(targetFields, Boolean); + for (const f of fields) { + if (!targetFields[f] && (f in targetFields || !isDisablingOnly)) { + return false; + } + } + return true; +} diff --git a/packages/filter/src/index.ts b/packages/filter/src/index.ts index d296d7e02fbf..b4e48c76f9cd 100644 --- a/packages/filter/src/index.ts +++ b/packages/filter/src/index.ts @@ -18,3 +18,4 @@ */ export * from './query'; +export * from './ensure-fields'; diff --git a/packages/filter/src/query.ts b/packages/filter/src/query.ts index 209876ef4eca..b237c706b5ca 100644 --- a/packages/filter/src/query.ts +++ b/packages/filter/src/query.ts @@ -506,7 +506,7 @@ export class WhereBuilder { } /** - * A builder for Filter. It provides fleunt APIs to add clauses such as + * A builder for Filter. It provides fluent APIs to add clauses such as * `fields`, `order`, `where`, `limit`, `offset`, and `include`. * * @example 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 83768048488e..3aef924fc04a 100644 --- a/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts +++ b/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts @@ -88,6 +88,230 @@ describe('HasMany relation', () => { expect(orders).to.deepEqual(persistedOrders); }); + it('can include the related model when the foreign key is omitted in filter', async () => { + const order = await customerOrderRepo.create({ + description: 'an order desc', + }); + + const customer = await customerRepo.findById(existingCustomerId, { + fields: { + name: true, + }, + include: [ + { + relation: 'orders', + scope: { + fields: { + description: true, + }, + }, + }, + ], + }); + + withProtoCheck(false, () => { + expect(customer).to.deepEqual({ + id: undefined, + name: 'a customer', + orders: [ + { + id: undefined, + description: order.description, + customerId: undefined, + }, + ], + reviewsApproved: undefined, + reviewsAuthored: undefined, + }); + }); + }); + + it('can include the related model when the foreign key is disabled in filter', async () => { + const order = await customerOrderRepo.create({ + description: 'an order desc', + }); + const customer = await customerRepo.findById(existingCustomerId, { + fields: { + id: false, + name: true, + }, + include: [ + { + relation: 'orders', + scope: { + fields: { + customerId: false, + description: true, + }, + }, + }, + ], + }); + + withProtoCheck(false, () => { + expect(customer).to.deepEqual({ + id: undefined, + name: 'a customer', + orders: [ + { + id: undefined, + description: order.description, + customerId: undefined, + }, + ], + reviewsApproved: undefined, + reviewsAuthored: undefined, + }); + }); + }); + + it('can include the related model when only the foreign key is disabled in filter', async () => { + const order = await customerOrderRepo.create({ + description: 'an order desc', + }); + const customer = await customerRepo.findById(existingCustomerId, { + fields: { + id: false, + }, + include: [ + { + relation: 'orders', + scope: { + fields: { + customerId: false, + }, + }, + }, + ], + }); + + withProtoCheck(false, () => { + expect(customer).to.deepEqual({ + id: undefined, + name: 'a customer', + orders: [ + { + id: order.id, + description: order.description, + customerId: undefined, + }, + ], + reviewsApproved: undefined, + reviewsAuthored: undefined, + }); + }); + }); + + it('preserves the foreign key value when set in filter', async () => { + const order = await customerOrderRepo.create({ + description: 'an order desc', + }); + const customer = await customerRepo.findById(existingCustomerId, { + fields: { + id: true, + name: true, + }, + include: [ + { + relation: 'orders', + scope: { + fields: { + customerId: true, + description: true, + }, + }, + }, + ], + }); + + withProtoCheck(false, () => { + 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 the fields set in filter', async () => { + await customerOrderRepo.create({ + description: 'an order desc', + }); + const customer = await customerRepo.findById(existingCustomerId, { + fields: {}, + include: [ + { + relation: 'orders', + scope: { + fields: {}, + }, + }, + ], + }); + + withProtoCheck(false, () => { + expect(customer).to.deepEqual({ + id: undefined, + name: undefined, + orders: [ + { + id: undefined, + description: undefined, + customerId: undefined, + }, + ], + reviewsApproved: undefined, + reviewsAuthored: undefined, + }); + }); + }); + + it('preserves the fields not excluded in filter', async () => { + const order = await customerOrderRepo.create({ + description: 'an order desc', + }); + + const customer = await customerRepo.findById(existingCustomerId, { + fields: { + name: false, + }, + include: [ + { + relation: 'orders', + scope: { + fields: { + description: false, + }, + }, + }, + ], + }); + + withProtoCheck(false, () => { + expect(customer).to.deepEqual({ + id: existingCustomerId, + name: undefined, + orders: [ + { + id: order.id, + description: undefined, + customerId: existingCustomerId, + }, + ], + reviewsApproved: undefined, + reviewsAuthored: undefined, + }); + }); + }); + it('finds appropriate related model instances for multiple relations', async () => { // note(shimks): roundabout way of creating reviews with 'approves' // ideally, the review repository should have a approve function @@ -146,6 +370,14 @@ describe('HasMany relation', () => { ); customerOrderRepo = orderFactoryFn(existingCustomerId); + const customerCrud = customerRepo as DefaultCrudRepository< + Customer, + number + >; + customerCrud.registerInclusionResolver( + 'orders', + orderFactoryFn.inclusionResolver, + ); } function givenRepositoryFactoryFunctions() { @@ -161,6 +393,8 @@ describe('HasMany relation', () => { }); describe('BelongsTo relation', () => { + let customer: Customer; + let order: Order; let findCustomerOfOrder: BelongsToAccessor< Customer, typeof Order.prototype.id @@ -175,30 +409,224 @@ describe('BelongsTo relation', () => { reviewRepo.deleteAll(), ]); }); + beforeEach(givenCustomerAndOrder); it('finds an instance of the related model', async () => { - const customer = await customerRepo.create({name: 'Order McForder'}); - const order = await orderRepo.create({ - customerId: customer.id, - description: 'Order from Order McForder, the hoarder of Mordor', - }); - const result = await findCustomerOfOrder(order.id); expect(result).to.deepEqual(customer); }); it('throws EntityNotFound error when the related model does not exist', async () => { - const order = await orderRepo.create({ + const orderToFail = await orderRepo.create({ customerId: 999, // does not exist description: 'Order of a fictional customer', }); - await expect(findCustomerOfOrder(order.id)).to.be.rejectedWith( + await expect(findCustomerOfOrder(orderToFail.id)).to.be.rejectedWith( EntityNotFoundError, ); }); + 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', + scope: { + fields: { + name: true, + }, + }, + }, + ], + })) as OrderWithRelations; + + withProtoCheck(false, () => { + expect(orderWithRelations).to.deepEqual({ + id: 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', + scope: { + fields: { + id: false, + name: true, + }, + }, + }, + ], + })) as OrderWithRelations; + + withProtoCheck(false, () => { + expect(orderWithRelations).to.deepEqual({ + id: 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', + scope: { + fields: { + id: false, + }, + }, + }, + ], + })) as OrderWithRelations; + + withProtoCheck(false, () => { + 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', + scope: { + fields: { + id: true, + name: true, + }, + }, + }, + ], + })) as OrderWithRelations; + + withProtoCheck(false, () => { + 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 the fields set in filter', async () => { + const orderWithRelations = (await orderRepo.findById(order.id, { + fields: {}, + include: [ + { + relation: 'customer', + scope: { + fields: {}, + }, + }, + ], + })) as OrderWithRelations; + + withProtoCheck(false, () => { + expect(orderWithRelations).to.deepEqual({ + id: undefined, + description: undefined, + customerId: undefined, + customer: { + id: undefined, + name: undefined, + orders: undefined, + reviewsApproved: undefined, + reviewsAuthored: undefined, + }, + }); + }); + }); + + it('preserves the fields not excluded in filter', async () => { + const orderWithRelations = (await orderRepo.findById(order.id, { + fields: { + description: false, + customerId: false, + }, + include: [ + { + relation: 'customer', + scope: { + fields: { + name: false, + }, + }, + }, + ], + })) as OrderWithRelations; + + withProtoCheck(false, () => { + expect(orderWithRelations).to.deepEqual({ + id: order.id, + description: undefined, + customerId: undefined, + customer: { + id: customer.id, + name: undefined, + orders: undefined, + reviewsApproved: undefined, + reviewsAuthored: undefined, + }, + }); + }); + }); + //--- HELPERS ---// function givenAccessor() { @@ -207,6 +635,22 @@ describe('BelongsTo relation', () => { Getter.fromValue(customerRepo), orderRepo, ); + + const orderCrud = orderRepo as DefaultCrudRepository; + orderCrud.registerInclusionResolver( + 'customer', + findCustomerOfOrder.inclusionResolver, + ); + } + + async function givenCustomerAndOrder() { + customer = await customerRepo.create({ + name: 'Order McForder', + }); + order = await orderRepo.create({ + customerId: customer.id, + description: 'Order from Order McForder, the hoarder of Mordor', + }); } }); @@ -498,6 +942,10 @@ class CartItem extends Entity { .addProperty('description', {type: 'string', required: true}); } +class OrderWithRelations extends Order { + customer: Customer; +} + class Review extends Entity { id: number; description: string; @@ -564,3 +1012,14 @@ function givenCrudRepositories() { ); reviewRepo = new DefaultCrudRepository(Review, db); } + +function withProtoCheck(value: boolean, fn: Function) { + const shouldJs = (expect as unknown) as {config: {checkProtoEql: boolean}}; + const oldValue = shouldJs.config.checkProtoEql; + shouldJs.config.checkProtoEql = value; + try { + fn(); + } finally { + shouldJs.config.checkProtoEql = oldValue; + } +} 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 991b12d50404..a0866aff656a 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 @@ -515,8 +515,10 @@ describe('DefaultCrudRepository', () => { const hasManyResolver: InclusionResolver< Folder, File - > = async entities => { + > = async resolveEntities => { + const entities = await resolveEntities(); const files = []; + for (const entity of entities) { const file = await folderFiles(entity.id).find(); files.push(file); @@ -528,7 +530,8 @@ describe('DefaultCrudRepository', () => { const belongsToResolver: InclusionResolver< File, Folder - > = async entities => { + > = async resolveEntities => { + const entities = await resolveEntities(); const folders = []; for (const file of entities) { @@ -542,7 +545,8 @@ describe('DefaultCrudRepository', () => { const hasOneResolver: InclusionResolver< Folder, Author - > = async entities => { + > = async resolveEntities => { + const entities = await resolveEntities(); const authors = []; for (const folder of entities) { @@ -745,8 +749,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(); }; 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..aa2565176959 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,13 +59,27 @@ describe('includeRelatedModels', () => { const categories = await includeRelatedModels( categoryRepo, - [category], - [{relation: 'products'}], + async () => [category], + { + include: [{relation: 'products'}], + }, ); expect(categories[0].products).to.be.empty(); }); + it('resolves when the inclusion resolver is misbehaving', async () => { + const category = await categoryRepo.create({name: 'category'}); + + categoryRepo.inclusionResolvers.set('products', misbehavingResolver); + + await expect( + includeRelatedModels(categoryRepo, async () => [category], { + include: [{relation: 'products'}], + }), + ).to.be.fulfilled(); + }); + it('includes related model for one instance - belongsTo', async () => { const category = await categoryRepo.create({name: 'category'}); const product = await productRepo.create({ @@ -73,8 +91,10 @@ describe('includeRelatedModels', () => { const productWithCategories = await includeRelatedModels( productRepo, - [product], - [{relation: 'category'}], + async () => [product], + { + include: [{relation: 'category'}], + }, ); expect(productWithCategories[0].toJSON()).to.deepEqual({ @@ -105,8 +125,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 +154,10 @@ describe('includeRelatedModels', () => { const categoryWithProducts = await includeRelatedModels( categoryRepo, - [category], - [{relation: 'products'}], + async () => [category], + { + include: [{relation: 'products'}], + }, ); expect(toJSON(categoryWithProducts)).to.deepEqual([ @@ -167,8 +191,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,7 +212,8 @@ describe('includeRelatedModels', () => { const belongsToResolver: InclusionResolver< Product, Category - > = async entities => { + > = async resolveEntities => { + const entities = await resolveEntities(); const categories = []; for (const product of entities) { @@ -200,7 +227,8 @@ describe('includeRelatedModels', () => { const hasManyResolver: InclusionResolver< Category, Product - > = async entities => { + > = async resolveEntities => { + const entities = await resolveEntities(); const products = []; for (const category of entities) { @@ -209,4 +237,12 @@ describe('includeRelatedModels', () => { } return products; }; + + const misbehavingResolver: InclusionResolver< + Category, + Product + > = async resolveEntities => { + // resolveEntities is not invoked intentionally + return []; + }; }); 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 30426364501c..d4a94c403b3d 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 @@ -5,7 +5,7 @@ import {AnyObject, Options} from '../../common-types'; import {Entity} from '../../model'; -import {Filter, Inclusion} from '@loopback/filter'; +import {Filter, Inclusion, ensureFields} from '@loopback/filter'; import {EntityCrudRepository} from '../../repositories/repository'; import { deduplicate, @@ -43,26 +43,36 @@ export function createBelongsToInclusionResolver< const relationMeta = resolveBelongsToMetadata(meta); return async function fetchIncludedModels( - entities: Entity[], + resolveEntities: (fieldsToEnsure: 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); + const {filter: scope, fieldsAdded} = ensureFields( + [targetKey], + inclusion.scope as Filter, + ); const targetRepo = await getTargetRepo(); const targetsFound = await findByForeignKeys( targetRepo, targetKey, dedupedSourceIds.filter(e => e), - inclusion.scope as Filter, + scope, options, ); - return flattenTargetsOfOneToOneRelation(sourceIds, targetsFound, targetKey); + return flattenTargetsOfOneToOneRelation( + sourceIds, + targetsFound, + targetKey, + fieldsAdded, + ); }; } diff --git a/packages/repository/src/relations/has-many/has-many-through.inclusion.resolver.ts b/packages/repository/src/relations/has-many/has-many-through.inclusion.resolver.ts index 87a25377fd2f..7619b8307fd3 100644 --- a/packages/repository/src/relations/has-many/has-many-through.inclusion.resolver.ts +++ b/packages/repository/src/relations/has-many/has-many-through.inclusion.resolver.ts @@ -51,10 +51,11 @@ export function createHasManyThroughInclusionResolver< const relationMeta = resolveHasManyMetadata(meta); return async function fetchHasManyThroughModels( - entities: Entity[], + resolveEntities: (fieldsToEnsure: string[]) => Promise, inclusion: Inclusion, options?: Options, ): Promise<((Target & TargetRelations)[] | undefined)[]> { + const entities = await resolveEntities([]); if (!entities.length) return []; debug('Fetching target models for entities:', entities); 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 b1ee285a4c8f..0951b4a51c8d 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 @@ -3,7 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Filter, Inclusion} from '@loopback/filter'; +import {Filter, Inclusion, ensureFields} from '@loopback/filter'; import debugFactory from 'debug'; import {AnyObject, Options} from '../../common-types'; import {Entity} from '../../model'; @@ -41,16 +41,17 @@ export function createHasManyInclusionResolver< const relationMeta = resolveHasManyMetadata(meta); return async function fetchHasManyModels( - entities: Entity[], + resolveEntities: (fieldsToEnsure: 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; @@ -60,12 +61,16 @@ export function createHasManyInclusionResolver< sourceIds.map(i => typeof i), ); + const {filter: scope, fieldsAdded} = ensureFields( + [targetKey], + inclusion.scope as Filter, + ); const targetRepo = await getTargetRepo(); const targetsFound = await findByForeignKeys( targetRepo, targetKey, sourceIds, - inclusion.scope as Filter, + scope, options, ); @@ -75,6 +80,7 @@ export function createHasManyInclusionResolver< sourceIds, targetsFound, targetKey, + fieldsAdded, ); debug('fetchHasManyModels result', result); 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 dcaa63b90518..21131733d3e7 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 @@ -3,7 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Filter, Inclusion} from '@loopback/filter'; +import {Filter, Inclusion, ensureFields} from '@loopback/filter'; import {AnyObject, Options} from '../../common-types'; import {Entity} from '../../model'; import {EntityCrudRepository} from '../../repositories/repository'; @@ -38,25 +38,35 @@ export function createHasOneInclusionResolver< const relationMeta = resolveHasOneMetadata(meta); return async function fetchHasOneModel( - entities: Entity[], + resolveEntities: (fieldsToEnsure: 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 {filter: scope, fieldsAdded} = ensureFields( + [targetKey], + inclusion.scope as Filter, + ); const targetRepo = await getTargetRepo(); const targetsFound = await findByForeignKeys( targetRepo, targetKey, sourceIds, - inclusion.scope as Filter, + scope, options, ); - return flattenTargetsOfOneToOneRelation(sourceIds, targetsFound, targetKey); + return flattenTargetsOfOneToOneRelation( + sourceIds, + targetsFound, + targetKey, + fieldsAdded, + ); }; } diff --git a/packages/repository/src/relations/relation.helpers.ts b/packages/repository/src/relations/relation.helpers.ts index cd04f05ae61f..f9bba316d21f 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, @@ -73,8 +74,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 */ @@ -83,12 +84,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), @@ -106,20 +109,54 @@ export async function includeRelatedModels< throw err; } + const relationKeys = {} as {[k in keyof T]: true}; + const pruningMask = {} as {[k in keyof T]: undefined}; + + const [entityPromise, resolveEntitiesWithFK] = captureResult(() => { + const fields = Object.keys(relationKeys) as (keyof T)[]; + const {filter: newFilter, fieldsAdded} = ensureFields(fields, filter ?? {}); + + fieldsAdded.forEach(f => (pruningMask[f] = undefined)); + return resolveEntities(newFilter); + }); + + const resolveEntitiesAfterInclusions = _.after( + include.length, + resolveEntitiesWithFK, + ); + 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 [invocationPromise, resolveRelationSource] = captureInvocation( + (fieldsToEnsure: string[] = []) => { + fieldsToEnsure.forEach(f => (relationKeys[f as keyof T] = true)); + return entityPromise; + }, + ); + + const resolutionPromise = resolver( + resolveRelationSource, + inclusionFilter, + options, + ); + + // this guards from the situation where resolveRelationSource + // is not invoked inside inclusion resolver + await Promise.race([invocationPromise, resolutionPromise]); + await resolveEntitiesAfterInclusions(); + + const targets = await resolutionPromise; + 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, pruningMask)); } /** * Checks if the resolver of the inclusion relation is registered @@ -149,12 +186,14 @@ function isInclusionAllowed( * @param sourceIds - One value or array of values of the target key * @param targetEntities - target entities that satisfy targetKey's value (ids). * @param targetKey - name of the target key + * @param fieldsToRemove - map of fields to remove after flattening * */ export function flattenTargetsOfOneToOneRelation( sourceIds: unknown[], targetEntities: Target[], targetKey: StringKeyOf, + fieldsToRemove = [] as (keyof Target)[], ): (Target | undefined)[] { const lookup = buildLookupMap( targetEntities, @@ -162,6 +201,11 @@ export function flattenTargetsOfOneToOneRelation( reduceAsSingleItem, ); + const pruningMask = _.fromPairs(fieldsToRemove.map(v => [v, undefined])); + targetEntities.forEach((e: Partial) => { + Object.assign(e, pruningMask); + }); + return flattenMapByKeys(sourceIds, lookup); } @@ -173,12 +217,14 @@ export function flattenTargetsOfOneToOneRelation( * @param sourceIds - One value or array of values of the target key * @param targetEntities - target entities that satisfy targetKey's value (ids). * @param targetKey - name of the target key + * @param fieldsToRemove - map of fields to remove after flattening * */ export function flattenTargetsOfOneToManyRelation( sourceIds: unknown[], targetEntities: Target[], targetKey: StringKeyOf, + fieldsToRemove = [] as (keyof Target)[], ): (Target[] | undefined)[] { debug('flattenTargetsOfOneToManyRelation'); debug('sourceIds', sourceIds); @@ -195,6 +241,11 @@ export function flattenTargetsOfOneToManyRelation( reduceAsArray, ); + const pruningMask = _.fromPairs(fieldsToRemove.map(v => [v, undefined])); + targetEntities.forEach((e: Partial) => { + Object.assign(e, pruningMask); + }); + debug('lookup map', lookup); return flattenMapByKeys(sourceIds, lookup); @@ -331,3 +382,36 @@ export function isBsonType(value: unknown): value is object { return Object.prototype.hasOwnProperty.call(target, '_bsontype'); } } + +/** + * Creates a Promise which resolves when a function is invoked + * + * @param fn - A function to wrap + */ +function captureInvocation(fn: (...args: T[]) => R) { + let wrapper = _.noop as typeof fn; + const promise = new Promise(resolve => { + wrapper = (...args: T[]) => { + resolve(args); + return fn(...args); + }; + }); + return [promise, wrapper] as const; +} + +/** + * Creates a Promise which resolves with a return value of a function + * + * @param fn - A function to wrap + */ +function captureResult(fn: (...args: T[]) => R) { + let wrapper = _.noop as typeof fn; + const promise = new Promise(resolve => { + wrapper = (...args: T[]) => { + const result = fn(...args); + resolve(result); + return result; + }; + }) as R extends Promise ? Promise : Promise; + return [promise, wrapper] as const; +} diff --git a/packages/repository/src/relations/relation.types.ts b/packages/repository/src/relations/relation.types.ts index e3dc68ad0c4a..36bdaf3b9db0 100644 --- a/packages/repository/src/relations/relation.types.ts +++ b/packages/repository/src/relations/relation.types.ts @@ -166,9 +166,10 @@ export {Getter} from '@loopback/core'; */ export type InclusionResolver = ( /** - * List of source models as returned by the first database query. + * A function resolving the first database query. + * @param fieldsToEnsure {string[]} a list of fields to include in the query */ - sourceEntities: S[], + resolveEntities: (fieldsToEnsure?: 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 2b42c5abbd7c..34f488a1f5fa 100644 --- a/packages/repository/src/repositories/legacy-juggler-bridge.ts +++ b/packages/repository/src/repositories/legacy-juggler-bridge.ts @@ -4,7 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {Getter} from '@loopback/core'; -import {Filter, FilterExcludingWhere, Inclusion, Where} from '@loopback/filter'; +import {Filter, FilterExcludingWhere, Where} from '@loopback/filter'; import assert from 'assert'; import legacy from 'loopback-datasource-juggler'; import { @@ -447,30 +447,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( @@ -478,17 +482,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]; @@ -689,16 +698,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 an 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, + ); } /**