Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): implement auto-flush mode #2491

Merged
merged 12 commits into from
Dec 11, 2021
34 changes: 34 additions & 0 deletions docs/docs/unit-of-work.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,5 +100,39 @@ await em.persistAndFlush(user);
You can find more information about transactions in [Transactions and concurrency](transactions.md)
page.

## Flush Modes

The flushing strategy is given by the `flushMode` of the current running `EntityManager`.

- `FlushMode.COMMIT` - The `EntityManager` tries to delay the flush until the current Transaction is committed, although it might flush prematurely too.
- `FlushMode.AUTO` - This is the default mode, and it flushes the `EntityManager` only if necessary.
- `FlushMode.ALWAYS` - Flushes the `EntityManager` before every query.

`FlushMode.AUTO` will try to detect changes on the entity we are querying, and flush
if there is an overlap:

```ts
// querying for author will trigger auto-flush if we have new author persisted
const a1 = new Author(...);
orm.em.persist(a1);
const r1 = await orm.em.find(Author, {});

// querying author won't trigger auto-flush if we have new book, but no changes on author
const b4 = new Book(...);
orm.em.persist(b4);
const r2 = await orm.em.find(Author, {});

// but querying for book will trigger auto-flush
const r3 = await orm.em.find(Book, {});
```

We can set the flush mode on different places:

- in the ORM config via `Options.flushMode`
- for given `EntityManager` instance (and its forks) via `em.setFlushMode()`
- for given `EntityManager` fork via `em.fork({ flushMode })`
- for given QueryBuilder instance via `qb.setFlushMode()`
- for given transaction scope via `em.transactional(..., { flushMode })`

