Skip to content

Commit

Permalink
perf(core): improve result mapping and snapshotting (#4053)
Browse files Browse the repository at this point in the history
Previously we had to snapshot the entity after it was loaded from
database and hydrated, because the data could be in a different format
(e.g. due to joined strategy). This is now improved and the snapshotting
is no longer needed.

This has a nice side effect, custom types "to db" methods are no longer
triggered after entity gets loaded, only the "from db" ones are.
  • Loading branch information
B4nan committed Feb 16, 2023
1 parent bc6d84f commit 8bb0268
Show file tree
Hide file tree
Showing 22 changed files with 269 additions and 257 deletions.
12 changes: 7 additions & 5 deletions packages/core/src/EntityManager.ts
Expand Up @@ -464,7 +464,6 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
ignoreLazyScalarProperties: true,
lookup: false,
});
em.unitOfWork.saveSnapshots();

return cached.data;
}
Expand Down Expand Up @@ -637,7 +636,9 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
em.entityFactory.mergeData(meta, entity, pk!);
}

em.unitOfWork.registerManaged(entity, data, { refresh: true });
// recompute the data as there might be some values missing (e.g. those with db column defaults)
const snapshot = this.comparator.prepareEntity(entity);
em.unitOfWork.registerManaged(entity, snapshot, { refresh: true });

return entity;
}
Expand Down Expand Up @@ -787,8 +788,10 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
}
}

for (const [entity, data] of entities) {
em.unitOfWork.registerManaged(entity, data, { refresh: true });
for (const [entity] of entities) {
// recompute the data as there might be some values missing (e.g. those with db column defaults)
const snapshot = this.comparator.prepareEntity(entity);
em.unitOfWork.registerManaged(entity, snapshot, { refresh: true });
}

return [...entities.keys()];
Expand Down Expand Up @@ -1034,7 +1037,6 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
entity = Utils.isEntity<Entity>(data) ? data : em.entityFactory.create<Entity>(entityName, data as EntityData<Entity>, { merge: true, ...options });
em.validator.validate(entity, data, childMeta ?? meta);
em.unitOfWork.merge(entity);
em.unitOfWork.saveSnapshots();

return entity!;
}
Expand Down
15 changes: 10 additions & 5 deletions packages/core/src/entity/EntityFactory.ts
Expand Up @@ -152,21 +152,26 @@ export class EntityFactory {
const schema = this.driver.getSchemaName(meta, options);

if (Array.isArray(id)) {
// composite FK as PK needs to be wrapped for `getPrimaryKeyCondFromArray` to work correctly
if (!meta.compositePK && meta.getPrimaryProps()[0].reference !== ReferenceType.SCALAR) {
id = [id] as Primary<T>[];
}

id = Utils.getPrimaryKeyCondFromArray(id, meta);
}

const pks = Utils.getOrderedPrimaryKeys<T>(id, meta, this.platform, options.convertCustomTypes);

if (Utils.isPrimaryKey(id)) {
id = { [meta.primaryKeys[0]]: id as Primary<T> };
}

const exists = this.unitOfWork.getById<T>(entityName, pks, schema);
const exists = this.unitOfWork.getById<T>(entityName, pks as Primary<T>, schema);

if (exists) {
return exists;
}

if (Utils.isPrimaryKey(id)) {
id = { [meta.primaryKeys[0]]: id as Primary<T> };
}

return this.create<T>(entityName, id as EntityData<T>, { ...options, initialized: false }) as T;
}

Expand Down
4 changes: 1 addition & 3 deletions packages/core/src/entity/EntityLoader.ts
Expand Up @@ -265,9 +265,7 @@ export class EntityLoader {
schema = children.find(e => e.__helper!.__schema)?.__helper!.__schema;
}

