Skip to content

Commit

Permalink
feat(core): automatically infer populate hint based on fields
Browse files Browse the repository at this point in the history
When only `FindOptions.fields` are provided, we now use those to infer the `populate` hint.

Closes #2468
  • Loading branch information
B4nan committed Dec 19, 2021
1 parent 43d550a commit 0097539
Show file tree
Hide file tree
Showing 4 changed files with 49 additions and 27 deletions.
47 changes: 32 additions & 15 deletions packages/core/src/EntityManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { QueryHelper, TransactionContext, Utils } from './utils';
import type { AssignOptions, EntityLoaderOptions, EntityRepository, IdentifiedReference } from './entity';
import { EntityAssigner, EntityFactory, EntityLoader, EntityValidator, Reference } from './entity';
import { UnitOfWork } from './unit-of-work';
import type { CountOptions, DeleteOptions, EntityManagerType, FindOneOptions, FindOneOrFailOptions, FindOptions, IDatabaseDriver, InsertOptions, LockOptions, UpdateOptions, GetReferenceOptions } from './drivers';
import type { CountOptions, DeleteOptions, EntityManagerType, FindOneOptions, FindOneOrFailOptions, FindOptions, IDatabaseDriver, InsertOptions, LockOptions, UpdateOptions, GetReferenceOptions, EntityField } from './drivers';
import type { AnyEntity, AutoPath, Dictionary, EntityData, EntityDictionary, EntityDTO, EntityMetadata, EntityName, FilterDef, FilterQuery, GetRepository, Loaded, New, Populate, PopulateOptions, Primary } from './typings';
import { FlushMode, LoadStrategy, LockMode, ReferenceType, SCALAR_TYPES } from './enums';
import type { TransactionOptions } from './enums';
Expand Down Expand Up @@ -108,7 +108,7 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
where = await this.processWhere<T, P>(entityName, where, options, 'read');
this.validator.validateParams(where);
options.orderBy = options.orderBy || {};
options.populate = this.preparePopulate<T>(entityName, options.populate, options.strategy) as unknown as Populate<T, P>;
options.populate = this.preparePopulate<T, P>(entityName, options) as unknown as Populate<T, P>;
const populate = options.populate as unknown as PopulateOptions<T>[];
const cached = await this.tryCache<T, Loaded<T, P>[]>(entityName, options.cache, [entityName, 'em.find', options, where], options.refresh, true);

Expand Down Expand Up @@ -307,7 +307,7 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
}

this.validator.validateParams(where);
options.populate = this.preparePopulate<T>(entityName, options.populate as true, options.strategy) as unknown as Populate<T, P>;
options.populate = this.preparePopulate<T, P>(entityName, options) as unknown as Populate<T, P>;
const cached = await this.tryCache<T, Loaded<T, P>>(entityName, options.cache, [entityName, 'em.findOne', options, where], options.refresh, true);

if (cached?.data) {
Expand Down Expand Up @@ -581,7 +581,7 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
async count<T, P extends string = never>(entityName: EntityName<T>, where: FilterQuery<T> = {} as FilterQuery<T>, options: CountOptions<T, P> = {}): Promise<number> {
entityName = Utils.className(entityName);
where = await this.processWhere<T, P>(entityName, where, options as FindOptions<T, P>, 'read');
options.populate = this.preparePopulate(entityName, options.populate) as unknown as Populate<T>;
options.populate = this.preparePopulate(entityName, options) as unknown as Populate<T>;
this.validator.validateParams(where);

const cached = await this.tryCache<T, number>(entityName, options.cache, [entityName, 'em.count', options, where]);
Expand Down Expand Up @@ -725,7 +725,7 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
entityName = Utils.className(entityName);
const [p, ...parts] = property.split('.');
const props = this.metadata.get(entityName).properties;
const ret = p in props && (props[p].reference !== ReferenceType.SCALAR || props[p].lazy);
const ret = p in props;

if (!ret) {
return !!this.metadata.find(property)?.pivotTable;
Expand All @@ -749,7 +749,7 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
}

const entityName = entities[0].constructor.name;
const preparedPopulate = this.preparePopulate<T>(entityName, populate as true);
const preparedPopulate = this.preparePopulate<T>(entityName, { populate: populate as true });
await this.entityLoader.populate(entityName, entities, preparedPopulate, options);

return entities as Loaded<T, P>[];
Expand Down Expand Up @@ -883,28 +883,45 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
});
}