> This part of documentation is highly inspired by [doctrine internals docs](https://www.doctrine-project.org/projects/doctrine-orm/en/2.6/reference/unitofwork.html)
> as the behaviour here is pretty much the same.
51 changes: 40 additions & 11 deletions packages/core/src/EntityManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { EntityAssigner, EntityFactory, EntityLoader, EntityValidator, Reference
import { UnitOfWork } from './unit-of-work';
import type { CountOptions, DeleteOptions, EntityManagerType, FindOneOptions, FindOneOrFailOptions, FindOptions, IDatabaseDriver, InsertOptions, LockOptions, UpdateOptions, GetReferenceOptions } from './drivers';
import type { AnyEntity, AutoPath, Dictionary, EntityData, EntityDictionary, EntityDTO, EntityMetadata, EntityName, FilterDef, FilterQuery, GetRepository, Loaded, New, Populate, PopulateOptions, Primary } from './typings';
import type { IsolationLevel } from './enums';
import { LoadStrategy, LockMode, ReferenceType, SCALAR_TYPES } from './enums';
import { FlushMode, LoadStrategy, LockMode, ReferenceType, SCALAR_TYPES } from './enums';
import type { TransactionOptions } from './enums';
import type { MetadataStorage } from './metadata';
import type { Transaction } from './connections';
import { EventManager, TransactionEventBroadcaster } from './events';
Expand All @@ -35,6 +35,7 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
private filters: Dictionary<FilterDef<any>> = {};
private filterParams: Dictionary<Dictionary> = {};
private transactionContext?: Transaction;
private flushMode?: FlushMode;

/**
* @internal
Expand Down Expand Up @@ -93,7 +94,9 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
* Finds all entities matching your `where` query. You can pass additional options via the `options` parameter.
*/
async find<T extends AnyEntity<T>, P extends string = never>(entityName: EntityName<T>, where: FilterQuery<T>, options: FindOptions<T, P> = {}): Promise<Loaded<T, P>[]> {
if (options.disableIdentityMap) {
if (!options.disableIdentityMap) {
await this.tryFlush(entityName, options);
} else {
const fork = this.fork({ clear: false });
const ret = await fork.find<T, P>(entityName, where, { ...options, disableIdentityMap: false });
fork.clear();
Expand Down Expand Up @@ -179,6 +182,10 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
return this.getContext().filterParams[name] as T;
}

setFlushMode(flushMode?: FlushMode): void {
this.flushMode = flushMode;
}

protected async processWhere<T extends AnyEntity<T>, P extends string = never>(entityName: string, where: FilterQuery<T>, options: FindOptions<T, P> | FindOneOptions<T, P>, type: 'read' | 'update' | 'delete'): Promise<FilterQuery<T>> {
where = QueryHelper.processWhere(where as FilterQuery<T>, entityName, this.metadata, this.driver.getPlatform(), options.convertCustomTypes);
where = await this.applyFilters(entityName, where, options.filters ?? {}, type);
Expand Down Expand Up @@ -277,7 +284,9 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
* Finds first entity matching your `where` query.
*/
async findOne<T extends AnyEntity<T>, P extends string = never>(entityName: EntityName<T>, where: FilterQuery<T>, options: FindOneOptions<T, P> = {}): Promise<Loaded<T, P> | null> {
if (options.disableIdentityMap) {
if (!options.disableIdentityMap) {
await this.tryFlush(entityName, options);
} else {
const fork = this.fork({ clear: false });
const ret = await fork.findOne<T, P>(entityName, where, { ...options, disableIdentityMap: false });
fork.clear();
Expand Down Expand Up @@ -341,8 +350,8 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
/**
* Runs your callback wrapped inside a database transaction.
*/
async transactional<T>(cb: (em: D[typeof EntityManagerType]) => Promise<T>, options: { ctx?: Transaction; isolationLevel?: IsolationLevel } = {}): Promise<T> {
const em = this.fork({ clear: false });
async transactional<T>(cb: (em: D[typeof EntityManagerType]) => Promise<T>, options: TransactionOptions = {}): Promise<T> {
const em = this.fork({ clear: false, flushMode: options.flushMode });
options.ctx ??= this.transactionContext;

return TransactionContext.createAsync(em, async () => {
Expand All @@ -359,7 +368,7 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
/**
* Starts new transaction bound to this EntityManager. Use `ctx` parameter to provide the parent when nesting transactions.
*/
async begin(options: { ctx?: Transaction; isolationLevel?: IsolationLevel } = {}): Promise<void> {
async begin(options: TransactionOptions = {}): Promise<void> {
this.transactionContext = await this.getConnection('write').begin({ ...options, eventBroadcaster: new TransactionEventBroadcaster(this) });
}

Expand All @@ -378,6 +387,7 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
async rollback(): Promise<void> {
await this.getConnection('write').rollback(this.transactionContext, new TransactionEventBroadcaster(this));
delete this.transactionContext;
this.getUnitOfWork().clearActionsQueue();
}

/**
Expand Down Expand Up @@ -465,19 +475,19 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {

/**
* Merges given entity to this EntityManager so it becomes managed. You can force refreshing of existing entities
* via second parameter. By default it will return already loaded entities without modifying them.
* via second parameter. By default, it will return already loaded entities without modifying them.
*/
merge<T extends AnyEntity<T>>(entity: T, options?: MergeOptions): T;

/**
* Merges given entity to this EntityManager so it becomes managed. You can force refreshing of existing entities
* via second parameter. By default it will return already loaded entities without modifying them.
* via second parameter. By default, it will return already loaded entities without modifying them.
*/
merge<T extends AnyEntity<T>>(entityName: EntityName<T>, data: EntityData<T> | EntityDTO<T>, options?: MergeOptions): T;

/**
* Merges given entity to this EntityManager so it becomes managed. You can force refreshing of existing entities
* via second parameter. By default it will return already loaded entities without modifying them.
* via second parameter. By default, it will return already loaded entities without modifying them.
*/
merge<T extends AnyEntity<T>>(entityName: EntityName<T> | T, data?: EntityData<T> | EntityDTO<T> | MergeOptions, options: MergeOptions = {}): T {
if (Utils.isEntity(entityName)) {
Expand Down Expand Up @@ -677,6 +687,23 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
await this.getUnitOfWork().commit();
}

/**
* @internal
*/
async tryFlush<T>(entityName: EntityName<T>, options: { flushMode?: FlushMode }): Promise<void> {
const flushMode = options.flushMode ?? this.getContext().flushMode ?? this.config.get('flushMode');
entityName = Utils.className(entityName);
const meta = this.metadata.get(entityName);

if (flushMode === FlushMode.COMMIT) {
return;
}

if (flushMode === FlushMode.ALWAYS || this.getUnitOfWork().shouldAutoFlush(meta)) {
await this.flush();
}
}

/**
* Clears the EntityManager. All entities that are currently managed by this EntityManager become detached.
*/
Expand Down Expand Up @@ -734,6 +761,7 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
const allowGlobalContext = this.config.get('allowGlobalContext');
this.config.set('allowGlobalContext', true);
const em = new (this.constructor as typeof EntityManager)(this.config, this.driver, this.metadata, options.useContext, eventManager);
em.setFlushMode(options.flushMode ?? this.flushMode);
this.config.set('allowGlobalContext', allowGlobalContext);

em.filters = { ...this.filters };
Expand Down Expand Up @@ -967,11 +995,12 @@ export interface MergeOptions {
schema?: string;
}

interface ForkOptions {
export interface ForkOptions {
/** do we want clear identity map? defaults to true */
clear?: boolean;
/** use request context? should be used only for top level request scope EM, defaults to false */
useContext?: boolean;
/** do we want to use fresh EventManager instance? defaults to false (global instance) */
freshEventManager?: boolean;
flushMode?: FlushMode;
}
3 changes: 2 additions & 1 deletion packages/core/src/drivers/IDatabaseDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type {
IPrimaryKey, PopulateOptions, EntityDictionary, ExpandProperty, AutoPath,
} from '../typings';
import type { Connection, QueryResult, Transaction } from '../connections';
import type { LockMode, QueryOrderMap, QueryFlag, LoadStrategy } from '../enums';
import type { FlushMode, LockMode, QueryOrderMap, QueryFlag, LoadStrategy } from '../enums';
import type { Platform } from '../platforms';
import type { MetadataStorage } from '../metadata';
import type { Collection } from '../entity';
Expand Down Expand Up @@ -100,6 +100,7 @@ export interface FindOptions<T, P extends string = never> {
groupBy?: string | string[];
having?: QBFilterQuery<T>;
strategy?: LoadStrategy;
flushMode?: FlushMode;
filters?: Dictionary<boolean | Dictionary> | string[] | boolean;
lockMode?: Exclude<LockMode, LockMode.OPTIMISTIC>;
lockTableAliases?: string[];
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/entity/BaseEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export abstract class BaseEntity<T, PK extends keyof T, P extends string = never
return (this as unknown as AnyEntity<T>).__helper!.__initialized;
}

isTouched(): boolean {
return (this as unknown as AnyEntity<T>).__helper!.__touched;
}

populated(populated = true): void {
(this as unknown as AnyEntity<T>).__helper!.populated(populated);
}
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/entity/EntityFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export class EntityFactory {
const entity = exists ?? this.createEntity<T>(data, meta2, options);
entity.__helper!.__initialized = options.initialized;
this.hydrate(entity, meta2, data, options);
entity.__helper!.__touched = false;

if (exists && meta.discriminatorColumn && !(entity instanceof meta2.class)) {
Object.setPrototypeOf(entity, meta2.prototype);
Expand Down Expand Up @@ -117,6 +118,8 @@ export class EntityFactory {
this.create(prop.type, data[prop.name] as EntityData<T>, options); // we can ignore the value, we just care about the `mergeData` call
}
});

entity.__helper!.__touched = false;
}

createReference<T>(entityName: EntityName<T>, id: Primary<T> | Primary<T>[] | Record<string, Primary<T>>, options: Pick<FactoryOptions, 'merge' | 'convertCustomTypes' | 'schema'> = {}): T {
Expand Down Expand Up @@ -166,6 +169,10 @@ export class EntityFactory {
meta.relations
.filter(prop => [ReferenceType.ONE_TO_MANY, ReferenceType.MANY_TO_MANY].includes(prop.reference))
.forEach(prop => delete entity[prop.name]);

if (options.initialized && !(entity as Dictionary).__gettersDefined) {
Object.defineProperties(entity, meta.definedProperties);
}
}

return entity;
Expand All @@ -181,6 +188,10 @@ export class EntityFactory {
this.unitOfWork.registerManaged(entity);
}

if (options.initialized && !(entity as Dictionary).__gettersDefined) {
Object.defineProperties(entity, meta.definedProperties);
}

return entity;
}

Expand Down
58 changes: 42 additions & 16 deletions packages/core/src/entity/EntityHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,9 @@ export class EntityHelper {
}

EntityHelper.defineBaseProperties(meta, meta.prototype, fork);
EntityHelper.defineProperties(meta);
const prototype = meta.prototype as Dictionary;

if (em.config.get('propagateToOneOwner')) {
EntityHelper.defineReferenceProperties(meta);
}

if (!prototype.toJSON) { // toJSON can be overridden
prototype.toJSON = function (this: T, ...args: any[]) {
return EntityTransformer.toObject<T>(this, ...args.slice(meta.toJsonParams.length));
Expand All @@ -55,6 +52,12 @@ export class EntityHelper {
});
}

/**
* As a performance optimization, we create entity state methods in a lazy manner. We first add
* the `null` value to the prototype to reserve space in memory. Then we define a setter on the
* prototype, that will be executed exactly once per entity instance. There we redefine given
* property on the entity instance, so shadowing the prototype setter.
*/
private static defineBaseProperties<T extends AnyEntity<T>>(meta: EntityMetadata<T>, prototype: T, em: EntityManager) {
const helperParams = meta.embeddable ? [] : [em.getComparator().getPkGetter(meta), em.getComparator().getPkSerializer(meta), em.getComparator().getPkGetterConverted(meta)];
Object.defineProperties(prototype, {
Expand All @@ -80,22 +83,45 @@ export class EntityHelper {

/**
* Defines getter and setter for every owning side of m:1 and 1:1 relation. This is then used for propagation of
* changes to the inverse side of bi-directional relations.
* changes to the inverse side of bi-directional relations. Rest of the properties are also defined this way to
* achieve dirtyness, which is then used for fast checks whether we need to auto-flush because of managed entities.
*
* First defines a setter on the prototype, once called, actual get/set handlers are registered on the instance rather
* than on its prototype. Thanks to this we still have those properties enumerable (e.g. part of `Object.keys(entity)`).
*/
private static defineReferenceProperties<T extends AnyEntity<T>>(meta: EntityMetadata<T>): void {
private static defineProperties<T extends AnyEntity<T>>(meta: EntityMetadata<T>): void {
Object
.values<EntityProperty>(meta.properties)
.values(meta.properties)
.filter(prop => [ReferenceType.ONE_TO_ONE, ReferenceType.MANY_TO_ONE].includes(prop.reference) && (prop.inversedBy || prop.mappedBy) && !prop.mapToPk)
.forEach(prop => {
Object.defineProperty(meta.prototype, prop.name, {
set(val: AnyEntity) {
if (!('__data' in this)) {
Object.defineProperty(this, '__data', { value: {} });
}
EntityHelper.defineReferenceProperty(meta, prop, this);
this[prop.name] = val;
},
});
});

EntityHelper.defineReferenceProperty(meta, prop, this, val);
Object
.values(meta.properties)
.filter(prop => !(prop.inherited || prop.primary || prop.persist === false))
.filter(prop => !([ReferenceType.ONE_TO_ONE, ReferenceType.MANY_TO_ONE].includes(prop.reference) && (prop.inversedBy || prop.mappedBy) && !prop.mapToPk))
.forEach(prop => {
Object.defineProperty(meta.prototype, prop.name, {
set(val) {
Object.defineProperty(this, prop.name, {
get() {
return this.__helper.__data[prop.name];
},
set(val) {
this.__helper.__data[prop.name] = val;
this.__helper.__touched = true;
},
enumerable: true,
configurable: true,
});
this.__helper.__data[prop.name] = val;
this.__helper.__touched = true;
},
});
});
Expand All @@ -118,20 +144,20 @@ export class EntityHelper {
}
}

private static defineReferenceProperty<T extends AnyEntity<T>>(meta: EntityMetadata<T>, prop: EntityProperty<T>, ref: T, val: AnyEntity): void {
static defineReferenceProperty<T extends AnyEntity<T>>(meta: EntityMetadata<T>, prop: EntityProperty<T>, ref: T): void {
Object.defineProperty(ref, prop.name, {
get() {
return this.__data[prop.name];
return this.__helper.__data[prop.name];
},
set(val: AnyEntity | Reference<AnyEntity>) {
const entity = Reference.unwrapReference(val ?? this.__data[prop.name]);
this.__data[prop.name] = Reference.wrapReference(val as T, prop);
const entity = Reference.unwrapReference(val ?? this.__helper.__data[prop.name]);
this.__helper.__data[prop.name] = Reference.wrapReference(val as T, prop);
this.__helper.__touched = true;
EntityHelper.propagate(meta, entity, this, prop, Reference.unwrapReference(val));
},
enumerable: true,
configurable: true,
});
ref[prop.name] = val as T[string & keyof T];
}

private static propagate<T extends AnyEntity<T>, O extends AnyEntity<O>>(meta: EntityMetadata<O>, entity: T, owner: O, prop: EntityProperty<O>, value?: O[keyof O]): void {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/entity/WrappedEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ import type { EntityIdentifier } from './EntityIdentifier';
export class WrappedEntity<T extends AnyEntity<T>, PK extends keyof T> {

__initialized = true;
__touched = false;
__populated?: boolean;
__lazyInitialized?: boolean;
__managed?: boolean;
__schema?: string;
__em?: EntityManager;
__serializationContext: { root?: SerializationContext<T>; populate?: PopulateOptions<T>[] } = {};
__loadedProperties = new Set<string>();
__data: Dictionary = {};

/** holds last entity data snapshot so we can compute changes when persisting managed entities */
__originalEntityData?: EntityData<T>;
Expand All @@ -38,6 +40,10 @@ export class WrappedEntity<T extends AnyEntity<T>, PK extends keyof T> {
return this.__initialized;
}

isTouched(): boolean {
return this.__touched;
}

populated(populated = true): void {
this.__populated = populated;
this.__lazyInitialized = false;
Expand Down
Loading