1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 | import assert from 'assert';
|
7 | import debugFactory from 'debug';
|
8 | import _, {cloneDeep} from 'lodash';
|
9 | import {
|
10 | AnyObject,
|
11 | Entity,
|
12 | EntityCrudRepository,
|
13 | Filter,
|
14 | FilterBuilder,
|
15 | InclusionFilter,
|
16 | Options,
|
17 | Where,
|
18 | } from '..';
|
19 | const debug = debugFactory('loopback:repository:relation-helpers');
|
20 |
|
21 |
|
22 |
|
23 |
|
24 |
|
25 |
|
26 |
|
27 |
|
28 |
|
29 |
|
30 | export 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 |
|
52 |
|
53 |
|
54 | if (options) {
|
55 | useScopeFilterGlobally = options.isThroughModelInclude;
|
56 | }
|
57 |
|
58 |
|
59 |
|
60 |
|
61 | if (!scope?.limit) {
|
62 | useScopeFilterGlobally = true;
|
63 | }
|
64 |
|
65 |
|
66 |
|
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 |
|
76 |
|
77 |
|
78 | const findPromises = fkValues.map(fk => {
|
79 | const where = {[fkName]: fk} as unknown as Where<Target>;
|
80 | let localScope = cloneDeep(scope);
|
81 |
|
82 | localScope = new FilterBuilder(localScope).impose({where}).filter;
|
83 | return targetRepository.find(localScope, options);
|
84 | });
|
85 | return Promise.all(findPromises).then(findResults => {
|
86 |
|
87 | return _.flatten(findResults);
|
88 | });
|
89 | } else {
|
90 | const where = {[fkName]: value} as unknown as Where<Target>;
|
91 |
|
92 | if (isScopeSet) {
|
93 |
|
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 |
|
103 | export type StringKeyOf<T> = Extract<keyof T, string>;
|
104 |
|
105 |
|
106 |
|
107 |
|
108 |
|
109 |
|
110 |
|
111 |
|
112 |
|
113 |
|
114 |
|
115 | export 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 |
|
181 |
|
182 |
|
183 |
|
184 |
|
185 |
|
186 | function 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 |
|
202 |
|
203 |
|
204 |
|
205 |
|
206 |
|
207 |
|
208 |
|
209 | export 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 |
|
225 |
|
226 |
|
227 |
|
228 |
|
229 |
|
230 |
|
231 |
|
232 |
|
233 | export 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 |
|
260 |
|
261 |
|
262 |
|
263 |
|
264 |
|
265 | export function flattenMapByKeys<T>(
|
266 | sourceIds: unknown[],
|
267 | targetMap: Map<unknown, T>,
|
268 | ): (T | undefined)[] {
|
269 | const result: (T | undefined)[] = new Array(sourceIds.length);
|
270 |
|
271 |
|
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 |
|
283 |
|
284 |
|
285 |
|
286 |
|
287 |
|
288 |
|
289 | export 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 |
|
297 | const key = getKeyValue(entity, keyName) as Key;
|
298 |
|
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 |
|
308 |
|
309 |
|
310 |
|
311 |
|
312 | export function getKeyValue(model: AnyObject, keyName: string) {
|
313 | return normalizeKey(model[keyName]);
|
314 | }
|
315 |
|
316 |
|
317 |
|
318 |
|
319 |
|
320 |
|
321 |
|
322 | export function normalizeKey(rawKey: unknown) {
|
323 | if (isBsonType(rawKey)) {
|
324 | return rawKey.toString();
|
325 | }
|
326 | return rawKey;
|
327 | }
|
328 |
|
329 |
|
330 |
|
331 |
|
332 |
|
333 |
|
334 |
|
335 | export function reduceAsArray<T>(acc: T[] | undefined, it: T) {
|
336 | if (acc) acc.push(it);
|
337 | else acc = [it];
|
338 | return acc;
|
339 | }
|
340 |
|
341 |
|
342 |
|
343 |
|
344 |
|
345 |
|
346 | export function reduceAsSingleItem<T>(_acc: T | undefined, it: T) {
|
347 | return it;
|
348 | }
|
349 |
|
350 |
|
351 |
|
352 |
|
353 |
|
354 |
|
355 | export 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 |
|
373 |
|
374 |
|
375 |
|
376 |
|
377 |
|
378 |
|
379 | export function isBsonType(value: unknown): value is object {
|
380 | if (typeof value !== 'object' || !value) return false;
|
381 |
|
382 |
|
383 | return check(value) || check(value.constructor.prototype);
|
384 |
|
385 | function check(target: unknown) {
|
386 | return Object.prototype.hasOwnProperty.call(target, '_bsontype');
|
387 | }
|
388 | }
|