diff --git a/docs/site/BelongsTo-relation.md b/docs/site/BelongsTo-relation.md index 7c4eaf928b0d..34ac05e89de3 100644 --- a/docs/site/BelongsTo-relation.md +++ b/docs/site/BelongsTo-relation.md @@ -267,3 +267,105 @@ DO NOT declare `@repository.getter(CategoryRepository) protected categoryRepositoryGetter: Getter` on constructor to avoid "Circular dependency" error (see [issue #2118](https://github.com/strongloop/loopback-next/issues/2118)) + +## Querying related models + +LoopBack 4 has the concept of an `inclusion resolver` in relations, which helps +to query data through an `include` filter. An inclusion resolver is a function +that can fetch target models for the given list of source model instances. +LoopBack 4 creates a different inclusion resolver for each relation type. + +The following is an example for using BelongsTo inclusion resolvers: + +Use the relation between `Customer` and `Order` we show above, an `Order` +belongs to a `Customer`. + +After setting up the relation in the repository class, the inclusion resolver +allows users to retrieve all addresses along with their related customers +through the following code: + +```ts +addressRepo.find({include: [{relation: 'customer'}]}); +``` + +### Enable/disable the inclusion resolvers: + +- Base repository classes have a public property `inclusionResolvers`, which + maintains a map containing inclusion resolvers for each relation. +- The `inclusionResolver` of a certain relation is built when the source + repository class calls the `createBelongsToAccessorFor` function in the + constructor with the relation name. +- Call `registerInclusionResolver` to add the resolver of that relation to the + `inclusionResolvers` map. (As we realized in LB3, not all relations are + allowed to be traversed. Users can decide to which resolvers can be added.) + The first parameter is the name of the relation. + +The following code snippet shows how to register the inclusion resolver for the +belongsTo relation 'customer': + +```ts +export class AddressRepository extends DefaultCrudRepository { + customer: BelongsToAccessor; + + constructor( + dataSource: juggler.DataSource, + customerRepositoryGetter: Getter, + ) { + super(Address, dataSource); + + // we already have this line to create a HasManyRepository factory + this.customer = this.createBelongsToAccessorFor( + 'customer', + customerRepositoryGetter, + ); + + // add this line to register inclusion resolver. + this.registerInclusion('customer', this.customer.inclusionResolver); + } +} +``` + +- We can simply include the relation in queries via `find()`, `findOne()`, and + `findById()` methods. Example: + + ```ts + addressRepository.find({include: [{relation: 'customer'}]}); + ``` + + which returns: + + ```ts + [ + { + id: 1, + street: 'Warden Rd', + city: 'Thrudheim', + province: 'Asgard', + customer: { + id: 12, + name: 'Thor', + }, + }, + { + id: 2, + street: 'AgentOfShield', + city: 'Culver', + province: 'Cali', + customer: { + id: 10, + name: 'Captain', + }, + }, + ]; + ``` + +- You can delete a relation from `inclusionResolvers` to disable the inclusion + for a certain relation. e.g + `addressRepository.inclusionResolvers.delete('customer')` + +{% include note.html content=" +Inclusion with custom scope: +Besides specifying the relation name to include, it's also possible to specify additional scope constraints. +However, this feature is not supported yet. Check our GitHub issue for more information: +[Include related models with a custom scope](https://github.com/strongloop/loopback-next/issues/3453). +" %} diff --git a/packages/repository-tests/src/crud-test-suite.ts b/packages/repository-tests/src/crud-test-suite.ts index 6ea0f927d579..057edd762fc5 100644 --- a/packages/repository-tests/src/crud-test-suite.ts +++ b/packages/repository-tests/src/crud-test-suite.ts @@ -33,6 +33,7 @@ export function crudRepositoryTestSuite( freeFormProperties: true, emptyValue: undefined, supportsTransactions: true, + supportsInclusionResolvers: true, ...partialFeatures, }; diff --git a/packages/repository-tests/src/crud/relations/acceptance/belongs-to-inclusion-resolver.relation.acceptance.ts b/packages/repository-tests/src/crud/relations/acceptance/belongs-to-inclusion-resolver.relation.acceptance.ts new file mode 100644 index 000000000000..122d8fb9c76d --- /dev/null +++ b/packages/repository-tests/src/crud/relations/acceptance/belongs-to-inclusion-resolver.relation.acceptance.ts @@ -0,0 +1,221 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/repository-tests +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect, skipIf, toJSON} from '@loopback/testlab'; +import {Suite} from 'mocha'; +import { + CrudFeatures, + CrudRepositoryCtor, + CrudTestContext, + DataSourceOptions, +} from '../../..'; +import { + deleteAllModelsInDefaultDataSource, + MixedIdType, + withCrudCtx, +} from '../../../helpers.repository-tests'; +import { + Customer, + CustomerRepository, + Address, + AddressRepository, +} from '../fixtures/models'; +import {givenBoundCrudRepositories} from '../helpers'; + +export function hasManyRelationAcceptance( + dataSourceOptions: DataSourceOptions, + repositoryClass: CrudRepositoryCtor, + features: CrudFeatures, +) { + skipIf<[(this: Suite) => void], void>( + !features.supportsInclusionResolvers, + describe, + 'retrieve models including relations', + () => { + describe('BelongsTo inclusion resolvers - acceptance', () => { + before(deleteAllModelsInDefaultDataSource); + let customerRepo: CustomerRepository; + let addressRepo: AddressRepository; + let existingCustomerId: MixedIdType; + + before( + withCrudCtx(async function setupRepository(ctx: CrudTestContext) { + // when running the test suite on MongoDB, we don't really need to setup + // this config for mongo connector to pass the test. + // however real-world applications might have such config for MongoDB + // setting it up to check if it works fine as well + Address.definition.properties.customerId.type = features.idType; + Address.definition.properties.customerId.mongodb = { + dataType: 'ObjectID', + }; + // this helper should create the inclusion resolvers for us + ({customerRepo, addressRepo} = givenBoundCrudRepositories( + ctx.dataSource, + repositoryClass, + )); + // inclusionResolvers should be defined. And resolver for each + // relation should be created by the belongsToFactory at this point. + expect(customerRepo.inclusionResolvers).to.not.be.undefined(); + expect(addressRepo.inclusionResolvers).to.not.be.undefined(); + expect( + addressRepo.customer!.inclusionResolver, + ).to.not.be.undefined(); + + // inclusionResolvers shouldn't setup yet at this point + expect(customerRepo.inclusionResolvers).to.deepEqual(new Map()); + + await ctx.dataSource.automigrate([Customer.name, Address.name]); + }), + ); + + beforeEach(async () => { + addressRepo.inclusionResolvers.set( + 'customer', + addressRepo.customer!.inclusionResolver, + ); + await customerRepo.deleteAll(); + await addressRepo.deleteAll(); + }); + + it("defines a repository's inclusionResolvers property", () => { + expect(customerRepo.inclusionResolvers).to.not.be.undefined(); + expect(addressRepo.inclusionResolvers).to.not.be.undefined(); + }); + + it("throws an error if the repository doesn't have such relation names", async () => { + await addressRepo.create({ + street: 'home of Thor Rd.', + city: 'Thrudheim', + province: 'Asgard', + zipcode: '8200', + customerId: existingCustomerId, + }); + await expect( + addressRepo.find({include: [{relation: 'home'}]}), + ).to.be.rejectedWith( + `Invalid "filter.include" entries: {"relation":"home"}`, + ); + }); + + it('throws error if the target repository does not have the registered resolver', async () => { + await addressRepo.create({ + street: 'home of Thor Rd.', + city: 'Thrudheim', + province: 'Asgard', + zipcode: '8200', + customerId: existingCustomerId, + }); + // unregister the resolver + addressRepo.inclusionResolvers.delete('customer'); + + await expect( + addressRepo.find({include: [{relation: 'customer'}]}), + ).to.be.rejectedWith( + `Invalid "filter.include" entries: {"relation":"customer"}`, + ); + }); + + it('simple belongs-to relation retrieve via find() method', async () => { + const thor = await customerRepo.create({name: 'Thor'}); + const address = await addressRepo.create({ + street: 'home of Thor Rd.', + city: 'Thrudheim', + province: 'Asgard', + zipcode: '8200', + customerId: thor.id, + }); + const result = await addressRepo.find({ + include: [{relation: 'customer'}], + }); + + const expected = { + ...address, + customer: { + id: thor.id, + name: 'Thor', + parentId: features.emptyValue, + }, + }; + expect(toJSON(result)).to.deepEqual([toJSON(expected)]); + }); + + it('returns related instances to target models via find() method', async () => { + const thor = await customerRepo.create({name: 'Thor'}); + const odin = await customerRepo.create({name: 'Odin'}); + const addr1 = await addressRepo.create({ + street: 'home of Thor Rd.', + city: 'Thrudheim', + province: 'Asgard', + zipcode: '999', + customerId: thor.id, + }); + const addr2 = await addressRepo.create({ + street: 'home of Odin Rd.', + city: 'Valhalla', + province: 'Asgard', + zipcode: '000', + customerId: odin.id, + }); + + const result = await addressRepo.find({ + include: [{relation: 'customer'}], + }); + + const expected = [ + { + ...addr1, + customer: { + id: thor.id, + name: 'Thor', + parentId: features.emptyValue, + }, + }, + { + ...addr2, + customer: { + id: odin.id, + name: 'Odin', + parentId: features.emptyValue, + }, + }, + ]; + expect(toJSON(result)).to.deepEqual(toJSON(expected)); + }); + + it('returns related instances to target models via findById() method', async () => { + const thor = await customerRepo.create({name: 'Thor'}); + const odin = await customerRepo.create({name: 'Odin'}); + await addressRepo.create({ + street: 'home of Thor Rd.', + city: 'Thrudheim', + province: 'Asgard', + zipcode: '999', + customerId: thor.id, + }); + const addr2 = await addressRepo.create({ + street: 'home of Odin Rd.', + city: 'Valhalla', + province: 'Asgard', + zipcode: '000', + customerId: odin.id, + }); + + const result = await addressRepo.findById(addr2.id, { + include: [{relation: 'customer'}], + }); + const expected = { + ...addr2, + customer: { + id: odin.id, + name: 'Odin', + parentId: features.emptyValue, + }, + }; + expect(toJSON(result)).to.deepEqual(toJSON(expected)); + }); + }); + }, + ); +} diff --git a/packages/repository-tests/src/types.repository-tests.ts b/packages/repository-tests/src/types.repository-tests.ts index a37cc4e7b251..c41e335a53be 100644 --- a/packages/repository-tests/src/types.repository-tests.ts +++ b/packages/repository-tests/src/types.repository-tests.ts @@ -56,6 +56,14 @@ export interface CrudFeatures { * Default: `false` */ supportsTransactions: boolean; + + /** + * Does the repository provide `inclusionResolvers` object where resolvers + * can be registered? + * + * Default: `true` + */ + supportsInclusionResolvers: boolean; } /** diff --git a/packages/repository/src/__tests__/unit/repositories/relations-helpers/find-by-foreign-keys.helpers.unit.ts b/packages/repository/src/__tests__/unit/repositories/relations-helpers/find-by-foreign-keys.unit.ts similarity index 100% rename from packages/repository/src/__tests__/unit/repositories/relations-helpers/find-by-foreign-keys.helpers.unit.ts rename to packages/repository/src/__tests__/unit/repositories/relations-helpers/find-by-foreign-keys.unit.ts diff --git a/packages/repository/src/__tests__/unit/repositories/relations-helpers/flatten-targets-of-one-to-one-relation.helpers.unit.ts b/packages/repository/src/__tests__/unit/repositories/relations-helpers/flatten-targets-of-one-to-one-relation.helpers.unit.ts new file mode 100644 index 000000000000..777ecaea316c --- /dev/null +++ b/packages/repository/src/__tests__/unit/repositories/relations-helpers/flatten-targets-of-one-to-one-relation.helpers.unit.ts @@ -0,0 +1,89 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {flattenTargetsOfOneToOneRelation} from '../../../..'; +import { + createCategory, + createProduct, + createManufacturer, +} from './relations-helpers-fixtures'; + +describe('flattenTargetsOfOneToOneRelation', () => { + describe('uses reduceAsSingleItem strategy for belongsTo relation', () => { + it('gets the result of passing in a single sourceId', () => { + const stationery = createCategory({id: 1, name: 'stationery'}); + const pen = createProduct({name: 'pen', categoryId: stationery.id}); + createProduct({name: 'eraser', categoryId: 2}); + + const result = flattenTargetsOfOneToOneRelation( + [pen.categoryId], + [stationery], + 'id', + ); + expect(result).to.eql([stationery]); + }); + + it('gets the result of passing in multiple sourceIds', () => { + const stationery = createCategory({id: 1, name: 'stationery'}); + const book = createCategory({id: 2, name: 'book'}); + const pen = createProduct({name: 'pen', categoryId: stationery.id}); + const pencil = createProduct({ + name: 'pencil', + categoryId: stationery.id, + }); + const erasers = createProduct({name: 'eraser', categoryId: book.id}); + // the order of sourceIds matters + const result = flattenTargetsOfOneToOneRelation( + [erasers.categoryId, pencil.categoryId, pen.categoryId], + [book, stationery, stationery], + 'id', + ); + expect(result).to.deepEqual([book, stationery, stationery]); + }); + }); + + describe('uses reduceAsSingleItem strategy for hasOne relation', () => { + it('gets the result of passing in a single sourceId', () => { + const pen = createProduct({id: 1, name: 'pen'}); + const penMaker = createManufacturer({ + name: 'Mr. Plastic', + productId: pen.id, + }); + + const result = flattenTargetsOfOneToOneRelation( + [pen.id], + [penMaker], + 'productId', + ); + expect(result).to.eql([penMaker]); + }); + + it('gets the result of passing in multiple sourceIds', () => { + const pen = createProduct({id: 1, name: 'pen'}); + const pencil = createProduct({id: 2, name: 'pencil'}); + const eraser = createProduct({id: 3, name: 'eraser'}); + const penMaker = createManufacturer({ + name: 'Mr. Plastic', + productId: pen.id, + }); + const pencilMaker = createManufacturer({ + name: 'Mr. Tree', + productId: pencil.id, + }); + const eraserMaker = createManufacturer({ + name: 'Mr. Rubber', + productId: eraser.id, + }); + // the order of sourceIds matters + const result = flattenTargetsOfOneToOneRelation( + [eraser.id, pencil.id, pen.id], + [penMaker, pencilMaker, eraserMaker], + 'productId', + ); + expect(result).to.deepEqual([eraserMaker, pencilMaker, penMaker]); + }); + }); +}); diff --git a/packages/repository/src/__tests__/unit/repositories/relations-helpers/include-related-models.helpers.unit.ts b/packages/repository/src/__tests__/unit/repositories/relations-helpers/include-related-models.unit.ts similarity index 100% rename from packages/repository/src/__tests__/unit/repositories/relations-helpers/include-related-models.helpers.unit.ts rename to packages/repository/src/__tests__/unit/repositories/relations-helpers/include-related-models.unit.ts diff --git a/packages/repository/src/__tests__/unit/repositories/relations-helpers/relations-helpers-fixtures.ts b/packages/repository/src/__tests__/unit/repositories/relations-helpers/relations-helpers-fixtures.ts index f6f0ec735256..da2d89e99892 100644 --- a/packages/repository/src/__tests__/unit/repositories/relations-helpers/relations-helpers-fixtures.ts +++ b/packages/repository/src/__tests__/unit/repositories/relations-helpers/relations-helpers-fixtures.ts @@ -14,29 +14,85 @@ import { juggler, model, property, + hasOne, + HasOneRepositoryFactory, } from '../../../..'; +@model() +export class Manufacturer extends Entity { + @property({id: true}) + id: number; + @property() + name: string; + @belongsTo(() => Product) + productId: number; + + constructor(data: Partial) { + super(data); + } +} +interface ManufacturerRelations { + products?: Product; +} + +export class ManufacturerRepository extends DefaultCrudRepository< + Manufacturer, + typeof Manufacturer.prototype.id, + ManufacturerRelations +> { + public readonly product: BelongsToAccessor< + Product, + typeof Manufacturer.prototype.id + >; + constructor( + dataSource: juggler.DataSource, + productRepository?: Getter, + ) { + super(Manufacturer, dataSource); + if (productRepository) + this.product = this.createBelongsToAccessorFor( + 'product', + productRepository, + ); + } +} + @model() export class Product extends Entity { @property({id: true}) id: number; @property() name: string; + @hasOne(() => Manufacturer) + manufacturer: Manufacturer; @belongsTo(() => Category) categoryId: number; + + constructor(data: Partial) { + super(data); + } +} +interface ProductRelations { + manufacturer?: Manufacturer; } export class ProductRepository extends DefaultCrudRepository< Product, - typeof Product.prototype.id + typeof Product.prototype.id, + ProductRelations > { public readonly category: BelongsToAccessor< Category, typeof Product.prototype.id >; + public readonly manufacturer: HasOneRepositoryFactory< + Manufacturer, + typeof Product.prototype.id + >; constructor( dataSource: juggler.DataSource, categoryRepository?: Getter, + manyfacturerRepository?: Getter, ) { super(Product, dataSource); if (categoryRepository) @@ -44,6 +100,11 @@ export class ProductRepository extends DefaultCrudRepository< 'category', categoryRepository, ); + if (manyfacturerRepository) + this.manufacturer = this.createHasOneRepositoryFactoryFor( + 'manufacturer', + manyfacturerRepository, + ); } } @@ -55,6 +116,10 @@ export class Category extends Entity { name: string; @hasMany(() => Product, {keyTo: 'categoryId'}) products?: Product[]; + + constructor(data: Partial) { + super(data); + } } interface CategoryRelations { products?: Product[]; @@ -85,3 +150,15 @@ export const testdb: juggler.DataSource = new juggler.DataSource({ name: 'db', connector: 'memory', }); + +export function createCategory(properties: Partial) { + return new Category(properties as Category); +} + +export function createProduct(properties: Partial) { + return new Product(properties as Product); +} + +export function createManufacturer(properties: Partial) { + return new Manufacturer(properties as Manufacturer); +} diff --git a/packages/repository/src/relations/belongs-to/belongs-to-accessor.ts b/packages/repository/src/relations/belongs-to/belongs-to-accessor.ts index 23ecb636d0cc..84458227ebfe 100644 --- a/packages/repository/src/relations/belongs-to/belongs-to-accessor.ts +++ b/packages/repository/src/relations/belongs-to/belongs-to-accessor.ts @@ -7,15 +7,28 @@ import * as debugFactory from 'debug'; import {DataObject} from '../../common-types'; import {Entity} from '../../model'; import {EntityCrudRepository} from '../../repositories/repository'; -import {BelongsToDefinition, Getter} from '../relation.types'; +import { + BelongsToDefinition, + Getter, + InclusionResolver, +} from '../relation.types'; import {resolveBelongsToMetadata} from './belongs-to.helpers'; import {DefaultBelongsToRepository} from './belongs-to.repository'; +import {createBelongsToInclusionResolver} from './belongs-to.inclusion-resolver'; const debug = debugFactory('loopback:repository:belongs-to-accessor'); -export type BelongsToAccessor = ( - sourceId: SourceId, -) => Promise; +export interface BelongsToAccessor { + /** + * Invoke the function to obtain HasManyRepository. + */ + (sourceId: SourceId): Promise; + + /** + * Use `resolver` property to obtain an InclusionResolver for this relation. + */ + inclusionResolver: InclusionResolver; +} /** * Enforces a BelongsTo constraint on a repository @@ -32,7 +45,10 @@ export function createBelongsToAccessor< ): BelongsToAccessor { const meta = resolveBelongsToMetadata(belongsToMetadata); debug('Resolved BelongsTo relation metadata: %o', meta); - return async function getTargetInstanceOfBelongsTo(sourceId: SourceId) { + const result: BelongsToAccessor< + Target, + SourceId + > = async function getTargetInstanceOfBelongsTo(sourceId: SourceId) { const foreignKey = meta.keyFrom; const primaryKey = meta.keyTo; const sourceModel = await sourceRepository.findById(sourceId); @@ -45,4 +61,10 @@ export function createBelongsToAccessor< ); return constrainedRepo.get(); }; + + result.inclusionResolver = createBelongsToInclusionResolver( + meta, + targetRepoGetter, + ); + return result; } diff --git a/packages/repository/src/relations/belongs-to/belongs-to.helpers.ts b/packages/repository/src/relations/belongs-to/belongs-to.helpers.ts index 13acf53b2cd8..27c21b496646 100644 --- a/packages/repository/src/relations/belongs-to/belongs-to.helpers.ts +++ b/packages/repository/src/relations/belongs-to/belongs-to.helpers.ts @@ -2,10 +2,11 @@ // Node module: @loopback/repository // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT + import * as debugFactory from 'debug'; import {InvalidRelationError} from '../../errors'; import {isTypeResolver} from '../../type-resolver'; -import {BelongsToDefinition} from '../relation.types'; +import {BelongsToDefinition, RelationType} from '../relation.types'; const debug = debugFactory('loopback:repository:belongs-to-helpers'); @@ -23,6 +24,11 @@ export type BelongsToResolvedDefinition = BelongsToDefinition & {keyTo: string}; * @internal */ export function resolveBelongsToMetadata(relationMeta: BelongsToDefinition) { + if ((relationMeta.type as RelationType) !== RelationType.belongsTo) { + const reason = 'relation type must be BelongsTo'; + throw new InvalidRelationError(reason, relationMeta); + } + if (!isTypeResolver(relationMeta.target)) { const reason = 'target must be a type resolver'; throw new InvalidRelationError(reason, relationMeta); 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 new file mode 100644 index 000000000000..7a9c8de61925 --- /dev/null +++ b/packages/repository/src/relations/belongs-to/belongs-to.inclusion-resolver.ts @@ -0,0 +1,57 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {AnyObject, Options} from '../../common-types'; +import {Entity} from '../../model'; +import {Filter, Inclusion} from '../../query'; +import {EntityCrudRepository} from '../../repositories/repository'; +import { + findByForeignKeys, + flattenTargetsOfOneToOneRelation, + StringKeyOf, + deduplicate, +} from '../relation.helpers'; +import { + BelongsToDefinition, + Getter, + InclusionResolver, +} from '../relation.types'; +import {resolveBelongsToMetadata} from './belongs-to.helpers'; + +export function createBelongsToInclusionResolver< + Target extends Entity, + TargetID, + TargetRelations extends object +>( + meta: BelongsToDefinition, + getTargetRepo: Getter< + EntityCrudRepository + >, +): InclusionResolver { + const relationMeta = resolveBelongsToMetadata(meta); + + return async function fetchIncludedModels( + entities: Entity[], + inclusion: Inclusion, + options?: Options, + ): Promise<((Target & TargetRelations) | undefined)[]> { + if (!entities.length) return []; + + const sourceKey = relationMeta.keyFrom; + const sourceIds = entities.map(e => (e as AnyObject)[sourceKey]); + const targetKey = relationMeta.keyTo as StringKeyOf; + + const targetRepo = await getTargetRepo(); + const targetsFound = await findByForeignKeys( + targetRepo, + targetKey, + deduplicate(sourceIds), + inclusion.scope as Filter, + options, + ); + + return flattenTargetsOfOneToOneRelation(sourceIds, targetsFound, targetKey); + }; +} diff --git a/packages/repository/src/relations/belongs-to/index.ts b/packages/repository/src/relations/belongs-to/index.ts index 1541beeac649..6c5f1b1ecb35 100644 --- a/packages/repository/src/relations/belongs-to/index.ts +++ b/packages/repository/src/relations/belongs-to/index.ts @@ -6,3 +6,4 @@ export * from './belongs-to.decorator'; export * from './belongs-to.repository'; export * from './belongs-to-accessor'; +export * from './belongs-to.inclusion-resolver'; diff --git a/packages/repository/src/relations/relation.helpers.ts b/packages/repository/src/relations/relation.helpers.ts index a79674e7b63a..3af88fab7dea 100644 --- a/packages/repository/src/relations/relation.helpers.ts +++ b/packages/repository/src/relations/relation.helpers.ts @@ -3,6 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import * as assert from 'assert'; import * as debugFactory from 'debug'; import * as _ from 'lodash'; import { @@ -46,7 +47,14 @@ export async function findByForeignKeys< if (Array.isArray(fkValues)) { if (fkValues.length === 0) return []; - value = fkValues.length === 1 ? fkValues[0] : {inq: fkValues}; + value = + fkValues.length === 1 + ? fkValues[0] + : { + // Create a copy to prevent query coercion algorithm + // inside connectors from modifying the original values + inq: [...fkValues], + }; } else { value = fkValues; } @@ -57,7 +65,7 @@ export async function findByForeignKeys< return targetRepository.find(targetFilter, options); } -type StringKeyOf = Extract; +export type StringKeyOf = Extract; /** * Returns model instances that include related models that have a registered @@ -132,3 +140,164 @@ function isInclusionAllowed( debug('isInclusionAllowed for %j (relation %s)? %s', include, allowed); return allowed; } + +/** + * Returns an array of instances. The order of arrays is based on + * the order of sourceIds + * + * @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 + * + * @return + */ +export function flattenTargetsOfOneToOneRelation< + SourceWithRelations extends Entity, + Target extends Entity +>( + sourceIds: unknown[], + targetEntities: Target[], + targetKey: StringKeyOf, +): (Target | undefined)[] { + const lookup = buildLookupMap( + targetEntities, + targetKey, + reduceAsSingleItem, + ); + + return flattenMapByKeys(sourceIds, lookup); +} + +/** + * Returns an array of instances from the target map. The order of arrays is based on + * the order of sourceIds + * + * @param sourceIds - One value or array of values (of the target key) + * @param targetMap - a map that matches sourceIds with instances + */ +export function flattenMapByKeys( + sourceIds: unknown[], + targetMap: Map, +): (T | undefined)[] { + const result: (T | undefined)[] = new Array(sourceIds.length); + // mongodb: use string as key of targetMap, and convert sourceId to strings + // to make sure it gets the related instances. + sourceIds.forEach((id, index) => { + const key = normalizeKey(id); + const target = targetMap.get(key); + result[index] = target; + }); + + return result; +} + +/** + * Returns a map which maps key values(ids) to instances. The instances can be + * grouped by different strategies. + * + * @param list - an array of instances + * @param keyName - key name of the source + * @param reducer - a strategy to reduce inputs to single item or array + */ +export function buildLookupMap( + list: InType[], + keyName: StringKeyOf, + reducer: (accumulator: OutType | undefined, current: InType) => OutType, +): Map { + const lookup = new Map(); + for (const entity of list) { + // get a correct key value + const key = getKeyValue(entity, keyName) as Key; + // these 3 steps are to set up the map, the map differs according to the reducer. + const original = lookup.get(key); + const reduced = reducer(original, entity); + lookup.set(key, reduced); + } + return lookup; +} + +/** + * Returns value of a keyName. Aims to resolve ObjectId problem of Mongo. + * + * @param model - target model + * @param keyName - target key that gets the value from + */ +export function getKeyValue(model: AnyObject, keyName: string) { + return normalizeKey(model[keyName]); +} + +/** + * Workaround for MongoDB, where the connector returns ObjectID + * values even for properties configured with "type: string". + * + * @param rawKey + */ +export function normalizeKey(rawKey: unknown) { + if (isBsonType(rawKey)) { + return rawKey.toString(); + } + return rawKey; +} + +/** + * Returns an array of instances. For HasMany relation usage. + * + * @param acc + * @param it + */ +export function reduceAsArray(acc: T[] | undefined, it: T) { + if (acc) acc.push(it); + else acc = [it]; + return acc; +} +/** + * Returns a single of an instance. For HasOne and BelongsTo relation usage. + * + * @param _acc + * @param it + */ +export function reduceAsSingleItem(_acc: T | undefined, it: T) { + return it; +} + +/** + * Dedupe an array + * @param {Array} input an array + * @returns {Array} an array with unique items + */ +export function deduplicate(input: T[]): T[] { + const uniqArray: T[] = []; + if (!input) { + return uniqArray; + } + assert(Array.isArray(input), 'array argument is required'); + + const comparableArray = input.map(item => + isBsonType(item) ? item.toString() : item, + ); + for (let i = 0, n = comparableArray.length; i < n; i++) { + if (comparableArray.indexOf(comparableArray[i]) === i) { + uniqArray.push(input[i]); + } + } + return uniqArray; +} + +/** + * Checks if the value is BsonType (mongodb) + * It uses a general way to check the type ,so that it can detect + * different versions of bson that might be used in the code base. + * Might need to update in the future. + * + * @param value + */ +export function isBsonType(value: unknown): value is object { + if (typeof value !== 'object' || !value) return false; + + // bson@1.x stores _bsontype on ObjectID instance, bson@4.x on prototype + return check(value) || check(value.constructor.prototype); + + function check(target: unknown) { + return Object.prototype.hasOwnProperty.call(target, '_bsontype'); + } +}