Skip to content

Commit 5e17832

Browse files
committed
fix: compute mandatory populates even if not efficiently
1 parent ee1cafa commit 5e17832

File tree

3 files changed

+179
-17
lines changed

3 files changed

+179
-17
lines changed

.changeset/twelve-lions-vanish.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"mikro-orm-find-dataloader": minor
3+
---
4+
5+
fix: compute mandatory populates even if not efficiently

packages/find/src/find.test.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,23 @@ describe("find", () => {
124124
it("should fetch books with the find dataloader", async () => {
125125
const authors = await em.fork().find(Author, {});
126126
const mock = mockLogger(orm);
127-
const authorBooks = await Promise.all(
128-
authors.map(async ({ id }) => await em.getRepository(Book).find({ author: id })),
129-
);
127+
const authorBooks = await Promise.all([
128+
...authors.map(async ({ id }) => await em.getRepository(Book).find({ author: id })),
129+
// em.getRepository(Book).find({ author: { books: { author: 1 } } }),
130+
// em.getRepository(Book).find({ title: "a", author: [1, { books: { author: 1 } }] }),
131+
// em.getRepository(Book).find({ title: "a", author: { books: { author: 1 } } }),
132+
// em.getRepository(Book).find({ title: "a", author: { books: { author: { name: "a" } } } }),
133+
// em.getRepository(Book).find({ title: "a", author: { books: { title: "a" } } }),
134+
// em.getRepository(Book).find({ title: "a", author: { books: 1 } }),
135+
// em.getRepository(Book).find({ author: 1 }),
136+
// em.getRepository(Book).find({ author: 1 }),
137+
// em.getRepository(Book).find({ id: 2, title: "b", author: { id: 1, name: "a" } }),
138+
// em.getRepository(Book).find({ author: { id: 1, name: "a" } }),
139+
// em.getRepository(Book).find({ author: { id: 2 } }),
140+
// em.getRepository(Book).find({ author: { id: 3 } }),
141+
// em.getRepository(Book).find({ author: { name: "a" } }),
142+
]);
143+
// console.log(mock.mock.calls);
130144
expect(authorBooks).toBeDefined();
131145
expect(authorBooks).toMatchSnapshot();
132146
expect(mock.mock.calls).toEqual([

packages/find/src/findDataloader.ts

Lines changed: 157 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,90 @@ export function groupInversedOrMappedKeysByEntity<T extends AnyEntity<T>>(
124124
return entitiesMap;
125125
}
126126

127+
function allKeysArePK<K extends object>(
128+
keys: Array<EntityKey<K>> | undefined,
129+
primaryKeys: Array<EntityKey<K>>,
130+
): boolean {
131+
if (keys == null) {
132+
return false;
133+
}
134+
if (keys.length !== primaryKeys.length) {
135+
return false;
136+
}
137+
for (const key of keys) {
138+
if (!primaryKeys.includes(key)) {
139+
return false;
140+
}
141+
}
142+
return true;
143+
}
144+
145+
// {id: 5, name: "a"} returns false because contains additional fields
146+
// Returns true for all PK formats including {id: 1} or {owner: 1, recipient: 2}
147+
function isPK<K extends object>(filter: FilterQueryDataloader<K>, meta: EntityMetadata<K>): boolean {
148+
if (meta == null) {
149+
return false;
150+
}
151+
if (meta.compositePK) {
152+
// COMPOSITE
153+
if (Array.isArray(filter)) {
154+
// PK or PK[] or object[]
155+
// [1, 2]
156+
// [[1, 2], [3, 4]]
157+
// [{owner: 1, recipient: 2}, {owner: 3, recipient: 4}]
158+
// [{owner: 1, recipient: 2, sex: 0}, {owner: 3, recipient: 4, sex: 1}]
159+
if (Utils.isPrimaryKey(filter, meta.compositePK)) {
160+
// PK
161+
return true;
162+
}
163+
if (Utils.isPrimaryKey(filter[0], meta.compositePK)) {
164+
// PK[]
165+
return true;
166+
}
167+
const keys = typeof filter[0] === "object" ? (Object.keys(filter[0]) as Array<EntityKey<K>>) : undefined;
168+
if (allKeysArePK(keys, meta.primaryKeys)) {
169+
// object is PK or PK[]
170+
return true;
171+
}
172+
} else {
173+
// object
174+
// {owner: 1, recipient: 2, sex: 0}
175+
const keys = typeof filter === "object" ? (Object.keys(filter) as Array<EntityKey<K>>) : undefined;
176+
if (allKeysArePK(keys, meta.primaryKeys)) {
177+
// object is PK
178+
return true;
179+
}
180+
}
181+
} else {
182+
// NOT COMPOSITE
183+
if (Array.isArray(filter)) {
184+
// PK[]
185+
// [1, 2]
186+
// [{id: 1}, {id: 2}] NOT POSSIBLE FOR NON COMPOSITE
187+
if (Utils.isPrimaryKey(filter[0])) {
188+
return true;
189+
}
190+
} else {
191+
// PK or object
192+
// 1
193+
// {id: [1, 2], sex: 0} or {id: 1, sex: 0}
194+
if (Utils.isPrimaryKey(filter)) {
195+
// PK
196+
return true;
197+
}
198+
const keys =
199+
typeof filter === "object" ? (Object.keys(filter) as [EntityKey<K>, ...Array<EntityKey<K>>]) : undefined;
200+
if (keys?.length === 1 && keys[0] === meta.primaryKeys[0]) {
201+
// object is PK
202+
return true;
203+
}
204+
}
205+
}
206+
return false;
207+
}
208+
127209
// Call this fn only if keyProp.targetMeta != null otherwise you will get false positives
210+
// Returns only PKs in short-hand format like 1 or [1, 1] not {id: 1} or {owner: 1, recipient: 2}
128211
function getPKs<K extends object>(
129212
filter: FilterQueryDataloader<K>,
130213
meta: EntityMetadata<K>,
@@ -180,20 +263,20 @@ COMPOSITE PK:
180263
const asc = (a: string, b: string): number => a.localeCompare(b);
181264
const notNull = (el: string | undefined): boolean => el != null;
182265

183-
function getNewFiltersAndMapKeys<K extends object>(
266+
function getNewFiltersMapKeysAndMandatoryPopulate<K extends object>(
184267
cur: FilterQueryDataloader<K>,
185268
meta: EntityMetadata<K>,
186269
entityName: string,
187-
): Array<[FilterQueryDataloader<K>, string]>;
188-
function getNewFiltersAndMapKeys<K extends object>(
270+
): Array<[FilterQueryDataloader<K>, string, Set<string>?]>;
271+
function getNewFiltersMapKeysAndMandatoryPopulate<K extends object>(
189272
cur: FilterQueryDataloader<K>,
190273
meta: EntityMetadata<K>,
191-
): [FilterQueryDataloader<K>, string];
192-
function getNewFiltersAndMapKeys<K extends object>(
274+
): [FilterQueryDataloader<K>, string, string?];
275+
function getNewFiltersMapKeysAndMandatoryPopulate<K extends object>(
193276
cur: FilterQueryDataloader<K>,
194277
meta: EntityMetadata<K>,
195278
entityName?: string,
196-
): [FilterQueryDataloader<K>, string] | Array<[FilterQueryDataloader<K>, string]> {
279+
): [FilterQueryDataloader<K>, string, string?] | Array<[FilterQueryDataloader<K>, string, Set<string>?]> {
197280
const PKs = getPKs(cur, meta);
198281
if (PKs != null) {
199282
const res: [FilterQueryDataloader<K>, string] = [
@@ -206,6 +289,8 @@ function getNewFiltersAndMapKeys<K extends object>(
206289
} else {
207290
const newFilter: any = {};
208291
const keys: string[] = [];
292+
const populateSet = new Set<string>();
293+
let computedPopulate: string | undefined;
209294
if (Array.isArray(cur)) {
210295
// COMPOSITE PKs like [{owner: 1, recipient: 2}, {recipient: 4, owner: 3}]
211296
for (const key of meta.primaryKeys) {
@@ -223,27 +308,42 @@ function getNewFiltersAndMapKeys<K extends object>(
223308
// Using $or at the top level means that we can treat it as two separate queries and filter results from either of them
224309
if (key === "$or" && entityName != null) {
225310
return (value as Array<FilterQueryDataloader<K>>)
226-
.map((el) => getNewFiltersAndMapKeys(el, meta, entityName))
311+
.map((el) => getNewFiltersMapKeysAndMandatoryPopulate(el, meta, entityName))
227312
.flat();
228313
}
229314
const keyProp = meta.properties[key as EntityKey<K>];
230315
if (keyProp == null) {
231316
throw new Error(`Cannot find properties for ${key}`);
232317
}
233318
if (keyProp.targetMeta == null) {
319+
// Our current key might lead to a scalar (thus we don't need to populate anything)
320+
// or to an explicited PK like {id: 1, name: 'a'}
234321
newFilter[key] = Array.isArray(value) ? value : [value];
235322
keys.push(key);
236323
} else {
237-
const [subFilter, subKey] = getNewFiltersAndMapKeys(value, keyProp.targetMeta);
324+
// Our current key points to either a Reference or a Collection
325+
const [subFilter, subKey, furtherPop] = getNewFiltersMapKeysAndMandatoryPopulate(value, keyProp.targetMeta);
238326
newFilter[key] = subFilter;
239327
keys.push(`${key}:${subKey}`);
328+
// We need to populate all Collections and all the References
329+
// where we further match non-PKs properties
330+
if (keyProp.ref !== true || !isPK(value, keyProp.targetMeta)) {
331+
computedPopulate = furtherPop == null ? `${key}` : `${key}.${furtherPop}`;
332+
if (entityName != null) {
333+
populateSet.add(computedPopulate);
334+
} else {
335+
// We return computedPopulate as the third element of the array
336+
}
337+
}
240338
}
241339
}
242340
const res: [FilterQueryDataloader<K>, string] = [
243341
newFilter,
244342
[entityName, `{${keys.sort(asc).join(",")}}`].filter(notNull).join("|"),
245343
];
246-
return entityName == null ? res : [res];
344+
return entityName == null
345+
? [...res, computedPopulate]
346+
: [[...res, populateSet.size === 0 ? undefined : populateSet]];
247347
}
248348
}
249349
}
@@ -259,7 +359,7 @@ function updateQueryFilter<K extends object, P extends string = never>(
259359
newQueryMap?: boolean,
260360
): void {
261361
if (options?.populate != null && accOptions != null && accOptions.populate !== true) {
262-
if (Array.isArray(options.populate) && options.populate[0] === "*") {
362+
if (Array.isArray(options.populate) && options.populate.includes("*")) {
263363
accOptions.populate = true;
264364
} else if (Array.isArray(options.populate)) {
265365
if (accOptions.populate == null) {
@@ -276,14 +376,54 @@ function updateQueryFilter<K extends object, P extends string = never>(
276376
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
277377
const curValue = (cur as Record<string, any[]>)[key]!;
278378
if (Array.isArray(value)) {
279-
value.push(...curValue.reduce<any[]>((acc, cur) => acc.concat(cur), []));
379+
// value.push(...curValue.reduce<any[]>((acc, cur) => acc.concat(cur), []));
380+
value.push(...structuredClone(curValue));
280381
} else {
281382
updateQueryFilter([value], curValue);
282383
}
283384
}
284385
}
285386
}
286387

388+
// The least amount of populate necessary to map the dataloader results to their original queries
389+
export function getMandatoryPopulate<K extends object>(
390+
cur: FilterQueryDataloader<K>,
391+
meta: EntityMetadata<K>,
392+
): string | undefined;
393+
export function getMandatoryPopulate<K extends object>(
394+
cur: FilterQueryDataloader<K>,
395+
meta: EntityMetadata<K>,
396+
populate: Set<any>,
397+
): void;
398+
export function getMandatoryPopulate<K extends object>(
399+
cur: FilterQueryDataloader<K>,
400+
meta: EntityMetadata<K>,
401+
populate?: Set<any>,
402+
): any {
403+
for (const [key, value] of Object.entries(cur)) {
404+
const keyProp = meta.properties[key as EntityKey<K>];
405+
if (keyProp == null) {
406+
throw new Error(`Cannot find properties for ${key}`);
407+
}
408+
// If our current key leads to scalar we don't need to populate anything
409+
if (keyProp.targetMeta != null) {
410+
// Our current key points to either a Reference or a Collection
411+
// We need to populate all Collections
412+
// We also need to populate References whenever we have to further match non-PKs properties
413+
const PKs = getPKs(value, keyProp.targetMeta);
414+
if (keyProp.ref !== true || PKs == null) {
415+
const furtherPopulate = getMandatoryPopulate(value, keyProp.targetMeta);
416+
const computedPopulate = furtherPopulate == null ? `${key}` : `${key}.${furtherPopulate}`;
417+
if (populate != null) {
418+
populate.add(computedPopulate);
419+
} else {
420+
return computedPopulate;
421+
}
422+
}
423+
}
424+
}
425+
}
426+
287427
export interface DataloaderFind<K extends object, Hint extends string = never, Fields extends string = never> {
288428
entityName: string;
289429
meta: EntityMetadata<K>;
@@ -299,13 +439,13 @@ export function groupFindQueriesByOpts(
299439
const queriesMap = new Map<string, [FilterQueryDataloader<any>, { populate?: true | Set<any> }?]>();
300440
for (const dataloaderFind of dataloaderFinds) {
301441
const { entityName, meta, filter, options } = dataloaderFind;
302-
const filtersAndKeys = getNewFiltersAndMapKeys(filter, meta, entityName);
442+
const filtersAndKeys = getNewFiltersMapKeysAndMandatoryPopulate(filter, meta, entityName);
303443
dataloaderFind.filtersAndKeys = [];
304-
filtersAndKeys.forEach(([newFilter, key]) => {
444+
filtersAndKeys.forEach(([newFilter, key, mandatoryPopulate]) => {
305445
dataloaderFind.filtersAndKeys?.push({ key, newFilter });
306446
let queryMap = queriesMap.get(key);
307447
if (queryMap == null) {
308-
queryMap = [structuredClone(newFilter), {}];
448+
queryMap = [structuredClone(newFilter), { ...(mandatoryPopulate != null && { populate: mandatoryPopulate }) }];
309449
updateQueryFilter(queryMap, newFilter, options, true);
310450
queriesMap.set(key, queryMap);
311451
} else {
@@ -382,6 +522,9 @@ export function optsMapToQueries<Entity extends object>(
382522
populate: options.populate === true ? ["*"] : Array.from(options.populate),
383523
}),
384524
} satisfies Pick<FindOptions<any, any>, "populate">;
525+
console.log("entityName", entityName);
526+
console.log("filter", filter);
527+
console.log("findOptions", findOptions);
385528
const entities = await em.find(entityName, filter, findOptions);
386529
return [key, entities];
387530
});

0 commit comments

Comments
 (0)