const ids = Utils.unique(children.map(e => {
return Utils.getPrimaryKeyValues(Reference.unwrapReference(e), e.__meta!.primaryKeys, true);
}));
const ids = Utils.unique(children.map(e => e.__helper.getPrimaryKey()));
const where = this.mergePrimaryCondition<T>(ids, fk, options, meta, this.metadata, this.driver.getPlatform());
const fields = this.buildFields(options.fields, prop);
const { refresh, filters, convertCustomTypes, lockMode, strategy, populateWhere, connectionType } = options;
Expand Down
57 changes: 20 additions & 37 deletions packages/core/src/unit-of-work/UnitOfWork.ts
@@ -1,5 +1,14 @@
import { AsyncLocalStorage } from 'async_hooks';
import type { AnyEntity, Dictionary, EntityData, EntityMetadata, EntityProperty, FilterQuery, IPrimaryKeyValue, Primary } from '../typings';
import type {
AnyEntity,
Dictionary,
EntityData,
EntityMetadata,
EntityProperty,
FilterQuery,
IPrimaryKeyValue,
Primary,
} from '../typings';
import { Collection, EntityIdentifier, helper, Reference } from '../entity';
import { ChangeSet, ChangeSetType } from './ChangeSet';
import { ChangeSetComputer } from './ChangeSetComputer';
Expand Down Expand Up @@ -37,7 +46,6 @@ export class UnitOfWork {
private readonly queuedActions = new Set<string>();
private readonly loadedEntities = new Set<AnyEntity>();
private readonly flushQueue: (() => Promise<void>)[] = [];
private readonly snapshotQueue: (() => void)[] = [];
private working = false;
private insideHooks = false;

Expand All @@ -63,24 +71,12 @@ export class UnitOfWork {
// as there can be some entity with already changed state that is not yet flushed
if (wrapped.__initialized && (!visited || !wrapped.__originalEntityData)) {
wrapped.__originalEntityData = this.comparator.prepareEntity(entity);
this.queuedActions.delete(wrapped.__meta.className);
wrapped.__touched = false;
}

this.cascade(entity, Cascade.MERGE, visited ?? new Set<AnyEntity>());
}

/**
* @internal
*/
saveSnapshots() {
for (const callback of this.snapshotQueue) {
callback();
}

this.snapshotQueue.length = 0;
}

/**
* @internal
*/
Expand All @@ -91,31 +87,25 @@ export class UnitOfWork {
return entity;
}

if (options?.loaded && helper(entity).__initialized && !helper(entity).__onLoadFired) {
const wrapped = helper(entity);

if (options?.loaded && wrapped.__initialized && !wrapped.__onLoadFired) {
this.loadedEntities.add(entity as AnyEntity);
}

const wrapped = helper(entity);
wrapped.__em ??= this.em;
wrapped.__managed = true;

if (data && (options?.refresh || !wrapped.__originalEntityData)) {
Object.keys(data).forEach(key => helper(entity).__loadedProperties.add(key));
this.queuedActions.delete(wrapped.__meta.className);
Object.keys(data).forEach(key => wrapped.__loadedProperties.add(key));

// assign the data early, delay recompute via snapshot queue unless we refresh the state
if (options?.refresh) {
wrapped.__originalEntityData = this.comparator.prepareEntity(entity);
} else {
wrapped.__originalEntityData = data;
}

wrapped.__touched = false;
this.snapshotQueue.push(() => {
// we can't use the `data` directly here as it can contain fetch joined data, that can't be used for diffing the state
wrapped.__originalEntityData = this.comparator.prepareEntity(entity);
wrapped.__touched = false;
wrapped.__meta.relations.forEach(prop => {
if (Utils.isPlainObject(data[prop.name as string])) {
data[prop.name as string] = Utils.getPrimaryKeyValues(data[prop.name as string], wrapped.__meta.primaryKeys, true);
}
});
wrapped.__originalEntityData = data;
wrapped.__touched = false;
}

return entity;
Expand All @@ -125,8 +115,6 @@ export class UnitOfWork {
* @internal
*/
async dispatchOnLoadEvent(): Promise<void> {
this.saveSnapshots();

for (const entity of this.loadedEntities) {
if (this.eventManager.hasListeners(EventType.onLoad, entity.__meta!)) {
await this.eventManager.dispatchEvent(EventType.onLoad, { entity, em: this.em });
Expand Down Expand Up @@ -248,7 +236,6 @@ export class UnitOfWork {
this.initIdentifier(entity);
this.changeSets.set(entity, cs);
this.persistStack.delete(entity);
this.queuedActions.delete(cs.name);
helper(entity).__originalEntityData = this.comparator.prepareEntity(entity);
helper(entity).__touched = false;
}
Expand Down Expand Up @@ -863,8 +850,6 @@ export class UnitOfWork {
for (const changeSet of this.changeSets.values()) {
this.takeCollectionSnapshots(changeSet.entity, visited);
}

this.saveSnapshots();
}

private async commitCreateChangeSets<T extends object>(changeSets: ChangeSet<T>[], ctx?: Transaction): Promise<void> {
Expand All @@ -887,8 +872,6 @@ export class UnitOfWork {
this.registerManaged<T>(changeSet.entity, changeSet.payload, { refresh: true });
await this.runHooks(EventType.afterCreate, changeSet);
}

this.saveSnapshots();
}

private findExtraUpdates<T extends object>(changeSet: ChangeSet<T>, props: EntityProperty<T>[]): void {
Expand Down
43 changes: 29 additions & 14 deletions packages/core/src/utils/EntityComparator.ts
Expand Up @@ -272,6 +272,10 @@ export class EntityComparator {
idx += pk.fieldNames.length;
}

if (parts.length < 2) {
return parts[0];
}

return '[' + parts.join(', ') + ']';
};

Expand All @@ -292,6 +296,16 @@ export class EntityComparator {

if (prop.type === 'boolean') {
lines.push(` if (typeof ${propName(prop.fieldNames[0])} !== 'undefined') { ret${this.wrap(prop.name)} = ${propName(prop.fieldNames[0])} == null ? ${propName(prop.fieldNames[0])} : !!${propName(prop.fieldNames[0])}; ${propName(prop.fieldNames[0], 'mapped')} = true; }`);
} else if (prop.type.toLowerCase() === 'date') {
if (prop.embedded && meta.properties[prop.embedded[0]].object) {
const entityKey = 'ret.' + prop.fieldNames[0];
const entityKeyOptional = entityKey.replace(/\./g, '?.');
lines.push(` if (typeof ${entityKeyOptional} !== 'undefined') { ${entityKey} = ${entityKey} == null ? ${entityKey} : new Date(${entityKey}); }`);
} else {
lines.push(` if (typeof ${propName(prop.fieldNames[0])} !== 'undefined') { ret${this.wrap(prop.name)} = new Date(${propName(prop.fieldNames[0])}); ${propName(prop.fieldNames[0], 'mapped')} = true; }`);
}
} else if (prop.reference === ReferenceType.EMBEDDED && prop.object && !this.platform.convertsJsonAutomatically()) {
lines.push(` if (typeof ${propName(prop.fieldNames[0])} !== 'undefined') { ret${this.wrap(prop.name)} = ${propName(prop.fieldNames[0])} == null ? ${propName(prop.fieldNames[0])} : JSON.parse(${propName(prop.fieldNames[0])}); ${propName(prop.fieldNames[0], 'mapped')} = true; }`);
} else {
lines.push(` if (typeof ${propName(prop.fieldNames[0])} !== 'undefined') { ret${this.wrap(prop.name)} = ${propName(prop.fieldNames[0])}; ${propName(prop.fieldNames[0], 'mapped')} = true; }`);
}
Expand All @@ -314,7 +328,7 @@ export class EntityComparator {
}

let tail = '';
let ret = parts
const ret = parts
.map(k => {
if (k.match(/^\[idx_\d+]$/)) {
tail += k;
Expand All @@ -328,16 +342,6 @@ export class EntityComparator {
})
.filter(k => k)
.join(' && ');
const isRef = [ReferenceType.ONE_TO_ONE, ReferenceType.MANY_TO_ONE].includes(prop.reference) && !prop.mapToPk;
const isSetter = isRef && !!(prop.inversedBy || prop.mappedBy);

if (prop.primary || isSetter) {
ret += ` && entity${entityKey} != null`;
}

if (isRef) {
ret += ` && (entity${entityKey} == null || entity${entityKey}.__helper?.hasPrimaryKey())`;
}

return ret;
}
Expand Down Expand Up @@ -464,9 +468,20 @@ export class EntityComparator {
ret += ` ret${dataKey} = entity${entityKey};\n`;
}
} else {
const meta2 = this.metadata.find(prop.type);
context.set(`getPrimaryKeyValues_${convertorKey}`, (val: any) => val && Utils.getPrimaryKeyValues(val, meta2!.primaryKeys, true, true));
ret += ` ret${dataKey} = getPrimaryKeyValues_${convertorKey}(entity${entityKey});\n`;
const toArray = (val: unknown): unknown => {
if (Utils.isPlainObject(val)) {
return Object.values(val).map(v => toArray(v));
}

return val;
};

context.set('toArray', toArray);
ret += ` if (entity${entityKey} === null) {\n`;
ret += ` ret${dataKey} = null;\n`;
ret += ` } else if (typeof entity${entityKey} !== 'undefined') {\n`;
ret += ` ret${dataKey} = toArray(entity${entityKey}.__helper.getPrimaryKey(true));\n`;
ret += ` }\n`;
}

return ret + ' }\n';
Expand Down
2 changes: 1 addition & 1 deletion packages/knex/src/AbstractSqlDriver.ts
Expand Up @@ -340,7 +340,7 @@ export abstract class AbstractSqlDriver<Connection extends AbstractSqlConnection
props.forEach(prop => {
if (prop.fieldNames.length > 1) {
params.push(...(row[prop.name] as unknown[] ?? prop.fieldNames.map(() => null)));
keys.push(...prop.fieldNames.map(_ => '?'));
keys.push(...(row[prop.name] as unknown[] ?? prop.fieldNames).map(() => '?'));
} else if (prop.customType && 'convertToDatabaseValueSQL' in prop.customType && !this.platform.isRaw(row[prop.name])) {
keys.push(prop.customType.convertToDatabaseValueSQL!('?', this.platform));
params.push(row[prop.name]);
Expand Down
1 change: 0 additions & 1 deletion packages/knex/src/query/QueryBuilder.ts
Expand Up @@ -911,7 +911,6 @@ export class QueryBuilder<T extends object = AnyEntity> {

if (data) {
if (Utils.isEntity(data)) {
// data = helper(data).toJSON();
data = this.em?.getComparator().prepareEntity(data as T) ?? serialize(data as T);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/knex/src/query/QueryBuilderHelper.ts
Expand Up @@ -778,7 +778,7 @@ export class QueryBuilderHelper {
}

if (prop.joinColumns && Array.isArray(data[k])) {
const copy = data[k];
const copy = Utils.flatten(data[k]);
delete data[k];
prop.joinColumns.forEach((joinColumn, idx) => data[joinColumn] = copy[idx]);

Expand Down
4 changes: 2 additions & 2 deletions tests/EntityFactory.test.ts
Expand Up @@ -220,7 +220,7 @@ describe('EntityFactory', () => {
expect(mock.mock.calls).toHaveLength(3);
expect(mock.mock.calls[0][0]).toMatch(/db\.getCollection\('book-tag'\)\.insertMany\(\[ { name: 't1' } ], {}\);/);
expect(mock.mock.calls[1][0]).toMatch(/db\.getCollection\('author'\)\.insertMany\(\[ { createdAt: ISODate\('.*'\), updatedAt: ISODate\('.*'\), foo: 'bar', name: 'Jon', email: 'jon@snow\.com', termsAccepted: false } ], {}\);/);
expect(mock.mock.calls[2][0]).toMatch(/db\.getCollection\('books-table'\)\.insertMany\(\[ { createdAt: ISODate\('.*'\), title: 'B1', publisher: ObjectId\('5b0d19b28b21c648c2c8a600'\), author: ObjectId\('.*'\), tags: \[ ObjectId\('.*'\), ObjectId\('5b0d19b28b21c648c2c8a601'\) ] } ], {}\);/);
expect(mock.mock.calls[2][0]).toMatch(/db\.getCollection\('books-table'\)\.insertMany\(\[ { createdAt: ISODate\('.*'\), title: 'B1', author: ObjectId\('.*'\), publisher: ObjectId\('5b0d19b28b21c648c2c8a600'\), tags: \[ ObjectId\('.*'\), ObjectId\('5b0d19b28b21c648c2c8a601'\) ] } ], {}\);/);

orm.em.clear();
mock.mock.calls.length = 0;
Expand Down Expand Up @@ -249,7 +249,7 @@ describe('EntityFactory', () => {
expect(mock.mock.calls.length).toBe(3);
expect(mock.mock.calls[0][0]).toMatch(/db\.getCollection\('book-tag'\)\.insertMany\(\[ { name: 't1' } ], {}\);/);
expect(mock.mock.calls[1][0]).toMatch(/db\.getCollection\('author'\)\.insertMany\(\[ { createdAt: ISODate\('.*'\), updatedAt: ISODate\('.*'\), foo: 'bar', name: 'Jon', email: 'jon2@snow\.com', termsAccepted: false } ], {}\);/);
expect(mock.mock.calls[2][0]).toMatch(/db\.getCollection\('books-table'\)\.insertMany\(\[ { createdAt: ISODate\('.*'\), title: 'B1', publisher: ObjectId\('5b0d19b28b21c648c2c8a600'\), author: ObjectId\('.*'\), tags: \[ ObjectId\('.*'\), ObjectId\('5b0d19b28b21c648c2c8a601'\) ] } ], {}\);/);
expect(mock.mock.calls[2][0]).toMatch(/db\.getCollection\('books-table'\)\.insertMany\(\[ { createdAt: ISODate\('.*'\), title: 'B1', author: ObjectId\('.*'\), publisher: ObjectId\('5b0d19b28b21c648c2c8a600'\), tags: \[ ObjectId\('.*'\), ObjectId\('5b0d19b28b21c648c2c8a601'\) ] } ], {}\);/);
});

test('em.create() should not mutate the input object (GH issue 1294)', async () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/EntityManager.postgre.test.ts
Expand Up @@ -2019,7 +2019,7 @@ describe('EntityManagerPostgre', () => {

expect(mock.mock.calls[0][0]).toMatch('begin');
expect(mock.mock.calls[1][0]).toMatch('insert into "author2" ("created_at", "updated_at", "name", "email", "terms_accepted") values ($1, $2, $3, $4, $5) returning "id", "created_at", "updated_at", "age", "terms_accepted"');
expect(mock.mock.calls[2][0]).toMatch('insert into "book2" ("uuid_pk", "created_at", "title", "price", "publisher_id", "author_id") values ($1, $2, $3, $4, $5, $6) returning "uuid_pk", "created_at", "title"');
expect(mock.mock.calls[2][0]).toMatch('insert into "book2" ("uuid_pk", "created_at", "title", "price", "author_id", "publisher_id") values ($1, $2, $3, $4, $5, $6) returning "uuid_pk", "created_at", "title"');
expect(mock.mock.calls[3][0]).toMatch('commit');
expect(mock.mock.calls[4][0]).toMatch('begin');
expect(mock.mock.calls[5][0]).toMatch('update "book2" set "publisher_id" = $1 where "uuid_pk" = $2');
Expand Down
2 changes: 1 addition & 1 deletion tests/features/embeddables/GH2391.test.ts
Expand Up @@ -67,7 +67,7 @@ describe('onCreate and onUpdate in embeddables (GH 2283 and 2391)', () => {
});

test(`GH issue 2283, 2391`, async () => {
let line = orm.em.create(MyEntity, {});
let line = orm.em.create(MyEntity, {}, { persist: false });
await orm.em.fork().persistAndFlush(line);

expect(!!line.audit1.created).toBeTruthy();
Expand Down
Expand Up @@ -20,7 +20,7 @@ drop table if exists "parent" cascade;
exports[`embedded entities with custom types snapshot generator 1`] = `
"function(entity) {
const ret = {};
if (typeof entity.id !== 'undefined' && entity.id != null) {
if (typeof entity.id !== 'undefined') {
ret.id = entity.id;
}
Expand Down

1 comment on commit 8bb0268

@rvion
Copy link

@rvion rvion commented on 8bb0268 Feb 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥thanks !

Please sign in to comment.