@loopback/repository
Version:
Define and implement a common set of interfaces for interacting with databases
391 lines (357 loc) • 12 kB
text/typescript
// Copyright IBM Corp. and LoopBack contributors 2019,2020. 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 assert from 'assert';
import debugFactory from 'debug';
import _, {cloneDeep} from 'lodash';
import {
AnyObject,
Entity,
EntityCrudRepository,
Filter,
FilterBuilder,
InclusionFilter,
Options,
Where,
} from '..';
const debug = debugFactory('loopback:repository:relation-helpers');
/**
* Finds model instances that contain any of the provided foreign key values.
*
* @param targetRepository - The target repository where the related model instances are found
* @param fkName - Name of the foreign key
* @param fkValues - One value or array of values of the foreign key to be included
* @param scope - Additional scope constraints
* @param options - Options for the operations
*/
export async function findByForeignKeys<
Target extends Entity,
TargetRelations extends object,
ForeignKey extends StringKeyOf<Target>,
>(
targetRepository: EntityCrudRepository<Target, unknown, TargetRelations>,
fkName: ForeignKey,
fkValues: Target[ForeignKey][] | Target[ForeignKey],
scope?: Filter<Target> & {totalLimit?: number},
options?: Options,
): Promise<(Target & TargetRelations)[]> {
let value;
scope = cloneDeep(scope);
if (Array.isArray(fkValues)) {
if (fkValues.length === 0) return [];
value = fkValues.length === 1 ? fkValues[0] : {inq: fkValues};
} else {
value = fkValues;
}
let useScopeFilterGlobally = false;
// If its an include from a through model, fkValues will be an array.
// However, in this case we DO want to use the scope in the entire query, not
// on a per-fk basis
if (options) {
useScopeFilterGlobally = options.isThroughModelInclude;
}
// If `scope.limit` is not defined, there is no reason to apply the scope to
// each fk. This is to prevent unecessarily high database query counts.
// See: https://github.com/loopbackio/loopback-next/issues/8074
if (!scope?.limit) {
useScopeFilterGlobally = true;
}
// This code is to keep backward compatibility.
// See https://github.com/loopbackio/loopback-next/issues/6832 for more info.
if (scope?.totalLimit) {
scope.limit = scope.totalLimit;
useScopeFilterGlobally = true;
delete scope.totalLimit;
}
const isScopeSet = scope && !_.isEmpty(scope);
if (isScopeSet && Array.isArray(fkValues) && !useScopeFilterGlobally) {
// Since there is a scope, there could be a where filter, a limit, an order
// and we should run the scope in multiple queries so we can respect the
// scope filter params
const findPromises = fkValues.map(fk => {
const where = {[fkName]: fk} as unknown as Where<Target>;
let localScope = cloneDeep(scope);
// combine where clause to scope filter
localScope = new FilterBuilder(localScope).impose({where}).filter;
return targetRepository.find(localScope, options);
});
return Promise.all(findPromises).then(findResults => {
//findResults is an array of arrays for each scope result, so we need to flatten it before returning it
return _.flatten(findResults);
});
} else {
const where = {[fkName]: value} as unknown as Where<Target>;
if (isScopeSet) {
// combine where clause to scope filter
scope = new FilterBuilder(scope).impose({where}).filter;
} else {
scope = {where} as Filter<Target>;
}
return targetRepository.find(scope, options);
}
}
export type StringKeyOf<T> = Extract<keyof T, string>;
/**
* Returns model instances that include related models that have a registered
* 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 options - Options for the operations
*/
export async function includeRelatedModels<
T extends Entity,
Relations extends object = {},
>(
targetRepository: EntityCrudRepository<T, unknown, Relations>,
entities: T[],
include?: InclusionFilter[],
options?: Options,
): Promise<(T & Relations)[]> {
if (options?.polymorphicType) {
include = include?.filter(inclusionFilter => {
if (typeof inclusionFilter === 'string') {
return true;
} else {
if (
inclusionFilter.targetType === undefined ||
inclusionFilter.targetType === options?.polymorphicType
) {
return true;
}
}
});
} else {
include = cloneDeep(include);
}
if (include) {
entities = cloneDeep(entities);
}
const result = entities as (T & Relations)[];
if (!include) return result;
const invalidInclusions = include.filter(
inclusionFilter => !isInclusionAllowed(targetRepository, inclusionFilter),
);
if (invalidInclusions.length) {
const msg =
'Invalid "filter.include" entries: ' +
invalidInclusions
.map(inclusionFilter => JSON.stringify(inclusionFilter))
.join('; ');
const err = new Error(msg);
Object.assign(err, {
code: 'INVALID_INCLUSION_FILTER',
statusCode: 400,
});
throw err;
}
const resolveTasks = include.map(async inclusionFilter => {
const relationName =
typeof inclusionFilter === 'string'
? inclusionFilter
: inclusionFilter.relation;
const resolver = targetRepository.inclusionResolvers.get(relationName)!;
const targets = await resolver(entities, inclusionFilter, options);
result.forEach((entity, ix) => {
const src = entity as AnyObject;
src[relationName] = targets[ix];
});
});
await Promise.all(resolveTasks);
return result;
}
/**
* Checks if the resolver of the inclusion relation is registered
* in the inclusionResolver of the target repository
*
* @param targetRepository - The target repository where the relations are registered
* @param include - Inclusion filter
*/
function isInclusionAllowed<T extends Entity, Relations extends object = {}>(
targetRepository: EntityCrudRepository<T, unknown, Relations>,
include: InclusionFilter,
): boolean {
const relationName = typeof include === 'string' ? include : include.relation;
if (!relationName) {
debug('isInclusionAllowed for %j? No: missing relation name', include);
return false;
}
const allowed = targetRepository.inclusionResolvers.has(relationName);
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
*
*/
export function flattenTargetsOfOneToOneRelation<Target extends Entity>(
sourceIds: unknown[],
targetEntities: Target[],
targetKey: StringKeyOf<Target>,
): (Target | undefined)[] {
const lookup = buildLookupMap<unknown, Target, Target>(
targetEntities,
targetKey,
reduceAsSingleItem,
);
return flattenMapByKeys(sourceIds, lookup);
}
/**
* Returns an array of instances. The order of arrays is based on
* as a result of one to many relation. 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
*
*/
export function flattenTargetsOfOneToManyRelation<Target extends Entity>(
sourceIds: unknown[],
targetEntities: Target[],
targetKey: StringKeyOf<Target>,
): (Target[] | undefined)[] {
debug('flattenTargetsOfOneToManyRelation');
debug('sourceIds', sourceIds);
debug(
'sourceId types',
sourceIds.map(i => typeof i),
);
debug('targetEntities', targetEntities);
debug('targetKey', targetKey);
const lookup = buildLookupMap<unknown, Target, Target[]>(
targetEntities,
targetKey,
reduceAsArray,
);
debug('lookup map', lookup);
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<T>(
sourceIds: unknown[],
targetMap: Map<unknown, T>,
): (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<Key, InType extends object, OutType = InType>(
list: InType[],
keyName: StringKeyOf<InType>,
reducer: (accumulator: OutType | undefined, current: InType) => OutType,
): Map<Key, OutType> {
const lookup = new Map<Key, OutType>();
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<T>(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<T>(_acc: T | undefined, it: T) {
return it;
}
/**
* Dedupe an array
* @param input - an array of sourceIds
* @returns an array with unique items
*/
export function deduplicate<T>(input: T[]): T[] {
const uniqArray: T[] = [];
if (!input) {
return uniqArray;
}
assert(Array.isArray(input), 'array argument is required');
const comparableArray = input.map(item => normalizeKey(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');
}
}