Skip to content

Commit

Permalink
feat: add nestMode option for managed transactions (#16143)
Browse files Browse the repository at this point in the history
Co-authored-by: Rik Smale <13023439+WikiRik@users.noreply.github.com>
  • Loading branch information
ephys and WikiRik committed Jun 18, 2023
1 parent 8f8f13e commit c4eef63
Show file tree
Hide file tree
Showing 15 changed files with 599 additions and 249 deletions.
69 changes: 57 additions & 12 deletions packages/core/src/deferrable.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import isEqual from 'lodash/isEqual';
import type { AbstractQueryGenerator } from './dialects/abstract/query-generator.js';
import { classToInvokable } from './utils/class-to-invokable.js';
import { EMPTY_ARRAY } from './utils/object.js';

/**
* Can be used to
Expand Down Expand Up @@ -34,26 +36,42 @@ import { classToInvokable } from './utils/class-to-invokable.js';
* ```
*/
export class Deferrable {
static toString(queryGenerator: AbstractQueryGenerator) {
return new this().toString(queryGenerator);
}

toString(queryGenerator: AbstractQueryGenerator) {
return this.toSql(queryGenerator);
toString() {
return this.constructor.name;
}

toSql(_queryGenerator: AbstractQueryGenerator) {
throw new Error('toSql implementation missing');
}

isEqual(_other: unknown): boolean {
throw new Error('isEqual implementation missing');
}

static readonly INITIALLY_DEFERRED = classToInvokable(class INITIALLY_DEFERRED extends Deferrable {
toSql() {
return INITIALLY_DEFERRED.toSql();
}

static toSql() {
return 'DEFERRABLE INITIALLY DEFERRED';
}

isEqual(other: unknown): boolean {
return other instanceof INITIALLY_DEFERRED;
}
});

static readonly INITIALLY_IMMEDIATE = classToInvokable(class INITIALLY_IMMEDIATE extends Deferrable {
toSql() {
return INITIALLY_IMMEDIATE.toSql();
}

isEqual(other: unknown): boolean {
return other instanceof INITIALLY_IMMEDIATE;
}

static toSql() {
return 'DEFERRABLE INITIALLY IMMEDIATE';
}
});
Expand All @@ -64,47 +82,74 @@ export class Deferrable {
*/
static readonly NOT = classToInvokable(class NOT extends Deferrable {
toSql() {
return NOT.toSql();
}

isEqual(other: unknown): boolean {
return other instanceof NOT;
}

static toSql() {
return 'NOT DEFERRABLE';
}
});

// TODO: move the following classes to their own namespace, as they are not related to the above classes
// the ones above are about configuring a constraint's deferrability when defining the constraint.
// The ones below are for configuring them during a transaction
/**
* Will trigger an additional query at the beginning of a
* transaction which sets the constraints to deferred.
*/
static readonly SET_DEFERRED = classToInvokable(class SET_DEFERRED extends Deferrable {
readonly #constraints: string[];
readonly #constraints: readonly string[];

/**
* @param constraints An array of constraint names. Will defer all constraints by default.
*/
constructor(constraints: string[]) {
constructor(constraints: readonly string[] = EMPTY_ARRAY) {
super();
this.#constraints = constraints;
this.#constraints = Object.freeze([...constraints]);
}

toSql(queryGenerator: AbstractQueryGenerator): string {
return queryGenerator.setDeferredQuery(this.#constraints);
}

isEqual(other: unknown): boolean {
return other instanceof SET_DEFERRED && isEqual(this.#constraints, other.#constraints);
}

static toSql(queryGenerator: AbstractQueryGenerator): string {
return queryGenerator.setDeferredQuery(EMPTY_ARRAY);
}
});

/**
* Will trigger an additional query at the beginning of a
* transaction which sets the constraints to immediately.
*/
static readonly SET_IMMEDIATE = classToInvokable(class SET_IMMEDIATE extends Deferrable {
readonly #constraints: string[];
readonly #constraints: readonly string[];

/**
* @param constraints An array of constraint names. Will defer all constraints by default.
*/
constructor(constraints: string[]) {
constructor(constraints: readonly string[] = EMPTY_ARRAY) {
super();
this.#constraints = constraints;
this.#constraints = Object.freeze([...constraints]);
}

toSql(queryGenerator: AbstractQueryGenerator): string {
return queryGenerator.setImmediateQuery(this.#constraints);
}

isEqual(other: unknown): boolean {
return other instanceof SET_IMMEDIATE && isEqual(this.#constraints, other.#constraints);
}

static toSql(queryGenerator: AbstractQueryGenerator): string {
return queryGenerator.setImmediateQuery(EMPTY_ARRAY);
}
});
}
4 changes: 2 additions & 2 deletions packages/core/src/dialects/abstract/query-generator.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ export interface RemoveColumnQueryOptions {
export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript {
constructor(options: QueryGeneratorOptions);

setImmediateQuery(constraints: string[]): string;
setDeferredQuery(constraints: string[]): string;
setImmediateQuery(constraints: readonly string[]): string;
setDeferredQuery(constraints: readonly string[]): string;
generateTransactionId(): string;
quoteIdentifiers(identifiers: string): string;

Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/dialects/abstract/query-interface.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type {
NormalizedAttributeOptions,
} from '../../model';
import type { QueryRawOptions, QueryRawOptionsWithModel, Sequelize } from '../../sequelize';
import type { Transaction } from '../../transaction';
import type { IsolationLevel, Transaction } from '../../transaction';
import type { AllowLowercase } from '../../utils/types.js';
import type { DataType } from './data-types.js';
import type { RemoveIndexQueryOptions, TableNameOrModel } from './query-generator-typescript';
Expand Down Expand Up @@ -659,7 +659,7 @@ export class AbstractQueryInterface extends AbstractQueryInterfaceTypeScript {
/**
* Set the isolation level of a transaction
*/
setIsolationLevel(transaction: Transaction, value: string, options?: QueryRawOptions): Promise<void>;
setIsolationLevel(transaction: Transaction, value: IsolationLevel, options?: QueryRawOptions): Promise<void>;

/**
* Begin a new transaction
Expand All @@ -677,7 +677,7 @@ export class AbstractQueryInterface extends AbstractQueryInterfaceTypeScript {
commitTransaction(transaction: Transaction, options?: QueryRawOptions): Promise<void>;

/**
* Rollback ( revert ) a transaction that has'nt been commited
* Rollback (revert) a transaction that hasn't been committed
*/
rollbackTransaction(transaction: Transaction, options?: QueryRawOptions): Promise<void>;

Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/dialects/postgres/query-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ export class PostgresQueryGenerator extends PostgresQueryGeneratorTypeScript {
}

if (attribute.references.deferrable) {
sql += ` ${attribute.references.deferrable.toString(this)}`;
sql += ` ${attribute.references.deferrable.toSql(this)}`;
}
}
}
Expand All @@ -472,13 +472,13 @@ export class PostgresQueryGenerator extends PostgresQueryGeneratorTypeScript {
}

deferConstraintsQuery(options) {
return options.deferrable.toString(this);
return options.deferrable.toSql(this);
}

setConstraintQuery(columns, type) {
let columnFragment = 'ALL';

if (columns) {
if (columns?.length) {
columnFragment = columns.map(column => this.quoteIdentifier(column)).join(', ');
}

Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,16 @@ export { QueryTypes } from './query-types';
export { IndexHints } from './index-hints';
export { TableHints } from './table-hints';
export { Op, type OpTypes } from './operators';
export * from './transaction';
export {
TransactionType,
Lock,
IsolationLevel,
TransactionNestMode,
Transaction,
type ClsTransactionOptions,
type TransactionOptions,
type NormalizedTransactionOptions,
} from './transaction';

export type { Connection } from './dialects/abstract/connection-manager';
export * from './associations/index';
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export const Model = Pkg.Model;

// export * from './lib/transaction';
export const Transaction = Pkg.Transaction;
export const TransactionNestMode = Pkg.TransactionNestMode;
export const TransactionType = Pkg.TransactionType;
export const Lock = Pkg.Lock;
export const IsolationLevel = Pkg.IsolationLevel;

// export * from './lib/associations/index';
export const Association = Pkg.Association;
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/model.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import type {
OmitConstructors,
StrictRequiredBy,
} from './utils/types.js';
import type { LOCK, Op, TableHints, Transaction, WhereOptions } from './index';
import type { Lock, Op, TableHints, Transaction, WhereOptions } from './index';

export interface Logging {
/**
Expand Down Expand Up @@ -862,11 +862,11 @@ export interface FindOptions<TAttributes = any>
/**
* Lock the selected rows. Possible options are transaction.LOCK.UPDATE and transaction.LOCK.SHARE.
* Postgres also supports transaction.LOCK.KEY_SHARE, transaction.LOCK.NO_KEY_UPDATE and specific model
* locks with joins. See {@link LOCK}.
* locks with joins. See {@link Lock}.
*/
lock?:
| LOCK
| { level: LOCK, of: ModelStatic<Model> }
| Lock
| { level: Lock, of: ModelStatic<Model> }
| boolean;

/**
Expand Down
56 changes: 45 additions & 11 deletions packages/core/src/sequelize-typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,16 @@ import type { AsyncHookReturn, HookHandler } from './hooks.js';
import { HookHandlerBuilder } from './hooks.js';
import type { ModelHooks } from './model-hooks.js';
import { validModelHooks } from './model-hooks.js';
import { setTransactionFromCls } from './model-internals.js';
import type { ModelManager } from './model-manager.js';
import type { ConnectionOptions, Options, Sequelize } from './sequelize.js';
import type { TransactionOptions } from './transaction.js';
import { Transaction } from './transaction.js';
import type { ConnectionOptions, NormalizedOptions, Options, Sequelize } from './sequelize.js';
import type { ClsTransactionOptions, TransactionOptions } from './transaction.js';
import {
Transaction,
TransactionNestMode,
assertTransactionIsCompatibleWithOptions,
normalizeTransactionOptions,
} from './transaction.js';
import type { PartialBy } from './utils/types.js';
import type {
AbstractQueryInterface,
Expand Down Expand Up @@ -148,6 +154,7 @@ export abstract class SequelizeTypeScript {
abstract readonly dialect: AbstractDialect;
abstract readonly queryInterface: AbstractQueryInterface;
declare readonly connectionManager: AbstractConnectionManager;
declare readonly options: NormalizedOptions;

static get hooks(): HookHandler<StaticSequelizeHooks> {
return staticSequelizeHooks.getFor(this);
Expand Down Expand Up @@ -310,12 +317,12 @@ export abstract class SequelizeTypeScript {
* @param options Transaction Options
* @param callback Async callback during which the transaction will be active
*/
transaction<T>(options: TransactionOptions, callback: TransactionCallback<T>): Promise<T>;
transaction<T>(options: ClsTransactionOptions, callback: TransactionCallback<T>): Promise<T>;
async transaction<T>(
optionsOrCallback: TransactionOptions | TransactionCallback<T>,
optionsOrCallback: ClsTransactionOptions | TransactionCallback<T>,
maybeCallback?: TransactionCallback<T>,
): Promise<T> {
let options: TransactionOptions;
let options: ClsTransactionOptions;
let callback: TransactionCallback<T>;
if (typeof optionsOrCallback === 'function') {
callback = optionsOrCallback;
Expand All @@ -329,13 +336,40 @@ export abstract class SequelizeTypeScript {
throw new Error('sequelize.transaction requires a callback. If you wish to start an unmanaged transaction, please use sequelize.startUnmanagedTransaction instead');
}

const transaction = new Transaction(
// @ts-expect-error -- remove once this class has been merged back with the Sequelize class
this,
options,
);
const nestMode: TransactionNestMode = options.nestMode ?? this.options.clsTransactionNestMode;

// @ts-expect-error -- will be fixed once this class has been merged back with the Sequelize class
const normalizedOptions = normalizeTransactionOptions(this, options);

if (nestMode === TransactionNestMode.separate) {
delete normalizedOptions.transaction;
} else {
// @ts-expect-error -- will be fixed once this class has been merged back with the Sequelize class
setTransactionFromCls(normalizedOptions, this);

// in reuse & savepoint mode,
// we use the same transaction, so we need to make sure it's compatible with the requested options
if (normalizedOptions.transaction) {
assertTransactionIsCompatibleWithOptions(normalizedOptions.transaction, normalizedOptions);
}
}

const transaction = nestMode === TransactionNestMode.reuse && normalizedOptions.transaction
? normalizedOptions.transaction
: new Transaction(
// @ts-expect-error -- will be fixed once this class has been merged back with the Sequelize class
this,
normalizedOptions,
);

const isReusedTransaction = transaction === normalizedOptions.transaction;

const wrappedCallback = async () => {
// We did not create this transaction, so we're not responsible for managing it.
if (isReusedTransaction) {
return callback(transaction);
}

await transaction.prepareEnvironment();

let result;
Expand Down

0 comments on commit c4eef63

Please sign in to comment.