UNPKG

12 kBPlain TextView Raw
1// Copyright IBM Corp. and LoopBack contributors 2019,2020. All Rights Reserved.
2// Node module: @loopback/repository
3// This file is licensed under the MIT License.
4// License text available at https://opensource.org/licenses/MIT
5
6import assert from 'assert';
7import debugFactory from 'debug';
8import _, {cloneDeep} from 'lodash';
9import {
10 AnyObject,
11 Entity,
12 EntityCrudRepository,
13 Filter,
14 FilterBuilder,
15 InclusionFilter,
16 Options,
17 Where,
18} from '..';
19const debug = debugFactory('loopback:repository:relation-helpers');
20
21/**
22 * Finds model instances that contain any of the provided foreign key values.
23 *
24 * @param targetRepository - The target repository where the related model instances are found
25 * @param fkName - Name of the foreign key
26 * @param fkValues - One value or array of values of the foreign key to be included
27 * @param scope - Additional scope constraints
28 * @param options - Options for the operations
29 */
30export async function findByForeignKeys<
31 Target extends Entity,
32 TargetRelations extends object,
33 ForeignKey extends StringKeyOf<Target>,
34>(
35 targetRepository: EntityCrudRepository<Target, unknown, TargetRelations>,
36 fkName: ForeignKey,
37 fkValues: Target[ForeignKey][] | Target[ForeignKey],
38 scope?: Filter<Target> & {totalLimit?: number},
39 options?: Options,
40): Promise<(Target & TargetRelations)[]> {
41 let value;
42 scope = cloneDeep(scope);
43 if (Array.isArray(fkValues)) {
44 if (fkValues.length === 0) return [];
45 value = fkValues.length === 1 ? fkValues[0] : {inq: fkValues};
46 } else {
47 value = fkValues;
48 }
49 let useScopeFilterGlobally = false;
50
51 // If its an include from a through model, fkValues will be an array.
52 // However, in this case we DO want to use the scope in the entire query, not
53 // on a per-fk basis
54 if (options) {
55 useScopeFilterGlobally = options.isThroughModelInclude;
56 }
57
58 // If `scope.limit` is not defined, there is no reason to apply the scope to
59 // each fk. This is to prevent unecessarily high database query counts.
60 // See: https://github.com/loopbackio/loopback-next/issues/8074
61 if (!scope?.limit) {
62 useScopeFilterGlobally = true;
63 }
64
65 // This code is to keep backward compatibility.
66 // See https://github.com/loopbackio/loopback-next/issues/6832 for more info.
67 if (scope?.totalLimit) {
68 scope.limit = scope.totalLimit;
69 useScopeFilterGlobally = true;
70 delete scope.totalLimit;
71 }
72
73 const isScopeSet = scope && !_.isEmpty(scope);
74 if (isScopeSet && Array.isArray(fkValues) && !useScopeFilterGlobally) {
75 // Since there is a scope, there could be a where filter, a limit, an order
76 // and we should run the scope in multiple queries so we can respect the
77 // scope filter params
78 const findPromises = fkValues.map(fk => {
79 const where = {[fkName]: fk} as unknown as Where<Target>;
80 let localScope = cloneDeep(scope);
81 // combine where clause to scope filter
82 localScope = new FilterBuilder(localScope).impose({where}).filter;
83 return targetRepository.find(localScope, options);
84 });
85 return Promise.all(findPromises).then(findResults => {
86 //findResults is an array of arrays for each scope result, so we need to flatten it before returning it
87 return _.flatten(findResults);
88 });
89 } else {
90 const where = {[fkName]: value} as unknown as Where<Target>;
91
92 if (isScopeSet) {
93 // combine where clause to scope filter
94 scope = new FilterBuilder(scope).impose({where}).filter;
95 } else {
96 scope = {where} as Filter<Target>;
97 }
98
99 return targetRepository.find(scope, options);
100 }
101}
102
103export type StringKeyOf<T> = Extract<keyof T, string>;
104
105/**
106 * Returns model instances that include related models that have a registered
107 * resolver.
108 *
109 * @param targetRepository - The target repository where the model instances are found
110 * @param entities - An array of entity instances or data
111 * @param include -Inclusion filter
112 * @param options - Options for the operations
113 */
114
115export async function includeRelatedModels<
116 T extends Entity,
117 Relations extends object = {},
118>(
119 targetRepository: EntityCrudRepository<T, unknown, Relations>,
120 entities: T[],
121 include?: InclusionFilter[],
122 options?: Options,
123): Promise<(T & Relations)[]> {
124 entities = cloneDeep(entities);
125 if (options?.polymorphicType) {
126 include = include?.filter(inclusionFilter => {
127 if (typeof inclusionFilter === 'string') {
128 return true;
129 } else {
130 if (
131 inclusionFilter.targetType === undefined ||
132 inclusionFilter.targetType === options?.polymorphicType
133 ) {
134 return true;
135 }
136 }
137 });
138 } else {
139 include = cloneDeep(include);
140 }
141 const result = entities as (T & Relations)[];
142 if (!include) return result;
143
144 const invalidInclusions = include.filter(
145 inclusionFilter => !isInclusionAllowed(targetRepository, inclusionFilter),
146 );
147 if (invalidInclusions.length) {
148 const msg =
149 'Invalid "filter.include" entries: ' +
150 invalidInclusions
151 .map(inclusionFilter => JSON.stringify(inclusionFilter))
152 .join('; ');
153 const err = new Error(msg);
154 Object.assign(err, {
155 code: 'INVALID_INCLUSION_FILTER',
156 statusCode: 400,
157 });
158 throw err;
159 }
160
161 const resolveTasks = include.map(async inclusionFilter => {
162 const relationName =
163 typeof inclusionFilter === 'string'
164 ? inclusionFilter
165 : inclusionFilter.relation;
166 const resolver = targetRepository.inclusionResolvers.get(relationName)!;
167 const targets = await resolver(entities, inclusionFilter, options);
168
169 result.forEach((entity, ix) => {
170 const src = entity as AnyObject;
171 src[relationName] = targets[ix];
172 });
173 });
174
175 await Promise.all(resolveTasks);
176
177 return result;
178}
179/**
180 * Checks if the resolver of the inclusion relation is registered
181 * in the inclusionResolver of the target repository
182 *
183 * @param targetRepository - The target repository where the relations are registered
184 * @param include - Inclusion filter
185 */
186function isInclusionAllowed<T extends Entity, Relations extends object = {}>(
187 targetRepository: EntityCrudRepository<T, unknown, Relations>,
188 include: InclusionFilter,
189): boolean {
190 const relationName = typeof include === 'string' ? include : include.relation;
191 if (!relationName) {
192 debug('isInclusionAllowed for %j? No: missing relation name', include);
193 return false;
194 }
195 const allowed = targetRepository.inclusionResolvers.has(relationName);
196 debug('isInclusionAllowed for %j (relation %s)? %s', include, allowed);
197 return allowed;
198}
199
200/**
201 * Returns an array of instances. The order of arrays is based on
202 * the order of sourceIds
203 *
204 * @param sourceIds - One value or array of values of the target key
205 * @param targetEntities - target entities that satisfy targetKey's value (ids).
206 * @param targetKey - name of the target key
207 *
208 */
209export function flattenTargetsOfOneToOneRelation<Target extends Entity>(
210 sourceIds: unknown[],
211 targetEntities: Target[],
212 targetKey: StringKeyOf<Target>,
213): (Target | undefined)[] {
214 const lookup = buildLookupMap<unknown, Target, Target>(
215 targetEntities,
216 targetKey,
217 reduceAsSingleItem,
218 );
219
220 return flattenMapByKeys(sourceIds, lookup);
221}
222
223/**
224 * Returns an array of instances. The order of arrays is based on
225 * as a result of one to many relation. The order of arrays is based on
226 * the order of sourceIds
227 *
228 * @param sourceIds - One value or array of values of the target key
229 * @param targetEntities - target entities that satisfy targetKey's value (ids).
230 * @param targetKey - name of the target key
231 *
232 */
233export function flattenTargetsOfOneToManyRelation<Target extends Entity>(
234 sourceIds: unknown[],
235 targetEntities: Target[],
236 targetKey: StringKeyOf<Target>,
237): (Target[] | undefined)[] {
238 debug('flattenTargetsOfOneToManyRelation');
239 debug('sourceIds', sourceIds);
240 debug(
241 'sourceId types',
242 sourceIds.map(i => typeof i),
243 );
244 debug('targetEntities', targetEntities);
245 debug('targetKey', targetKey);
246
247 const lookup = buildLookupMap<unknown, Target, Target[]>(
248 targetEntities,
249 targetKey,
250 reduceAsArray,
251 );
252
253 debug('lookup map', lookup);
254
255 return flattenMapByKeys(sourceIds, lookup);
256}
257
258/**
259 * Returns an array of instances from the target map. The order of arrays is based on
260 * the order of sourceIds
261 *
262 * @param sourceIds - One value or array of values (of the target key)
263 * @param targetMap - a map that matches sourceIds with instances
264 */
265export function flattenMapByKeys<T>(
266 sourceIds: unknown[],
267 targetMap: Map<unknown, T>,
268): (T | undefined)[] {
269 const result: (T | undefined)[] = new Array(sourceIds.length);
270 // mongodb: use string as key of targetMap, and convert sourceId to strings
271 // to make sure it gets the related instances.
272 sourceIds.forEach((id, index) => {
273 const key = normalizeKey(id);
274 const target = targetMap.get(key);
275 result[index] = target;
276 });
277
278 return result;
279}
280
281/**
282 * Returns a map which maps key values(ids) to instances. The instances can be
283 * grouped by different strategies.
284 *
285 * @param list - an array of instances
286 * @param keyName - key name of the source
287 * @param reducer - a strategy to reduce inputs to single item or array
288 */
289export function buildLookupMap<Key, InType extends object, OutType = InType>(
290 list: InType[],
291 keyName: StringKeyOf<InType>,
292 reducer: (accumulator: OutType | undefined, current: InType) => OutType,
293): Map<Key, OutType> {
294 const lookup = new Map<Key, OutType>();
295 for (const entity of list) {
296 // get a correct key value
297 const key = getKeyValue(entity, keyName) as Key;
298 // these 3 steps are to set up the map, the map differs according to the reducer.
299 const original = lookup.get(key);
300 const reduced = reducer(original, entity);
301 lookup.set(key, reduced);
302 }
303 return lookup;
304}
305
306/**
307 * Returns value of a keyName. Aims to resolve ObjectId problem of Mongo.
308 *
309 * @param model - target model
310 * @param keyName - target key that gets the value from
311 */
312export function getKeyValue(model: AnyObject, keyName: string) {
313 return normalizeKey(model[keyName]);
314}
315
316/**
317 * Workaround for MongoDB, where the connector returns ObjectID
318 * values even for properties configured with "type: string".
319 *
320 * @param rawKey
321 */
322export function normalizeKey(rawKey: unknown) {
323 if (isBsonType(rawKey)) {
324 return rawKey.toString();
325 }
326 return rawKey;
327}
328
329/**
330 * Returns an array of instances. For HasMany relation usage.
331 *
332 * @param acc
333 * @param it
334 */
335export function reduceAsArray<T>(acc: T[] | undefined, it: T) {
336 if (acc) acc.push(it);
337 else acc = [it];
338 return acc;
339}
340/**
341 * Returns a single of an instance. For HasOne and BelongsTo relation usage.
342 *
343 * @param _acc
344 * @param it
345 */
346export function reduceAsSingleItem<T>(_acc: T | undefined, it: T) {
347 return it;
348}
349
350/**
351 * Dedupe an array
352 * @param input - an array of sourceIds
353 * @returns an array with unique items
354 */
355export function deduplicate<T>(input: T[]): T[] {
356 const uniqArray: T[] = [];
357 if (!input) {
358 return uniqArray;
359 }
360 assert(Array.isArray(input), 'array argument is required');
361
362 const comparableArray = input.map(item => normalizeKey(item));
363 for (let i = 0, n = comparableArray.length; i < n; i++) {
364 if (comparableArray.indexOf(comparableArray[i]) === i) {
365 uniqArray.push(input[i]);
366 }
367 }
368 return uniqArray;
369}
370
371/**
372 * Checks if the value is BsonType (mongodb)
373 * It uses a general way to check the type ,so that it can detect
374 * different versions of bson that might be used in the code base.
375 * Might need to update in the future.
376 *
377 * @param value
378 */
379export function isBsonType(value: unknown): value is object {
380 if (typeof value !== 'object' || !value) return false;
381
382 // bson@1.x stores _bsontype on ObjectID instance, bson@4.x on prototype
383 return check(value) || check(value.constructor.prototype);
384
385 function check(target: unknown) {
386 return Object.prototype.hasOwnProperty.call(target, '_bsontype');
387 }
388}