From 09c29bdae0d7c1c5e7ac3e3d803f06996bff0f8e Mon Sep 17 00:00:00 2001 From: InvictusMB Date: Sun, 4 Oct 2020 23:41:47 +0200 Subject: [PATCH 1/2] feat(filter): implement ensureFields utility Signed-off-by: InvictusMB --- packages/filter/package-lock.json | 11 +++ packages/filter/package.json | 2 + .../acceptance/ensure-fields.acceptance.ts | 57 ++++++++++++++ packages/filter/src/ensure-fields.ts | 75 +++++++++++++++++++ packages/filter/src/index.ts | 1 + packages/filter/src/query.ts | 2 +- 6 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 packages/filter/src/__tests__/acceptance/ensure-fields.acceptance.ts create mode 100644 packages/filter/src/ensure-fields.ts 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 From f9192c7e70d28c5f55548ea48cdf5341f53a03de Mon Sep 17 00:00:00 2001 From: InvictusMB Date: Thu, 28 May 2020 15:48:49 +0200 Subject: [PATCH 2/2] feat(repository): transparently ensure foreign key target in inclusion resolvers Signed-off-by: InvictusMB --- .../relation.factory.integration.ts | 394 +++++++++++++++++- .../belongs-to.inclusion-resolver.ts | 15 +- .../has-many/has-many.inclusion-resolver.ts | 9 +- .../has-one/has-one.inclusion-resolver.ts | 15 +- .../src/relations/relation.helpers.ts | 14 + 5 files changed, 431 insertions(+), 16 deletions(-) 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..eb72bee4f017 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,190 @@ 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, { + include: [ + { + relation: 'orders', + scope: { + fields: { + description: true, + }, + }, + }, + ], + }); + + 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, + }); + }); + }); + }); + + 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, { + include: [ + { + relation: 'orders', + scope: { + fields: { + customerId: false, + description: true, + }, + }, + }, + ], + }); + + 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, + }); + }); + }); + }); + + 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, { + include: [ + { + relation: 'orders', + scope: { + fields: { + customerId: false, + }, + }, + }, + ], + }); + + 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, + }); + }); + }); + }); + + 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, { + include: [ + { + relation: 'orders', + scope: { + fields: { + customerId: true, + description: true, + }, + }, + }, + ], + }); + + 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, + }); + }); + }); + }); + + it('includes only the fields set in filter', async () => { + await customerOrderRepo.create({ + description: 'an order desc', + }); + const customer = await customerRepo.findById(existingCustomerId, { + include: [ + { + relation: 'orders', + scope: { + fields: {}, + }, + }, + ], + }); + + withProtoCheck(false, () => { + expect(customer.orders).length(1); + expect(customer.orders).to.matchEach((v: Partial) => { + expect(v).to.deepEqual({ + id: undefined, + description: undefined, + customerId: 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 +330,14 @@ describe('HasMany relation', () => { ); customerOrderRepo = orderFactoryFn(existingCustomerId); + const customerCrud = customerRepo as DefaultCrudRepository< + Customer, + number + >; + customerCrud.registerInclusionResolver( + 'orders', + orderFactoryFn.inclusionResolver, + ); } function givenRepositoryFactoryFunctions() { @@ -161,6 +353,8 @@ describe('HasMany relation', () => { }); describe('BelongsTo relation', () => { + let customer: Customer; + let order: Order; let findCustomerOfOrder: BelongsToAccessor< Customer, typeof Order.prototype.id @@ -175,30 +369,183 @@ 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, { + include: [ + { + relation: 'customer', + scope: { + fields: { + name: true, + }, + }, + }, + ], + })) as OrderWithRelations; + + withProtoCheck(false, () => { + expect(orderWithRelations.customer).to.deepEqual({ + 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, { + include: [ + { + relation: 'customer', + scope: { + fields: { + id: false, + name: true, + }, + }, + }, + ], + })) as OrderWithRelations; + + withProtoCheck(false, () => { + expect(orderWithRelations.customer).to.deepEqual({ + 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, { + include: [ + { + relation: 'customer', + scope: { + fields: { + id: false, + }, + }, + }, + ], + })) as OrderWithRelations; + + withProtoCheck(false, () => { + expect(orderWithRelations.customer).to.deepEqual({ + 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, { + include: [ + { + relation: 'customer', + scope: { + fields: { + id: true, + name: true, + }, + }, + }, + ], + })) as OrderWithRelations; + + withProtoCheck(false, () => { + expect(orderWithRelations.customer).to.deepEqual({ + 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, { + include: [ + { + relation: 'customer', + scope: { + fields: {}, + }, + }, + ], + })) as OrderWithRelations; + + withProtoCheck(false, () => { + expect(orderWithRelations.customer).to.deepEqual({ + 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, + }, + include: [ + { + relation: 'customer', + scope: { + fields: { + name: false, + }, + }, + }, + ], + })) as OrderWithRelations; + + withProtoCheck(false, () => { + expect(orderWithRelations).to.deepEqual({ + id: order.id, + description: undefined, + customerId: customer.id, + customer: { + id: customer.id, + name: undefined, + orders: undefined, + reviewsApproved: undefined, + reviewsAuthored: undefined, + }, + }); + }); + }); + //--- HELPERS ---// function givenAccessor() { @@ -207,6 +554,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 +861,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 +931,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/relations/belongs-to/belongs-to.inclusion-resolver.ts b/packages/repository/src/relations/belongs-to/belongs-to.inclusion-resolver.ts index 30426364501c..1e399d17f657 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, @@ -54,15 +54,24 @@ export function createBelongsToInclusionResolver< 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.inclusion-resolver.ts b/packages/repository/src/relations/has-many/has-many.inclusion-resolver.ts index b1ee285a4c8f..266d9d967009 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'; @@ -60,12 +60,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 +79,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..fef24d9327ec 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'; @@ -48,15 +48,24 @@ export function createHasOneInclusionResolver< 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..311348f0df62 100644 --- a/packages/repository/src/relations/relation.helpers.ts +++ b/packages/repository/src/relations/relation.helpers.ts @@ -149,12 +149,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 +164,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 +180,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 +204,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);