const preparedPopulate = this.preparePopulate<T>(entityName, options.populate, options.strategy);
const preparedPopulate = this.preparePopulate<T, P>(entityName, options);
await this.entityLoader.populate(entityName, [entity], preparedPopulate, { ...options as Dictionary, where, convertCustomTypes: false, ignoreLazyScalarProperties: true, lookup: false });

return entity as Loaded<T, P>;
}

private preparePopulate<T extends AnyEntity<T>>(entityName: string, populate?: Populate<T, any>, strategy?: LoadStrategy): PopulateOptions<T>[] {
if (!populate) {
return this.entityLoader.normalizePopulate<T>(entityName, [], strategy);
private buildFields<T, P extends string>(fields: readonly EntityField<T, P>[] = []): readonly AutoPath<T, P>[] {
return fields.reduce((ret, f) => {
if (Utils.isPlainObject(f)) {
Object.keys(f).forEach(ff => ret.push(...this.buildFields(f[ff]).map(field => `${ff}.${field}` as never)));
} else {
ret.push(f as never);
}

return ret;
}, [] as AutoPath<T, P>[]);
}

private preparePopulate<T extends AnyEntity<T>, P extends string = never>(entityName: string, options: Pick<FindOptions<T, P>, 'populate' | 'strategy' | 'fields'>): PopulateOptions<T>[] {
// infer populate hint if only `fields` are available
if (!options.populate && options.fields) {
options.populate = this.buildFields(options.fields);
}

if (!options.populate) {
return this.entityLoader.normalizePopulate<T>(entityName, [], options.strategy);
}

if (Array.isArray(populate)) {
populate = (populate as string[]).map(field => {
if (Array.isArray(options.populate)) {
options.populate = (options.populate as string[]).map(field => {
if (Utils.isString(field)) {
return { field, strategy };
return { field, strategy: options.strategy };
}

return field;
}) as unknown as Populate<T>;
}

const ret: PopulateOptions<T>[] = this.entityLoader.normalizePopulate<T>(entityName, populate as true, strategy);
const ret: PopulateOptions<T>[] = this.entityLoader.normalizePopulate<T>(entityName, options.populate as true, options.strategy);
const invalid = ret.find(({ field }) => !this.canPopulate(entityName, field));

if (invalid) {
Expand All @@ -913,7 +930,7 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {

return ret.map(field => {
// force select-in strategy when populating all relations as otherwise we could cause infinite loops when self-referencing
field.strategy = populate === true ? LoadStrategy.SELECT_IN : (strategy ?? field.strategy ?? this.config.get('loadStrategy'));
field.strategy = options.populate === true ? LoadStrategy.SELECT_IN : (options.strategy ?? field.strategy ?? this.config.get('loadStrategy'));
return field;
});
}
Expand Down
19 changes: 12 additions & 7 deletions packages/core/src/entity/EntityLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ export class EntityLoader {

const ids = Utils.unique(children.map(e => Utils.getPrimaryKeyValues(e, e.__meta!.primaryKeys, true)));
const where = this.mergePrimaryCondition<T>(ids, fk, options, meta, this.metadata, this.driver.getPlatform());
const fields = this.buildFields<T>(prop, options);
const fields = this.buildFields(options.fields, prop);
const { refresh, filters, convertCustomTypes, lockMode, strategy } = options;

return this.em.find<T>(prop.type, where as FilterQuery<T>, {
Expand All @@ -266,12 +266,17 @@ export class EntityLoader {
}

private async populateField<T extends AnyEntity<T>>(entityName: string, entities: T[], populate: PopulateOptions<T>, options: Required<EntityLoaderOptions<T>>): Promise<void> {
const prop = this.metadata.find(entityName)!.properties[populate.field] as EntityProperty<T>;

if (prop.reference === ReferenceType.SCALAR && !prop.lazy) {
return;
}

if (!populate.children) {
return void await this.populateMany<T>(entityName, entities, populate, options);
}

await this.populateMany<T>(entityName, entities, populate, options);
const prop = this.metadata.find(entityName)!.properties[populate.field];
const children: T[] = [];

for (const entity of entities) {
Expand All @@ -287,15 +292,15 @@ export class EntityLoader {
}

const filtered = Utils.unique(children);
const fields = this.buildFields<T>(prop, options);
const fields = this.buildFields(options.fields, prop);
const innerOrderBy = Utils.asArray(options.orderBy)
.filter(orderBy => Utils.isObject(orderBy[prop.name]))
.map(orderBy => orderBy[prop.name]);
const { refresh, filters, ignoreLazyScalarProperties } = options;

await this.populate<T>(prop.type, filtered, populate.children, {
where: await this.extractChildCondition(options, prop, false) as FilterQuery<T>,
orderBy: innerOrderBy,
orderBy: innerOrderBy as QueryOrderMap<T>[],
fields: fields.length > 0 ? fields : undefined,
validate: false,
lookup: false,
Expand All @@ -309,7 +314,7 @@ export class EntityLoader {
const ids = filtered.map((e: AnyEntity<T>) => e.__helper!.__primaryKeys);
const refresh = options.refresh;
const where = await this.extractChildCondition(options, prop, true);
const fields = this.buildFields(prop, options);
const fields = this.buildFields(options.fields, prop);
const options2 = { ...options } as FindOptions<T>;
delete options2.limit;
delete options2.offset;
Expand Down Expand Up @@ -376,8 +381,8 @@ export class EntityLoader {
return subCond;
}

private buildFields<T>(prop: EntityProperty<T>, options: Required<EntityLoaderOptions<T>>): EntityField<T>[] {
return (options.fields || []).reduce((ret, f) => {
private buildFields<T, P extends string>(fields: readonly EntityField<T, P>[] = [], prop: EntityProperty<T>): readonly EntityField<T>[] {
return fields.reduce((ret, f) => {
if (Utils.isPlainObject(f)) {
Object.keys(f)
.filter(ff => ff === prop.name)
Expand Down
2 changes: 1 addition & 1 deletion tests/EntityManager.mongo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1315,7 +1315,7 @@ describe('EntityManagerMongo', () => {
test('canPopulate', async () => {
const repo = orm.em.getRepository(Author);
expect(repo.canPopulate('test')).toBe(false);
expect(repo.canPopulate('name')).toBe(false);
expect(repo.canPopulate('name')).toBe(true);
expect(repo.canPopulate('favouriteBook.author')).toBe(true);
expect(repo.canPopulate('books')).toBe(true);
});
Expand Down
8 changes: 4 additions & 4 deletions tests/features/partial-loading/partial-loading.mysql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe('partial loading (mysql)', () => {

const mock = mockLogger(orm, ['query']);

const r1 = await orm.em.find(Author2, god, { fields: ['id', 'books.author', 'books.title'], populate: ['books'] });
const r1 = await orm.em.find(Author2, god, { fields: ['id', 'books.author', 'books.title'] });
expect(r1).toHaveLength(1);
expect(r1[0].id).toBe(god.id);
expect(r1[0].name).toBeUndefined();
Expand All @@ -50,7 +50,7 @@ describe('partial loading (mysql)', () => {
orm.em.clear();
mock.mock.calls.length = 0;

const r2 = await orm.em.find(Author2, god, { fields: ['id', { books: ['uuid', 'author', 'title'] }], populate: ['books'] });
const r2 = await orm.em.find(Author2, god, { fields: ['id', { books: ['uuid', 'author', 'title'] }] });
expect(r2).toHaveLength(1);
expect(r2[0].id).toBe(god.id);
expect(r2[0].name).toBeUndefined();
Expand Down Expand Up @@ -118,7 +118,7 @@ describe('partial loading (mysql)', () => {

const mock = mockLogger(orm, ['query']);

const r1 = await orm.em.find(BookTag2, {}, { fields: ['name', 'books.title'], populate: ['books'], filters: false });
const r1 = await orm.em.find(BookTag2, {}, { fields: ['name', 'books.title'], filters: false });
expect(r1).toHaveLength(6);
expect(r1[0].name).toBe('t1');
expect(r1[0].books[0].title).toBe('Bible 1');
Expand All @@ -129,7 +129,7 @@ describe('partial loading (mysql)', () => {
orm.em.clear();
mock.mock.calls.length = 0;

const r2 = await orm.em.find(BookTag2, { name: 't1' }, { fields: ['name', { books: ['title'] }], populate: ['books'], filters: false });
const r2 = await orm.em.find(BookTag2, { name: 't1' }, { fields: ['name', { books: ['title'] }], filters: false });
expect(r2).toHaveLength(1);
expect(r2[0].name).toBe('t1');
expect(r2[0].books[0].title).toBe('Bible 1');
Expand Down

0 comments on commit 0097539

Please sign in to comment.