From ff035c4d8fac8e8cf791dd5f20c3150a07590f89 Mon Sep 17 00:00:00 2001 From: igalklebanov Date: Tue, 23 Apr 2024 22:24:45 +0300 Subject: [PATCH 01/22] controlled transaction. --- src/kysely.ts | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/src/kysely.ts b/src/kysely.ts index dadffe8da..be982431a 100644 --- a/src/kysely.ts +++ b/src/kysely.ts @@ -22,7 +22,10 @@ import { } from './query-builder/function-module.js' import { Log, LogConfig } from './util/log.js' import { QueryExecutorProvider } from './query-executor/query-executor-provider.js' -import { QueryResult } from './driver/database-connection.js' +import { + DatabaseConnection, + QueryResult, +} from './driver/database-connection.js' import { CompiledQuery } from './query-compiler/compiled-query.js' import { createQueryId, QueryId } from './util/query-id.js' import { Compilable, isCompilable } from './util/compilable.js' @@ -253,6 +256,13 @@ export class Kysely return new TransactionBuilder({ ...this.#props }) } + /** + * TODO: ... + */ + startTransaction(): ControlledTransactionBuilder { + return new ControlledTransactionBuilder({ ...this.#props }) + } + /** * Provides a kysely instance bound to a single database connection. * @@ -583,3 +593,89 @@ function validateTransactionSettings(settings: TransactionSettings): void { ) } } + +export class ControlledTransactionBuilder { + readonly #props: TransactionBuilderProps + + constructor(props: TransactionBuilderProps) { + this.#props = freeze(props) + } + + setIsolationLevel( + isolationLevel: IsolationLevel, + ): ControlledTransactionBuilder { + return new ControlledTransactionBuilder({ + ...this.#props, + isolationLevel, + }) + } + + async execute(): Promise> { + const { isolationLevel, ...kyselyProps } = this.#props + const settings = { isolationLevel } + + validateTransactionSettings(settings) + + const connection = await this.#props.driver.acquireConnection() + + await this.#props.driver.beginTransaction(connection, settings) + + return new ControlledTransaction({ + ...kyselyProps, + connection, + }) + } +} + +preventAwait( + ControlledTransactionBuilder, + "don't await ControlledTransactionBuilder instances directly. To execute the transaction you need to call the `execute` method", +) + +export class ControlledTransaction extends Transaction { + readonly #props: ControlledTransactionProps + + constructor(props: ControlledTransactionProps) { + const { connection, ...transactionProps } = props + super(transactionProps) + this.#props = props + } + + commit(): Command { + return new Command(() => + this.#props.driver.commitTransaction(this.#props.connection), + ) + } + + rollback(): Command { + return new Command(() => + this.#props.driver.rollbackTransaction(this.#props.connection), + ) + } +} + +interface ControlledTransactionProps extends KyselyProps { + readonly connection: DatabaseConnection +} + +preventAwait( + ControlledTransaction, + "don't await ControlledTransaction instances directly. To commit or rollback the transaction you need to call the `commit` or `rollback` method", +) + +export class Command { + readonly #cb: () => Promise + + constructor(cb: () => Promise) { + this.#cb = cb + } + + async execute(): Promise { + return await this.#cb() + } +} + +preventAwait( + Command, + "don't await Command instances directly. Call the `execute` method", +) From 396a59258c75015af994f999bb541604e574cdf1 Mon Sep 17 00:00:00 2001 From: igalklebanov Date: Wed, 24 Apr 2024 00:20:58 +0300 Subject: [PATCH 02/22] savepoints. --- src/dialect/mssql/mssql-driver.ts | 30 ++++++++++ src/dialect/mysql/mysql-driver.ts | 32 ++++++++++ src/dialect/postgres/postgres-driver.ts | 37 ++++++++++++ src/dialect/sqlite/sqlite-driver.ts | 32 ++++++++++ src/driver/driver.ts | 28 +++++++++ src/driver/runtime-driver.ts | 45 +++++++++++++++ src/kysely.ts | 77 +++++++++++++++++++++---- src/parser/savepoint-parser.ts | 12 ++++ 8 files changed, 282 insertions(+), 11 deletions(-) create mode 100644 src/parser/savepoint-parser.ts diff --git a/src/dialect/mssql/mssql-driver.ts b/src/dialect/mssql/mssql-driver.ts index b9d44e203..66677c1bc 100644 --- a/src/dialect/mssql/mssql-driver.ts +++ b/src/dialect/mssql/mssql-driver.ts @@ -30,6 +30,8 @@ import { CompiledQuery } from '../../query-compiler/compiled-query.js' import { extendStackTrace } from '../../util/stack-trace-utils.js' import { randomString } from '../../util/random-string.js' import { Deferred } from '../../util/deferred.js' +import { parseSavepointCommand } from '../../parser/savepoint-parser.js' +import { QueryCompiler } from '../../query-compiler/query-compiler.js' const PRIVATE_RELEASE_METHOD = Symbol() const PRIVATE_DESTROY_METHOD = Symbol() @@ -86,6 +88,34 @@ export class MssqlDriver implements Driver { await connection.rollbackTransaction() } + async savepoint( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler['compileQuery'], + ): Promise { + await connection.executeQuery( + compileQuery(parseSavepointCommand('save transaction', savepointName)), + ) + } + + async rollbackToSavepoint( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler['compileQuery'], + ): Promise { + await connection.executeQuery( + compileQuery( + parseSavepointCommand('rollback transaction', savepointName), + ), + ) + } + + async releaseSavepoint(): Promise { + throw new Error( + 'MS SQL Server (mssql) does not support releasing savepoints', + ) + } + async releaseConnection(connection: MssqlConnection): Promise { await connection[PRIVATE_RELEASE_METHOD]() this.#pool.release(connection) diff --git a/src/dialect/mysql/mysql-driver.ts b/src/dialect/mysql/mysql-driver.ts index 063d84ae0..084b6cb6f 100644 --- a/src/dialect/mysql/mysql-driver.ts +++ b/src/dialect/mysql/mysql-driver.ts @@ -3,7 +3,9 @@ import { QueryResult, } from '../../driver/database-connection.js' import { Driver, TransactionSettings } from '../../driver/driver.js' +import { parseSavepointCommand } from '../../parser/savepoint-parser.js' import { CompiledQuery } from '../../query-compiler/compiled-query.js' +import { QueryCompiler } from '../../query-compiler/query-compiler.js' import { isFunction, isObject, freeze } from '../../util/object-utils.js' import { extendStackTrace } from '../../util/stack-trace-utils.js' import { @@ -90,6 +92,36 @@ export class MysqlDriver implements Driver { await connection.executeQuery(CompiledQuery.raw('rollback')) } + async savepoint( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler['compileQuery'], + ): Promise { + await connection.executeQuery( + compileQuery(parseSavepointCommand('savepoint', savepointName)), + ) + } + + async rollbackToSavepoint( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler['compileQuery'], + ): Promise { + await connection.executeQuery( + compileQuery(parseSavepointCommand('rollback to', savepointName)), + ) + } + + async releaseSavepoint( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler['compileQuery'], + ): Promise { + await connection.executeQuery( + compileQuery(parseSavepointCommand('release savepoint', savepointName)), + ) + } + async releaseConnection(connection: MysqlConnection): Promise { connection[PRIVATE_RELEASE_METHOD]() } diff --git a/src/dialect/postgres/postgres-driver.ts b/src/dialect/postgres/postgres-driver.ts index 7b6639fe2..0321e548b 100644 --- a/src/dialect/postgres/postgres-driver.ts +++ b/src/dialect/postgres/postgres-driver.ts @@ -3,7 +3,14 @@ import { QueryResult, } from '../../driver/database-connection.js' import { Driver, TransactionSettings } from '../../driver/driver.js' +import { IdentifierNode } from '../../operation-node/identifier-node.js' +import { RawNode } from '../../operation-node/raw-node.js' +import { parseSavepointCommand } from '../../parser/savepoint-parser.js' import { CompiledQuery } from '../../query-compiler/compiled-query.js' +import { + QueryCompiler, + RootOperationNode, +} from '../../query-compiler/query-compiler.js' import { isFunction, freeze } from '../../util/object-utils.js' import { extendStackTrace } from '../../util/stack-trace-utils.js' import { @@ -78,6 +85,36 @@ export class PostgresDriver implements Driver { await connection.executeQuery(CompiledQuery.raw('rollback')) } + async savepoint( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler['compileQuery'], + ): Promise { + await connection.executeQuery( + compileQuery(parseSavepointCommand('savepoint', savepointName)), + ) + } + + async rollbackToSavepoint( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler['compileQuery'], + ): Promise { + await connection.executeQuery( + compileQuery(parseSavepointCommand('rollback to', savepointName)), + ) + } + + async releaseSavepoint( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler['compileQuery'], + ): Promise { + await connection.executeQuery( + compileQuery(parseSavepointCommand('release', savepointName)), + ) + } + async releaseConnection(connection: PostgresConnection): Promise { connection[PRIVATE_RELEASE_METHOD]() } diff --git a/src/dialect/sqlite/sqlite-driver.ts b/src/dialect/sqlite/sqlite-driver.ts index dcfbca2e6..5aefb32ef 100644 --- a/src/dialect/sqlite/sqlite-driver.ts +++ b/src/dialect/sqlite/sqlite-driver.ts @@ -4,7 +4,9 @@ import { } from '../../driver/database-connection.js' import { Driver } from '../../driver/driver.js' import { SelectQueryNode } from '../../operation-node/select-query-node.js' +import { parseSavepointCommand } from '../../parser/savepoint-parser.js' import { CompiledQuery } from '../../query-compiler/compiled-query.js' +import { QueryCompiler } from '../../query-compiler/query-compiler.js' import { freeze, isFunction } from '../../util/object-utils.js' import { SqliteDatabase, SqliteDialectConfig } from './sqlite-dialect-config.js' @@ -50,6 +52,36 @@ export class SqliteDriver implements Driver { await connection.executeQuery(CompiledQuery.raw('rollback')) } + async savepoint( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler['compileQuery'], + ): Promise { + await connection.executeQuery( + compileQuery(parseSavepointCommand('savepoint', savepointName)), + ) + } + + async rollbackToSavepoint( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler['compileQuery'], + ): Promise { + await connection.executeQuery( + compileQuery(parseSavepointCommand('rollback to', savepointName)), + ) + } + + async releaseSavepoint( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler['compileQuery'], + ): Promise { + await connection.executeQuery( + compileQuery(parseSavepointCommand('release', savepointName)), + ) + } + async releaseConnection(): Promise { this.#connectionMutex.unlock() } diff --git a/src/driver/driver.ts b/src/driver/driver.ts index ef9214d17..849cd0129 100644 --- a/src/driver/driver.ts +++ b/src/driver/driver.ts @@ -1,3 +1,4 @@ +import { QueryCompiler } from '../query-compiler/query-compiler.js' import { ArrayItemType } from '../util/type-utils.js' import { DatabaseConnection } from './database-connection.js' @@ -37,6 +38,33 @@ export interface Driver { */ rollbackTransaction(connection: DatabaseConnection): Promise + /** + * Establishses a new savepoint within a transaction. + */ + savepoint?( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler['compileQuery'], + ): Promise + + /** + * Rolls back to a savepoint within a transaction. + */ + rollbackToSavepoint?( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler['compileQuery'], + ): Promise + + /** + * Releases a savepoint within a transaction. + */ + releaseSavepoint?( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler['compileQuery'], + ): Promise + /** * Releases a connection back to the pool. */ diff --git a/src/driver/runtime-driver.ts b/src/driver/runtime-driver.ts index a7ba8d771..cf7563412 100644 --- a/src/driver/runtime-driver.ts +++ b/src/driver/runtime-driver.ts @@ -1,4 +1,5 @@ import { CompiledQuery } from '../query-compiler/compiled-query.js' +import { QueryCompiler } from '../query-compiler/query-compiler.js' import { Log } from '../util/log.js' import { performanceNow } from '../util/performance-now.js' import { DatabaseConnection, QueryResult } from './database-connection.js' @@ -85,6 +86,50 @@ export class RuntimeDriver implements Driver { return this.#driver.rollbackTransaction(connection) } + savepoint( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler['compileQuery'], + ): Promise { + if (this.#driver.savepoint) { + return this.#driver.savepoint(connection, savepointName, compileQuery) + } + + throw new Error('savepoints are not supported by this driver') + } + + rollbackToSavepoint( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler['compileQuery'], + ): Promise { + if (this.#driver.rollbackToSavepoint) { + return this.#driver.rollbackToSavepoint( + connection, + savepointName, + compileQuery, + ) + } + + throw new Error('savepoints are not supported by this driver') + } + + releaseSavepoint( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler['compileQuery'], + ): Promise { + if (this.#driver.releaseSavepoint) { + return this.#driver.releaseSavepoint( + connection, + savepointName, + compileQuery, + ) + } + + throw new Error('savepoints are not supported by this driver') + } + async destroy(): Promise { if (!this.#initPromise) { return diff --git a/src/kysely.ts b/src/kysely.ts index be982431a..5c36ee9e9 100644 --- a/src/kysely.ts +++ b/src/kysely.ts @@ -35,6 +35,7 @@ import { parseExpression } from './parser/expression-parser.js' import { Expression } from './expression/expression.js' import { WithSchemaPlugin } from './plugin/with-schema/with-schema-plugin.js' import { DrainOuterGeneric } from './util/type-utils.js' +import { QueryCompiler } from './query-compiler/query-compiler.js' /** * The main Kysely class. @@ -595,9 +596,9 @@ function validateTransactionSettings(settings: TransactionSettings): void { } export class ControlledTransactionBuilder { - readonly #props: TransactionBuilderProps + readonly #props: ControlledTransactionBuilderProps - constructor(props: TransactionBuilderProps) { + constructor(props: ControlledTransactionBuilderProps) { this.#props = freeze(props) } @@ -611,7 +612,7 @@ export class ControlledTransactionBuilder { } async execute(): Promise> { - const { isolationLevel, ...kyselyProps } = this.#props + const { isolationLevel, ...props } = this.#props const settings = { isolationLevel } validateTransactionSettings(settings) @@ -621,12 +622,16 @@ export class ControlledTransactionBuilder { await this.#props.driver.beginTransaction(connection, settings) return new ControlledTransaction({ - ...kyselyProps, + ...props, connection, }) } } +interface ControlledTransactionBuilderProps extends TransactionBuilderProps { + readonly releaseConnection?: boolean +} + preventAwait( ControlledTransactionBuilder, "don't await ControlledTransactionBuilder instances directly. To execute the transaction you need to call the `execute` method", @@ -634,28 +639,75 @@ preventAwait( export class ControlledTransaction extends Transaction { readonly #props: ControlledTransactionProps + readonly #compileQuery: QueryCompiler['compileQuery'] constructor(props: ControlledTransactionProps) { - const { connection, ...transactionProps } = props + const { + connection, + releaseConnectedWhenDone: releaseConnection, + ...transactionProps + } = props super(transactionProps) this.#props = props + + const queryId = createQueryId() + this.#compileQuery = (node) => props.executor.compileQuery(node, queryId) } commit(): Command { - return new Command(() => - this.#props.driver.commitTransaction(this.#props.connection), - ) + return new Command(async () => { + await this.#props.driver.commitTransaction(this.#props.connection) + await this.#releaseConnectionIfNecessary() + }) } rollback(): Command { - return new Command(() => - this.#props.driver.rollbackTransaction(this.#props.connection), - ) + return new Command(async () => { + await this.#props.driver.rollbackTransaction(this.#props.connection) + await this.#releaseConnectionIfNecessary() + }) + } + + savepoint(savepointName: string): Command { + return new Command(async () => { + await this.#props.driver.savepoint?.( + this.#props.connection, + savepointName, + this.#compileQuery, + ) + }) + } + + rollbackToSavepoint(savepointName: string): Command { + return new Command(async () => { + await this.#props.driver.rollbackToSavepoint?.( + this.#props.connection, + savepointName, + this.#compileQuery, + ) + }) + } + + releaseSavepoint(savepointName: string): Command { + return new Command(async () => { + await this.#props.driver.releaseSavepoint?.( + this.#props.connection, + savepointName, + this.#compileQuery, + ) + }) + } + + async #releaseConnectionIfNecessary(): Promise { + if (this.#props.releaseConnectedWhenDone !== false) { + await this.#props.driver.releaseConnection(this.#props.connection) + } } } interface ControlledTransactionProps extends KyselyProps { readonly connection: DatabaseConnection + readonly releaseConnectedWhenDone?: boolean } preventAwait( @@ -670,6 +722,9 @@ export class Command { this.#cb = cb } + /** + * Executes the command. + */ async execute(): Promise { return await this.#cb() } diff --git a/src/parser/savepoint-parser.ts b/src/parser/savepoint-parser.ts new file mode 100644 index 000000000..fbbebd9cd --- /dev/null +++ b/src/parser/savepoint-parser.ts @@ -0,0 +1,12 @@ +import { IdentifierNode } from '../operation-node/identifier-node.js' +import { RawNode } from '../operation-node/raw-node.js' + +export function parseSavepointCommand( + command: string, + savepointName: string, +): RawNode { + return RawNode.createWithChildren([ + RawNode.createWithSql(`${command} `), + IdentifierNode.create(savepointName), // ensures savepointName gets sanitized + ]) +} From 966764fead11d1fa831c222b249bf617b3270ddc Mon Sep 17 00:00:00 2001 From: igalklebanov Date: Wed, 24 Apr 2024 00:55:21 +0300 Subject: [PATCH 03/22] type-safe savepoints. --- src/kysely.ts | 63 +++++++++++++++++++++++++++++++--- src/parser/savepoint-parser.ts | 18 ++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/kysely.ts b/src/kysely.ts index 5c36ee9e9..f75447cd4 100644 --- a/src/kysely.ts +++ b/src/kysely.ts @@ -36,6 +36,10 @@ import { Expression } from './expression/expression.js' import { WithSchemaPlugin } from './plugin/with-schema/with-schema-plugin.js' import { DrainOuterGeneric } from './util/type-utils.js' import { QueryCompiler } from './query-compiler/query-compiler.js' +import { + ReleaseSavepoint, + RollbackToSavepoint, +} from './parser/savepoint-parser.js' /** * The main Kysely class. @@ -637,9 +641,14 @@ preventAwait( "don't await ControlledTransactionBuilder instances directly. To execute the transaction you need to call the `execute` method", ) -export class ControlledTransaction extends Transaction { +export class ControlledTransaction< + DB, + S extends string[] = [], +> extends Transaction { readonly #props: ControlledTransactionProps readonly #compileQuery: QueryCompiler['compileQuery'] + #isCommitted: boolean + #isRolledBack: boolean constructor(props: ControlledTransactionProps) { const { @@ -652,52 +661,98 @@ export class ControlledTransaction extends Transaction { const queryId = createQueryId() this.#compileQuery = (node) => props.executor.compileQuery(node, queryId) + + this.#isCommitted = false + this.#isRolledBack = false } commit(): Command { + this.#assertNotCommittedOrRolledBack() + return new Command(async () => { await this.#props.driver.commitTransaction(this.#props.connection) + this.#isCommitted = true await this.#releaseConnectionIfNecessary() }) } rollback(): Command { + this.#assertNotCommittedOrRolledBack() + return new Command(async () => { await this.#props.driver.rollbackTransaction(this.#props.connection) + this.#isRolledBack = true await this.#releaseConnectionIfNecessary() }) } - savepoint(savepointName: string): Command { + savepoint( + savepointName: SN extends S ? never : SN, + ): Command> { + this.#assertNotCommittedOrRolledBack() + return new Command(async () => { await this.#props.driver.savepoint?.( this.#props.connection, savepointName, this.#compileQuery, ) + + return new ControlledTransaction({ + ...this.#props, + connection: this.#props.connection, + }) }) } - rollbackToSavepoint(savepointName: string): Command { + rollbackToSavepoint( + savepointName: SN, + ): Command>> { + this.#assertNotCommittedOrRolledBack() + return new Command(async () => { await this.#props.driver.rollbackToSavepoint?.( this.#props.connection, savepointName, this.#compileQuery, ) + + return new ControlledTransaction({ + ...this.#props, + connection: this.#props.connection, + }) }) } - releaseSavepoint(savepointName: string): Command { + releaseSavepoint( + savepointName: SN, + ): Command>> { + this.#assertNotCommittedOrRolledBack() + return new Command(async () => { await this.#props.driver.releaseSavepoint?.( this.#props.connection, savepointName, this.#compileQuery, ) + + return new ControlledTransaction({ + ...this.#props, + connection: this.#props.connection, + }) }) } + #assertNotCommittedOrRolledBack(): void { + if (this.#isCommitted) { + throw new Error('Transaction is already committed') + } + + if (this.#isRolledBack) { + throw new Error('Transaction is already rolled back') + } + } + async #releaseConnectionIfNecessary(): Promise { if (this.#props.releaseConnectedWhenDone !== false) { await this.#props.driver.releaseConnection(this.#props.connection) diff --git a/src/parser/savepoint-parser.ts b/src/parser/savepoint-parser.ts index fbbebd9cd..8163bb935 100644 --- a/src/parser/savepoint-parser.ts +++ b/src/parser/savepoint-parser.ts @@ -1,6 +1,24 @@ import { IdentifierNode } from '../operation-node/identifier-node.js' import { RawNode } from '../operation-node/raw-node.js' +export type RollbackToSavepoint< + S extends string[], + SN extends S[number], +> = S extends [...infer L extends string[], infer R] + ? R extends SN + ? S + : RollbackToSavepoint + : never + +export type ReleaseSavepoint< + S extends string[], + SN extends S[number], +> = S extends [...infer L extends string[], infer R] + ? R extends SN + ? L + : ReleaseSavepoint + : never + export function parseSavepointCommand( command: string, savepointName: string, From 08fbbd0ad0f2090bc84d528e201a8b86dc14a843 Mon Sep 17 00:00:00 2001 From: igalklebanov Date: Fri, 26 Apr 2024 16:34:13 +0300 Subject: [PATCH 04/22] assert not ended on all executions. --- src/kysely.ts | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/src/kysely.ts b/src/kysely.ts index f75447cd4..e492010fe 100644 --- a/src/kysely.ts +++ b/src/kysely.ts @@ -628,6 +628,9 @@ export class ControlledTransactionBuilder { return new ControlledTransaction({ ...props, connection, + executor: this.#props.executor.withConnectionProvider( + new SingleConnectionProvider(connection), + ), }) } } @@ -653,17 +656,27 @@ export class ControlledTransaction< constructor(props: ControlledTransactionProps) { const { connection, - releaseConnectedWhenDone: releaseConnection, + releaseConnectionWhenDone: releaseConnection, ...transactionProps } = props super(transactionProps) - this.#props = props + this.#props = freeze(props) const queryId = createQueryId() this.#compileQuery = (node) => props.executor.compileQuery(node, queryId) this.#isCommitted = false this.#isRolledBack = false + + this.#assertNotCommittedOrRolledBackBeforeAllExecutions() + } + + get isCommitted(): boolean { + return this.#isCommitted + } + + get isRolledBack(): boolean { + return this.#isRolledBack } commit(): Command { @@ -743,18 +756,35 @@ export class ControlledTransaction< }) } + #assertNotCommittedOrRolledBackBeforeAllExecutions() { + const { executor } = this.#props + + const originalExecuteQuery = executor.executeQuery.bind(executor) + executor.executeQuery = async (query, queryId) => { + this.#assertNotCommittedOrRolledBack() + return await originalExecuteQuery(query, queryId) + } + + const that = this + const originalStream = executor.stream.bind(executor) + executor.stream = (query, chunkSize, queryId) => { + that.#assertNotCommittedOrRolledBack() + return originalStream(query, chunkSize, queryId) + } + } + #assertNotCommittedOrRolledBack(): void { - if (this.#isCommitted) { + if (this.isCommitted) { throw new Error('Transaction is already committed') } - if (this.#isRolledBack) { + if (this.isRolledBack) { throw new Error('Transaction is already rolled back') } } async #releaseConnectionIfNecessary(): Promise { - if (this.#props.releaseConnectedWhenDone !== false) { + if (this.#props.releaseConnectionWhenDone !== false) { await this.#props.driver.releaseConnection(this.#props.connection) } } @@ -762,7 +792,7 @@ export class ControlledTransaction< interface ControlledTransactionProps extends KyselyProps { readonly connection: DatabaseConnection - readonly releaseConnectedWhenDone?: boolean + readonly releaseConnectionWhenDone?: boolean } preventAwait( From 8123aba6602c32daa46739c98f9761f92f93212b Mon Sep 17 00:00:00 2001 From: Igal Klebanov Date: Fri, 26 Apr 2024 18:35:24 +0300 Subject: [PATCH 05/22] -preventAwait. --- src/kysely.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/kysely.ts b/src/kysely.ts index e492010fe..ffd612db6 100644 --- a/src/kysely.ts +++ b/src/kysely.ts @@ -795,11 +795,6 @@ interface ControlledTransactionProps extends KyselyProps { readonly releaseConnectionWhenDone?: boolean } -preventAwait( - ControlledTransaction, - "don't await ControlledTransaction instances directly. To commit or rollback the transaction you need to call the `commit` or `rollback` method", -) - export class Command { readonly #cb: () => Promise From 89296dd8102e1d8b87d4d0ffff62424ad15a24ba Mon Sep 17 00:00:00 2001 From: Igal Klebanov Date: Fri, 26 Apr 2024 19:31:09 +0300 Subject: [PATCH 06/22] use tedious for savepoint stuff. --- src/dialect/mssql/mssql-dialect-config.ts | 13 ++++------ src/dialect/mssql/mssql-driver.ts | 29 ++++++++++++----------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/src/dialect/mssql/mssql-dialect-config.ts b/src/dialect/mssql/mssql-dialect-config.ts index efac14bd1..d9b21ec18 100644 --- a/src/dialect/mssql/mssql-dialect-config.ts +++ b/src/dialect/mssql/mssql-dialect-config.ts @@ -65,17 +65,14 @@ export interface Tedious { export interface TediousConnection { beginTransaction( - callback: (error?: Error | null, transactionDescriptor?: any) => void, - name?: string, - isolationLevel?: number, + callback: (error?: Error | null) => void, + transactionId?: string | undefined, + isolationLevel?: number | undefined, ): void cancel(): boolean close(): void - commitTransaction( - callback: (error?: Error | null) => void, - name?: string, - ): void - connect(callback?: (error?: Error) => void): void + commitTransaction(callback: (error?: Error | null) => void): void + connect(callback: (error?: Error | null) => void): void execSql(request: TediousRequest): void off(event: 'error', listener: (error: unknown) => void): this off(event: string, listener: (...args: any[]) => void): this diff --git a/src/dialect/mssql/mssql-driver.ts b/src/dialect/mssql/mssql-driver.ts index 66677c1bc..19c7e246b 100644 --- a/src/dialect/mssql/mssql-driver.ts +++ b/src/dialect/mssql/mssql-driver.ts @@ -89,25 +89,17 @@ export class MssqlDriver implements Driver { } async savepoint( - connection: DatabaseConnection, + connection: MssqlConnection, savepointName: string, - compileQuery: QueryCompiler['compileQuery'], ): Promise { - await connection.executeQuery( - compileQuery(parseSavepointCommand('save transaction', savepointName)), - ) + await connection.savepoint(savepointName) } async rollbackToSavepoint( - connection: DatabaseConnection, + connection: MssqlConnection, savepointName: string, - compileQuery: QueryCompiler['compileQuery'], ): Promise { - await connection.executeQuery( - compileQuery( - parseSavepointCommand('rollback transaction', savepointName), - ), - ) + await connection.rollbackTransaction(savepointName) } async releaseSavepoint(): Promise { @@ -204,12 +196,21 @@ class MssqlConnection implements DatabaseConnection { } } - async rollbackTransaction(): Promise { + async rollbackTransaction(savepointName?: string): Promise { await new Promise((resolve, reject) => this.#connection.rollbackTransaction((error) => { if (error) reject(error) else resolve(undefined) - }), + }, savepointName), + ) + } + + async savepoint(savepointName: string): Promise { + await new Promise((resolve, reject) => + this.#connection.saveTransaction((error) => { + if (error) reject(error) + else resolve(undefined) + }, savepointName), ) } From 2f6bc241eac3bd1732620ff0b6acf17021107f23 Mon Sep 17 00:00:00 2001 From: igalklebanov Date: Sat, 27 Apr 2024 23:51:01 +0300 Subject: [PATCH 07/22] fix build issue with `= []` in generics. --- scripts/copy-interface-documentation.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/copy-interface-documentation.js b/scripts/copy-interface-documentation.js index 56a8a0df4..735886870 100644 --- a/scripts/copy-interface-documentation.js +++ b/scripts/copy-interface-documentation.js @@ -22,7 +22,7 @@ const OBJECT_REGEXES = [ /^(?:export )?declare (?:abstract )?class (\w+)/, /^(?:export )?interface (\w+)/, ] -const GENERIC_ARGUMENTS_REGEX = /<[\w"'`,{}=| ]+>/g +const GENERIC_ARGUMENTS_REGEX = /<[\w"'`,{}=|\[\] ]+>/g const JSDOC_START_REGEX = /^\s+\/\*\*/ const JSDOC_END_REGEX = /^\s+\*\// @@ -123,7 +123,7 @@ function parseObjects(file) { function parseImplements(line) { if (!line.endsWith('{')) { console.warn( - `skipping object declaration "${line}". Expected it to end with "{"'` + `skipping object declaration "${line}". Expected it to end with "{"'`, ) return [] } @@ -225,7 +225,7 @@ function findDocProperty(files, object, propertyName) { } const interfaceProperty = interfaceObject.properties.find( - (it) => it.name === propertyName + (it) => it.name === propertyName, ) if (interfaceProperty?.doc) { From 56ab464ff795bdd92ee9afb852dbd14c51cdcee4 Mon Sep 17 00:00:00 2001 From: igalklebanov Date: Sun, 28 Apr 2024 00:03:16 +0300 Subject: [PATCH 08/22] add missing overrides. --- src/kysely.ts | 44 ++++++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/src/kysely.ts b/src/kysely.ts index ffd612db6..3c939f308 100644 --- a/src/kysely.ts +++ b/src/kysely.ts @@ -711,10 +711,7 @@ export class ControlledTransaction< this.#compileQuery, ) - return new ControlledTransaction({ - ...this.#props, - connection: this.#props.connection, - }) + return new ControlledTransaction({ ...this.#props }) }) } @@ -730,10 +727,7 @@ export class ControlledTransaction< this.#compileQuery, ) - return new ControlledTransaction({ - ...this.#props, - connection: this.#props.connection, - }) + return new ControlledTransaction({ ...this.#props }) }) } @@ -749,13 +743,39 @@ export class ControlledTransaction< this.#compileQuery, ) - return new ControlledTransaction({ - ...this.#props, - connection: this.#props.connection, - }) + return new ControlledTransaction({ ...this.#props }) + }) + } + + override withPlugin(plugin: KyselyPlugin): ControlledTransaction { + return new ControlledTransaction({ + ...this.#props, + executor: this.#props.executor.withPlugin(plugin), + }) + } + + override withoutPlugins(): ControlledTransaction { + return new ControlledTransaction({ + ...this.#props, + executor: this.#props.executor.withoutPlugins(), }) } + override withSchema(schema: string): ControlledTransaction { + return new ControlledTransaction({ + ...this.#props, + executor: this.#props.executor.withPluginAtFront( + new WithSchemaPlugin(schema), + ), + }) + } + + override withTables< + T extends Record>, + >(): ControlledTransaction, S> { + return new ControlledTransaction({ ...this.#props }) + } + #assertNotCommittedOrRolledBackBeforeAllExecutions() { const { executor } = this.#props From 4f7983a4458ee683468e971e272cc801f5240cdf Mon Sep 17 00:00:00 2001 From: igalklebanov Date: Sun, 28 Apr 2024 00:19:14 +0300 Subject: [PATCH 09/22] solve connection usage. --- src/kysely.ts | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/kysely.ts b/src/kysely.ts index 3c939f308..e24442127 100644 --- a/src/kysely.ts +++ b/src/kysely.ts @@ -40,6 +40,7 @@ import { ReleaseSavepoint, RollbackToSavepoint, } from './parser/savepoint-parser.js' +import { Deferred } from './util/deferred.js' /** * The main Kysely class. @@ -621,7 +622,18 @@ export class ControlledTransactionBuilder { validateTransactionSettings(settings) - const connection = await this.#props.driver.acquireConnection() + const connectionDefer = new Deferred() + const connectionReleaseDefer = new Deferred() + + this.#props.executor + .provideConnection(async (connection) => { + connectionDefer.resolve(connection) + + return await connectionReleaseDefer.promise + }) + .catch((ex) => connectionDefer.reject(ex)) + + const connection = await connectionDefer.promise await this.#props.driver.beginTransaction(connection, settings) @@ -631,6 +643,7 @@ export class ControlledTransactionBuilder { executor: this.#props.executor.withConnectionProvider( new SingleConnectionProvider(connection), ), + releaseConnection: connectionReleaseDefer.resolve, }) } } @@ -654,11 +667,7 @@ export class ControlledTransaction< #isRolledBack: boolean constructor(props: ControlledTransactionProps) { - const { - connection, - releaseConnectionWhenDone: releaseConnection, - ...transactionProps - } = props + const { connection, releaseConnection, ...transactionProps } = props super(transactionProps) this.#props = freeze(props) @@ -685,7 +694,7 @@ export class ControlledTransaction< return new Command(async () => { await this.#props.driver.commitTransaction(this.#props.connection) this.#isCommitted = true - await this.#releaseConnectionIfNecessary() + this.#props.releaseConnection() }) } @@ -695,7 +704,7 @@ export class ControlledTransaction< return new Command(async () => { await this.#props.driver.rollbackTransaction(this.#props.connection) this.#isRolledBack = true - await this.#releaseConnectionIfNecessary() + this.#props.releaseConnection() }) } @@ -802,17 +811,11 @@ export class ControlledTransaction< throw new Error('Transaction is already rolled back') } } - - async #releaseConnectionIfNecessary(): Promise { - if (this.#props.releaseConnectionWhenDone !== false) { - await this.#props.driver.releaseConnection(this.#props.connection) - } - } } interface ControlledTransactionProps extends KyselyProps { readonly connection: DatabaseConnection - readonly releaseConnectionWhenDone?: boolean + readonly releaseConnection: () => void } export class Command { From af1179d3ab057fc02d6f9f830ee3e2fcfe11f448 Mon Sep 17 00:00:00 2001 From: igalklebanov Date: Sun, 28 Apr 2024 00:49:26 +0300 Subject: [PATCH 10/22] and now elegantly. --- src/kysely.ts | 28 ++++++++--------------- src/query-executor/query-executor-base.ts | 15 +++--------- src/util/provide-controlled-connection.ts | 27 ++++++++++++++++++++++ 3 files changed, 39 insertions(+), 31 deletions(-) create mode 100644 src/util/provide-controlled-connection.ts diff --git a/src/kysely.ts b/src/kysely.ts index e24442127..cb1fcfca2 100644 --- a/src/kysely.ts +++ b/src/kysely.ts @@ -40,7 +40,10 @@ import { ReleaseSavepoint, RollbackToSavepoint, } from './parser/savepoint-parser.js' -import { Deferred } from './util/deferred.js' +import { + ControlledConnection, + provideControlledConnection, +} from './util/provide-controlled-connection.js' /** * The main Kysely class. @@ -622,18 +625,7 @@ export class ControlledTransactionBuilder { validateTransactionSettings(settings) - const connectionDefer = new Deferred() - const connectionReleaseDefer = new Deferred() - - this.#props.executor - .provideConnection(async (connection) => { - connectionDefer.resolve(connection) - - return await connectionReleaseDefer.promise - }) - .catch((ex) => connectionDefer.reject(ex)) - - const connection = await connectionDefer.promise + const connection = await provideControlledConnection(this.#props.executor) await this.#props.driver.beginTransaction(connection, settings) @@ -643,7 +635,6 @@ export class ControlledTransactionBuilder { executor: this.#props.executor.withConnectionProvider( new SingleConnectionProvider(connection), ), - releaseConnection: connectionReleaseDefer.resolve, }) } } @@ -667,7 +658,7 @@ export class ControlledTransaction< #isRolledBack: boolean constructor(props: ControlledTransactionProps) { - const { connection, releaseConnection, ...transactionProps } = props + const { connection, ...transactionProps } = props super(transactionProps) this.#props = freeze(props) @@ -694,7 +685,7 @@ export class ControlledTransaction< return new Command(async () => { await this.#props.driver.commitTransaction(this.#props.connection) this.#isCommitted = true - this.#props.releaseConnection() + this.#props.connection.release() }) } @@ -704,7 +695,7 @@ export class ControlledTransaction< return new Command(async () => { await this.#props.driver.rollbackTransaction(this.#props.connection) this.#isRolledBack = true - this.#props.releaseConnection() + this.#props.connection.release() }) } @@ -814,8 +805,7 @@ export class ControlledTransaction< } interface ControlledTransactionProps extends KyselyProps { - readonly connection: DatabaseConnection - readonly releaseConnection: () => void + readonly connection: ControlledConnection } export class Command { diff --git a/src/query-executor/query-executor-base.ts b/src/query-executor/query-executor-base.ts index 8bc6d3db4..5aec6a5f4 100644 --- a/src/query-executor/query-executor-base.ts +++ b/src/query-executor/query-executor-base.ts @@ -12,6 +12,7 @@ import { DialectAdapter } from '../dialect/dialect-adapter.js' import { QueryExecutor } from './query-executor.js' import { Deferred } from '../util/deferred.js' import { logOnce } from '../util/log-once.js' +import { provideControlledConnection } from '../util/provide-controlled-connection.js' const NO_PLUGINS: ReadonlyArray = freeze([]) @@ -80,17 +81,7 @@ export abstract class QueryExecutorBase implements QueryExecutor { chunkSize: number, queryId: QueryId, ): AsyncIterableIterator> { - const connectionDefer = new Deferred() - const connectionReleaseDefer = new Deferred() - - this.provideConnection(async (connection) => { - connectionDefer.resolve(connection) - - // Lets wait until we don't need connection before returning here (returning releases connection) - return await connectionReleaseDefer.promise - }).catch((ex) => connectionDefer.reject(ex)) - - const connection = await connectionDefer.promise + const connection = await provideControlledConnection(this) try { for await (const result of connection.streamQuery( @@ -100,7 +91,7 @@ export abstract class QueryExecutorBase implements QueryExecutor { yield await this.#transformResult(result, queryId) } } finally { - connectionReleaseDefer.resolve() + connection.release() } } diff --git a/src/util/provide-controlled-connection.ts b/src/util/provide-controlled-connection.ts new file mode 100644 index 000000000..127712d19 --- /dev/null +++ b/src/util/provide-controlled-connection.ts @@ -0,0 +1,27 @@ +import { ConnectionProvider } from '../driver/connection-provider.js' +import { DatabaseConnection } from '../driver/database-connection.js' +import { Deferred } from './deferred.js' + +export async function provideControlledConnection( + connectionProvider: ConnectionProvider, +): Promise { + const connectionDefer = new Deferred() + const connectionReleaseDefer = new Deferred() + + connectionProvider + .provideConnection(async (connection) => { + connectionDefer.resolve(connection) + + return await connectionReleaseDefer.promise + }) + .catch((ex) => connectionDefer.reject(ex)) + + const connection = (await connectionDefer.promise) as ControlledConnection + connection.release = connectionReleaseDefer.resolve + + return connection +} + +export interface ControlledConnection extends DatabaseConnection { + release(): void +} From ce7a49224db8f56e44dd762092425ee90c091b14 Mon Sep 17 00:00:00 2001 From: igalklebanov Date: Sun, 28 Apr 2024 02:16:49 +0300 Subject: [PATCH 11/22] import type tedious. --- package.json | 8 ++++++++ src/dialect/mssql/mssql-dialect-config.ts | 2 ++ src/dialect/mssql/mssql-driver.ts | 13 +++++-------- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index da67bb071..d53058b27 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,14 @@ "Sami Koskimäki ", "Igal Klebanov " ], + "peerDependencies": { + "tedious": "~18.x" + }, + "peerDependenciesMeta": { + "tedious": { + "optional": true + } + }, "devDependencies": { "@types/better-sqlite3": "^7.6.11", "@types/chai": "^4.3.17", diff --git a/src/dialect/mssql/mssql-dialect-config.ts b/src/dialect/mssql/mssql-dialect-config.ts index d9b21ec18..f3959162e 100644 --- a/src/dialect/mssql/mssql-dialect-config.ts +++ b/src/dialect/mssql/mssql-dialect-config.ts @@ -1,3 +1,5 @@ +import type { Connection, ISOLATION_LEVEL, Request, TYPES } from 'tedious' + export interface MssqlDialectConfig { /** * This dialect uses the `tarn` package to manage the connection pool to your diff --git a/src/dialect/mssql/mssql-driver.ts b/src/dialect/mssql/mssql-driver.ts index 19c7e246b..6884f240d 100644 --- a/src/dialect/mssql/mssql-driver.ts +++ b/src/dialect/mssql/mssql-driver.ts @@ -23,15 +23,12 @@ import { TarnPool, Tedious, TediousColumnValue, - TediousConnection, - TediousRequest, } from './mssql-dialect-config.js' import { CompiledQuery } from '../../query-compiler/compiled-query.js' import { extendStackTrace } from '../../util/stack-trace-utils.js' import { randomString } from '../../util/random-string.js' import { Deferred } from '../../util/deferred.js' -import { parseSavepointCommand } from '../../parser/savepoint-parser.js' -import { QueryCompiler } from '../../query-compiler/query-compiler.js' +import type { Connection, Request } from 'tedious' const PRIVATE_RELEASE_METHOD = Symbol() const PRIVATE_DESTROY_METHOD = Symbol() @@ -119,10 +116,10 @@ export class MssqlDriver implements Driver { } class MssqlConnection implements DatabaseConnection { - readonly #connection: TediousConnection + readonly #connection: Connection readonly #tedious: Tedious - constructor(connection: TediousConnection, tedious: Tedious) { + constructor(connection: Connection, tedious: Tedious) { this.#connection = connection this.#tedious = tedious @@ -339,7 +336,7 @@ interface PlainDeferred { } class MssqlRequest { - readonly #request: TediousRequest + readonly #request: Request readonly #rows: O[] readonly #streamChunkSize: number | undefined readonly #subscribers: Record< @@ -399,7 +396,7 @@ class MssqlRequest { this.#attachListeners() } - get request(): TediousRequest { + get request(): Request { return this.#request } From 9e80ce5ed590c43309d988437f724c5b0bd1e9e6 Mon Sep 17 00:00:00 2001 From: igalklebanov Date: Sun, 28 Apr 2024 02:23:09 +0300 Subject: [PATCH 12/22] tests. --- test/node/src/controlled-transaction.test.ts | 532 +++++++++++++++++++ 1 file changed, 532 insertions(+) create mode 100644 test/node/src/controlled-transaction.test.ts diff --git a/test/node/src/controlled-transaction.test.ts b/test/node/src/controlled-transaction.test.ts new file mode 100644 index 000000000..6896f5c03 --- /dev/null +++ b/test/node/src/controlled-transaction.test.ts @@ -0,0 +1,532 @@ +import * as sinon from 'sinon' +import { Connection, ISOLATION_LEVEL } from 'tedious' +import { CompiledQuery, ControlledTransaction, IsolationLevel } from '../../../' +import { + DIALECTS, + Database, + TestContext, + clearDatabase, + destroyTest, + expect, + initTest, + insertDefaultDataSet, +} from './test-setup.js' + +for (const dialect of DIALECTS) { + describe(`${dialect}: controlled transaction`, () => { + let ctx: TestContext + const executedQueries: CompiledQuery[] = [] + const sandbox = sinon.createSandbox() + let tediousBeginTransactionSpy: sinon.SinonSpy< + Parameters, + ReturnType + > + let tediousCommitTransactionSpy: sinon.SinonSpy< + Parameters, + ReturnType + > + let tediousRollbackTransactionSpy: sinon.SinonSpy< + Parameters, + ReturnType + > + let tediousSaveTransactionSpy: sinon.SinonSpy< + Parameters, + ReturnType + > + + before(async function () { + ctx = await initTest(this, dialect, (event) => { + if (event.level === 'query') { + executedQueries.push(event.query) + } + }) + }) + + beforeEach(async () => { + await insertDefaultDataSet(ctx) + executedQueries.length = 0 + tediousBeginTransactionSpy = sandbox.spy( + Connection.prototype, + 'beginTransaction', + ) + tediousCommitTransactionSpy = sandbox.spy( + Connection.prototype, + 'commitTransaction', + ) + tediousRollbackTransactionSpy = sandbox.spy( + Connection.prototype, + 'rollbackTransaction', + ) + tediousSaveTransactionSpy = sandbox.spy( + Connection.prototype, + 'saveTransaction', + ) + }) + + afterEach(async () => { + await clearDatabase(ctx) + sandbox.restore() + }) + + after(async () => { + await destroyTest(ctx) + }) + + it('should be able to start and commit a transaction', async () => { + const trx = await ctx.db.startTransaction().execute() + + await insertSomething(trx) + + await trx.commit().execute() + + if (dialect == 'postgres') { + expect( + executedQueries.map((it) => ({ + sql: it.sql, + parameters: it.parameters, + })), + ).to.eql([ + { + sql: 'begin', + parameters: [], + }, + { + sql: 'insert into "person" ("first_name", "last_name", "gender") values ($1, $2, $3)', + parameters: ['Foo', 'Barson', 'male'], + }, + { sql: 'commit', parameters: [] }, + ]) + } else if (dialect === 'mysql') { + expect( + executedQueries.map((it) => ({ + sql: it.sql, + parameters: it.parameters, + })), + ).to.eql([ + { + sql: 'begin', + parameters: [], + }, + { + sql: 'insert into `person` (`first_name`, `last_name`, `gender`) values (?, ?, ?)', + parameters: ['Foo', 'Barson', 'male'], + }, + { sql: 'commit', parameters: [] }, + ]) + } else if (dialect === 'mssql') { + expect(tediousBeginTransactionSpy.calledOnce).to.be.true + expect(tediousBeginTransactionSpy.getCall(0).args[1]).to.be.undefined + expect(tediousBeginTransactionSpy.getCall(0).args[2]).to.be.undefined + + expect( + executedQueries.map((it) => ({ + sql: it.sql, + parameters: it.parameters, + })), + ).to.eql([ + { + sql: 'insert into "person" ("first_name", "last_name", "gender") values (@1, @2, @3)', + parameters: ['Foo', 'Barson', 'male'], + }, + ]) + + expect(tediousCommitTransactionSpy.calledOnce).to.be.true + } else { + expect( + executedQueries.map((it) => ({ + sql: it.sql, + parameters: it.parameters, + })), + ).to.eql([ + { + sql: 'begin', + parameters: [], + }, + { + sql: 'insert into "person" ("first_name", "last_name", "gender") values (?, ?, ?)', + parameters: ['Foo', 'Barson', 'male'], + }, + { sql: 'commit', parameters: [] }, + ]) + } + }) + + it('should be able to start and rollback a transaction', async () => { + const trx = await ctx.db.startTransaction().execute() + + await insertSomething(trx) + + await trx.rollback().execute() + + if (dialect == 'postgres') { + expect( + executedQueries.map((it) => ({ + sql: it.sql, + parameters: it.parameters, + })), + ).to.eql([ + { + sql: 'begin', + parameters: [], + }, + { + sql: 'insert into "person" ("first_name", "last_name", "gender") values ($1, $2, $3)', + parameters: ['Foo', 'Barson', 'male'], + }, + { sql: 'rollback', parameters: [] }, + ]) + } else if (dialect === 'mysql') { + expect( + executedQueries.map((it) => ({ + sql: it.sql, + parameters: it.parameters, + })), + ).to.eql([ + { + sql: 'begin', + parameters: [], + }, + { + sql: 'insert into `person` (`first_name`, `last_name`, `gender`) values (?, ?, ?)', + parameters: ['Foo', 'Barson', 'male'], + }, + { sql: 'rollback', parameters: [] }, + ]) + } else if (dialect === 'mssql') { + expect(tediousBeginTransactionSpy.calledOnce).to.be.true + expect(tediousBeginTransactionSpy.getCall(0).args[1]).to.be.undefined + expect(tediousBeginTransactionSpy.getCall(0).args[2]).to.be.undefined + + expect( + executedQueries.map((it) => ({ + sql: it.sql, + parameters: it.parameters, + })), + ).to.eql([ + { + sql: 'insert into "person" ("first_name", "last_name", "gender") values (@1, @2, @3)', + parameters: ['Foo', 'Barson', 'male'], + }, + ]) + + expect(tediousRollbackTransactionSpy.calledOnce).to.be.true + } else { + expect( + executedQueries.map((it) => ({ + sql: it.sql, + parameters: it.parameters, + })), + ).to.eql([ + { + sql: 'begin', + parameters: [], + }, + { + sql: 'insert into "person" ("first_name", "last_name", "gender") values (?, ?, ?)', + parameters: ['Foo', 'Barson', 'male'], + }, + { sql: 'rollback', parameters: [] }, + ]) + } + + const person = await ctx.db + .selectFrom('person') + .where('first_name', '=', 'Foo') + .select('first_name') + .executeTakeFirst() + + expect(person).to.be.undefined + }) + + if (dialect === 'postgres' || dialect === 'mysql' || dialect === 'mssql') { + for (const isolationLevel of [ + 'read uncommitted', + 'read committed', + 'repeatable read', + 'serializable', + ...(dialect === 'mssql' ? (['snapshot'] as const) : []), + ] satisfies IsolationLevel[]) { + it(`should set the transaction isolation level as "${isolationLevel}"`, async () => { + const trx = await ctx.db + .startTransaction() + .setIsolationLevel(isolationLevel) + .execute() + + await trx + .insertInto('person') + .values({ + first_name: 'Foo', + last_name: 'Barson', + gender: 'male', + }) + .execute() + + await trx.commit().execute() + }) + } + } + + it('should be able to start a transaction with a single connection', async () => { + const result = await ctx.db.connection().execute(async (conn) => { + const trx = await conn.startTransaction().execute() + + const result = await insertSomething(trx) + + await trx.commit().execute() + + return result + }) + + expect(result.numInsertedOrUpdatedRows).to.equal(1n) + await ctx.db + .selectFrom('person') + .where('first_name', '=', 'Foo') + .select('first_name') + .executeTakeFirstOrThrow() + }) + + it('should be able to savepoint and rollback to savepoint', async () => { + const trx = await ctx.db.startTransaction().execute() + + await insertSomething(trx) + + const trxAfterFoo = await trx.savepoint('foo').execute() + + await insertSomethingElse(trxAfterFoo) + + await trxAfterFoo.rollbackToSavepoint('foo').execute() + + await trxAfterFoo.commit().execute() + + if (dialect == 'postgres') { + expect( + executedQueries.map((it) => ({ + sql: it.sql, + parameters: it.parameters, + })), + ).to.eql([ + { sql: 'begin', parameters: [] }, + { + sql: 'insert into "person" ("first_name", "last_name", "gender") values ($1, $2, $3)', + parameters: ['Foo', 'Barson', 'male'], + }, + { sql: 'savepoint "foo"', parameters: [] }, + { + sql: 'insert into "person" ("first_name", "last_name", "gender") values ($1, $2, $3)', + parameters: ['Fizz', 'Buzzson', 'female'], + }, + { sql: 'rollback to "foo"', parameters: [] }, + { sql: 'commit', parameters: [] }, + ]) + } else if (dialect === 'mysql') { + expect( + executedQueries.map((it) => ({ + sql: it.sql, + parameters: it.parameters, + })), + ).to.eql([ + { sql: 'begin', parameters: [] }, + { + sql: 'insert into `person` (`first_name`, `last_name`, `gender`) values (?, ?, ?)', + parameters: ['Foo', 'Barson', 'male'], + }, + { sql: 'savepoint `foo`', parameters: [] }, + { + sql: 'insert into `person` (`first_name`, `last_name`, `gender`) values (?, ?, ?)', + parameters: ['Fizz', 'Buzzson', 'female'], + }, + { sql: 'rollback to `foo`', parameters: [] }, + { sql: 'commit', parameters: [] }, + ]) + } else if (dialect === 'mssql') { + expect(tediousBeginTransactionSpy.calledOnce).to.be.true + expect(tediousBeginTransactionSpy.getCall(0).args[1]).to.be.undefined + expect(tediousBeginTransactionSpy.getCall(0).args[2]).to.be.undefined + + expect( + executedQueries.map((it) => ({ + sql: it.sql, + parameters: it.parameters, + })), + ).to.eql([ + { + sql: 'insert into "person" ("first_name", "last_name", "gender") values (@1, @2, @3)', + parameters: ['Foo', 'Barson', 'male'], + }, + { + sql: 'insert into "person" ("first_name", "last_name", "gender") values (@1, @2, @3)', + parameters: ['Fizz', 'Buzzson', 'female'], + }, + ]) + + expect(tediousSaveTransactionSpy.calledOnce).to.be.true + expect(tediousSaveTransactionSpy.getCall(0).args[1]).to.equal('foo') + + expect(tediousRollbackTransactionSpy.calledOnce).to.be.true + expect(tediousRollbackTransactionSpy.getCall(0).args[1]).to.equal('foo') + + expect(tediousCommitTransactionSpy.calledOnce).to.be.true + } else { + expect( + executedQueries.map((it) => ({ + sql: it.sql, + parameters: it.parameters, + })), + ).to.eql([ + { sql: 'begin', parameters: [] }, + { + sql: 'insert into "person" ("first_name", "last_name", "gender") values (?, ?, ?)', + parameters: ['Foo', 'Barson', 'male'], + }, + { sql: 'savepoint "foo"', parameters: [] }, + { + sql: 'insert into "person" ("first_name", "last_name", "gender") values (?, ?, ?)', + parameters: ['Fizz', 'Buzzson', 'female'], + }, + { sql: 'rollback to "foo"', parameters: [] }, + { sql: 'commit', parameters: [] }, + ]) + } + + const results = await ctx.db + .selectFrom('person') + .where('first_name', 'in', ['Foo', 'Fizz']) + .select('first_name') + .execute() + + expect(results).to.have.length(1) + expect(results[0].first_name).to.equal('Foo') + }) + + if (dialect === 'postgres' || dialect === 'mysql' || dialect === 'sqlite') { + it('should be able to savepoint and release savepoint', async () => { + const trx = await ctx.db.startTransaction().execute() + + await insertSomething(trx) + + const trxAfterFoo = await trx.savepoint('foo').execute() + + await insertSomethingElse(trxAfterFoo) + + await trxAfterFoo.releaseSavepoint('foo').execute() + + await trxAfterFoo.commit().execute() + + if (dialect == 'postgres') { + expect( + executedQueries.map((it) => ({ + sql: it.sql, + parameters: it.parameters, + })), + ).to.eql([ + { sql: 'begin', parameters: [] }, + { + sql: 'insert into "person" ("first_name", "last_name", "gender") values ($1, $2, $3)', + parameters: ['Foo', 'Barson', 'male'], + }, + { sql: 'savepoint "foo"', parameters: [] }, + { + sql: 'insert into "person" ("first_name", "last_name", "gender") values ($1, $2, $3)', + parameters: ['Fizz', 'Buzzson', 'female'], + }, + { sql: 'release "foo"', parameters: [] }, + { sql: 'commit', parameters: [] }, + ]) + } else if (dialect === 'mysql') { + expect( + executedQueries.map((it) => ({ + sql: it.sql, + parameters: it.parameters, + })), + ).to.eql([ + { sql: 'begin', parameters: [] }, + { + sql: 'insert into `person` (`first_name`, `last_name`, `gender`) values (?, ?, ?)', + parameters: ['Foo', 'Barson', 'male'], + }, + { sql: 'savepoint `foo`', parameters: [] }, + { + sql: 'insert into `person` (`first_name`, `last_name`, `gender`) values (?, ?, ?)', + parameters: ['Fizz', 'Buzzson', 'female'], + }, + { sql: 'release savepoint `foo`', parameters: [] }, + { sql: 'commit', parameters: [] }, + ]) + } else { + expect( + executedQueries.map((it) => ({ + sql: it.sql, + parameters: it.parameters, + })), + ).to.eql([ + { sql: 'begin', parameters: [] }, + { + sql: 'insert into "person" ("first_name", "last_name", "gender") values (?, ?, ?)', + parameters: ['Foo', 'Barson', 'male'], + }, + { sql: 'savepoint "foo"', parameters: [] }, + { + sql: 'insert into "person" ("first_name", "last_name", "gender") values (?, ?, ?)', + parameters: ['Fizz', 'Buzzson', 'female'], + }, + { sql: 'release "foo"', parameters: [] }, + { sql: 'commit', parameters: [] }, + ]) + } + + const results = await ctx.db + .selectFrom('person') + .where('first_name', 'in', ['Foo', 'Fizz']) + .select('first_name') + .orderBy('first_name') + .execute() + + expect(results).to.have.length(2) + expect(results[0].first_name).to.equal('Fizz') + expect(results[1].first_name).to.equal('Foo') + }) + } + + it('should throw an error when trying to execute a query after the transaction has been committed', async () => { + const trx = await ctx.db.startTransaction().execute() + + await insertSomething(trx) + + await trx.commit().execute() + + await expect(insertSomethingElse(trx)).to.be.rejected + }) + + it('should throw an error when trying to execute a query after the transaction has been rolled back', async () => { + const trx = await ctx.db.startTransaction().execute() + + await insertSomething(trx) + + await trx.rollback().execute() + + await expect(insertSomethingElse(trx)).to.be.rejected + }) + }) + + async function insertSomething(trx: ControlledTransaction) { + return await trx + .insertInto('person') + .values({ + first_name: 'Foo', + last_name: 'Barson', + gender: 'male', + }) + .executeTakeFirstOrThrow() + } + + async function insertSomethingElse(trx: ControlledTransaction) { + return await trx + .insertInto('person') + .values({ + first_name: 'Fizz', + last_name: 'Buzzson', + gender: 'female', + }) + .executeTakeFirstOrThrow() + } +} From d4d88929a904733e7449e6be71d4be458c927900 Mon Sep 17 00:00:00 2001 From: Igal Klebanov Date: Sun, 28 Apr 2024 23:41:08 +0300 Subject: [PATCH 13/22] dont use real types from tedious. --- package.json | 8 -------- src/dialect/mssql/mssql-dialect-config.ts | 2 +- src/dialect/mssql/mssql-driver.ts | 11 ++++++----- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index d53058b27..da67bb071 100644 --- a/package.json +++ b/package.json @@ -69,14 +69,6 @@ "Sami Koskimäki ", "Igal Klebanov " ], - "peerDependencies": { - "tedious": "~18.x" - }, - "peerDependenciesMeta": { - "tedious": { - "optional": true - } - }, "devDependencies": { "@types/better-sqlite3": "^7.6.11", "@types/chai": "^4.3.17", diff --git a/src/dialect/mssql/mssql-dialect-config.ts b/src/dialect/mssql/mssql-dialect-config.ts index f3959162e..607aa4983 100644 --- a/src/dialect/mssql/mssql-dialect-config.ts +++ b/src/dialect/mssql/mssql-dialect-config.ts @@ -1,4 +1,4 @@ -import type { Connection, ISOLATION_LEVEL, Request, TYPES } from 'tedious' +import { Request } from 'tedious' export interface MssqlDialectConfig { /** diff --git a/src/dialect/mssql/mssql-driver.ts b/src/dialect/mssql/mssql-driver.ts index 6884f240d..838ee09d8 100644 --- a/src/dialect/mssql/mssql-driver.ts +++ b/src/dialect/mssql/mssql-driver.ts @@ -23,12 +23,13 @@ import { TarnPool, Tedious, TediousColumnValue, + TediousConnection, + TediousRequest, } from './mssql-dialect-config.js' import { CompiledQuery } from '../../query-compiler/compiled-query.js' import { extendStackTrace } from '../../util/stack-trace-utils.js' import { randomString } from '../../util/random-string.js' import { Deferred } from '../../util/deferred.js' -import type { Connection, Request } from 'tedious' const PRIVATE_RELEASE_METHOD = Symbol() const PRIVATE_DESTROY_METHOD = Symbol() @@ -116,10 +117,10 @@ export class MssqlDriver implements Driver { } class MssqlConnection implements DatabaseConnection { - readonly #connection: Connection + readonly #connection: TediousConnection readonly #tedious: Tedious - constructor(connection: Connection, tedious: Tedious) { + constructor(connection: TediousConnection, tedious: Tedious) { this.#connection = connection this.#tedious = tedious @@ -336,7 +337,7 @@ interface PlainDeferred { } class MssqlRequest { - readonly #request: Request + readonly #request: TediousRequest readonly #rows: O[] readonly #streamChunkSize: number | undefined readonly #subscribers: Record< @@ -396,7 +397,7 @@ class MssqlRequest { this.#attachListeners() } - get request(): Request { + get request(): TediousRequest { return this.#request } From e88584e9f46f2c81b1ea71f0570531d73f784ac2 Mon Sep 17 00:00:00 2001 From: Igal Klebanov Date: Mon, 29 Apr 2024 12:27:28 +0300 Subject: [PATCH 14/22] jsdocs --- src/kysely.ts | 49 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/kysely.ts b/src/kysely.ts index cb1fcfca2..5157c5629 100644 --- a/src/kysely.ts +++ b/src/kysely.ts @@ -219,6 +219,9 @@ export class Kysely * of type {@link Transaction} which inherits {@link Kysely}. Any query * started through the transaction object is executed inside the transaction. * + * To run a controlled transaction, allowing you to commit and rollback manually, + * use {@link startTransaction} instead. + * * ### Examples * * @@ -266,7 +269,51 @@ export class Kysely } /** - * TODO: ... + * Creates a {@link ControlledTransactionBuilder} that can be used to run queries inside a controlled transaction. + * + * The returned {@link ControlledTransactionBuilder} can be used to configure the transaction. + * The {@link ControlledTransactionBuilder.execute} method can then be called + * to start the transaction and return a {@link ControlledTransaction}. + * + * A {@link ControlledTransaction} allows you to commit and rollback manually, execute savepoint commands. It extends {@link Transaction} which extends {@link Kysely}, so you can run queries inside the transaction. + * + * ### Examples + * + * + * + * A controlled transaction allows you to commit and rollback manually, execute savepoint commands, and queries in general. In this example we start a transaction, use it to insert two rows and then commit the transaction. + * If an error is thrown, we catch it and rollback the transaction. + * + * ```ts + * const trx = await db.startTransaction().execute() + * + * try { + * const jennifer = await trx.insertInto('person') + * .values({ + * first_name: 'Jennifer', + * last_name: 'Aniston', + * age: 40, + * }) + * .returning('id') + * .executeTakeFirstOrThrow() + * + * const catto = await trx.insertInto('pet') + * .values({ + * owner_id: jennifer.id, + * name: 'Catto', + * species: 'cat', + * is_favorite: false, + * }) + * .returningAll() + * .executeTakeFirstOrThrow() + * + * await trx.commit().execute() + * + * return catto + * } catch (error) { + * await trx.rollback().execute() + * } + * ``` */ startTransaction(): ControlledTransactionBuilder { return new ControlledTransactionBuilder({ ...this.#props }) From 9764c568a000d52fd751c164b09063ec9f20f078 Mon Sep 17 00:00:00 2001 From: igalklebanov Date: Mon, 29 Apr 2024 13:35:26 +0300 Subject: [PATCH 15/22] jsdocs --- src/kysely.ts | 64 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/src/kysely.ts b/src/kysely.ts index 5157c5629..4be8d25a0 100644 --- a/src/kysely.ts +++ b/src/kysely.ts @@ -281,8 +281,11 @@ export class Kysely * * * - * A controlled transaction allows you to commit and rollback manually, execute savepoint commands, and queries in general. In this example we start a transaction, use it to insert two rows and then commit the transaction. - * If an error is thrown, we catch it and rollback the transaction. + * A controlled transaction allows you to commit and rollback manually, execute + * savepoint commands, and queries in general. + * + * In this example we start a transaction, use it to insert two rows and then commit + * the transaction. If an error is thrown, we catch it and rollback the transaction. * * ```ts * const trx = await db.startTransaction().execute() @@ -314,6 +317,63 @@ export class Kysely * await trx.rollback().execute() * } * ``` + * + * + * + * A controlled transaction allows you to commit and rollback manually, execute + * savepoint commands, and queries in general. + * + * In this example we start a transaction, insert a person, create a savepoint, + * try inserting a toy and a pet, and if an error is thrown, we rollback to the + * savepoint. Eventually we release the savepoint, insert an audit record and + * commit the transaction. If an error is thrown, we catch it and rollback the + * transaction. + * + * ```ts + * const trx = await db.startTransaction().execute() + * + * try { + * const jennifer = await trx + * .insertInto('person') + * .values({ + * first_name: 'Jennifer', + * last_name: 'Aniston', + * age: 40, + * }) + * .returning('id') + * .executeTakeFirstOrThrow() + * + * const trxAfterJennifer = await trx.savepoint('after_jennifer').execute() + * + * try { + * const bone = await trxAfterJennifer + * .insertInto('toy') + * .values({ name: 'Bone', price: 1.99 }) + * .returning('id') + * .executeTakeFirstOrThrow() + * + * await trxAfterJennifer + * .insertInto('pet') + * .values({ + * owner_id: jennifer.id, + * name: 'Catto', + * species: 'cat', + * favorite_toy_id: bone.id, + * }) + * .execute() + * } catch (error) { + * await trxAfterJennifer.rollbackToSavepoint('after_jennifer').execute() + * } + * + * await trxAfterJennifer.releaseSavepoint('after_jennifer').execute() + * + * await trx.insertInto('audit').values({ action: 'added Jennifer' }).execute() + * + * await trx.commit().execute() + * } catch (error) { + * await trx.rollback().execute() + * } + * ``` */ startTransaction(): ControlledTransactionBuilder { return new ControlledTransactionBuilder({ ...this.#props }) From 1b849930bd05411fce477f85487adb46bb2fe37a Mon Sep 17 00:00:00 2001 From: igalklebanov Date: Mon, 29 Apr 2024 14:30:11 +0300 Subject: [PATCH 16/22] jsdocs. --- src/kysely.ts | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/src/kysely.ts b/src/kysely.ts index 4be8d25a0..ddb1f7760 100644 --- a/src/kysely.ts +++ b/src/kysely.ts @@ -786,6 +786,23 @@ export class ControlledTransaction< return this.#isRolledBack } + /** + * Commits the transaction. + * + * See {@link rollback}. + * + * ### Examples + * + * ```ts + * try { + * await doSomething(trx) + * + * await trx.commit().execute() + * } catch (error) { + * await trx.rollback().execute() + * } + * ``` + */ commit(): Command { this.#assertNotCommittedOrRolledBack() @@ -796,6 +813,23 @@ export class ControlledTransaction< }) } + /** + * Rolls back the transaction. + * + * See {@link commit} and {@link rollbackToSavepoint}. + * + * ### Examples + * + * ```ts + * try { + * await doSomething(trx) + * + * await trx.commit().execute() + * } catch (error) { + * await trx.rollback().execute() + * } + * ``` + */ rollback(): Command { this.#assertNotCommittedOrRolledBack() @@ -806,6 +840,27 @@ export class ControlledTransaction< }) } + /** + * Creates a savepoint with a given name. + * + * See {@link rollbackToSavepoint} and {@link releaseSavepoint}. + * + * For a type-safe experience, you should use the returned instance from now on. + * + * ### Examples + * + * ```ts + * await insertJennifer(trx) + * + * const trxAfterJennifer = await trx.savepoint('after_jennifer').execute() + * + * try { + * await doSomething(trxAfterJennifer) + * } catch (error) { + * await trxAfterJennifer.rollbackToSavepoint('after_jennifer').execute() + * } + * ``` + */ savepoint( savepointName: SN extends S ? never : SN, ): Command> { @@ -822,6 +877,28 @@ export class ControlledTransaction< }) } + /** + * Rolls back to a savepoint with a given name. + * + * See {@link savepoint} and {@link releaseSavepoint}. + * + * You must use the same instance returned by {@link savepoint}, or + * escape the type-check by using `as any`. + * + * ### Examples + * + * ```ts + * await insertJennifer(trx) + * + * const trxAfterJennifer = await trx.savepoint('after_jennifer').execute() + * + * try { + * await doSomething(trxAfterJennifer) + * } catch (error) { + * await trxAfterJennifer.rollbackToSavepoint('after_jennifer').execute() + * } + * ``` + */ rollbackToSavepoint( savepointName: SN, ): Command>> { @@ -838,6 +915,32 @@ export class ControlledTransaction< }) } + /** + * Releases a savepoint with a given name. + * + * See {@link savepoint} and {@link rollbackToSavepoint}. + * + * You must use the same instance returned by {@link savepoint}, or + * escape the type-check by using `as any`. + * + * ### Examples + * + * ```ts + * await insertJennifer(trx) + * + * const trxAfterJennifer = await trx.savepoint('after_jennifer').execute() + * + * try { + * await doSomething(trxAfterJennifer) + * } catch (error) { + * await trxAfterJennifer.rollbackToSavepoint('after_jennifer').execute() + * } + * + * await trxAfterJennifer.releaseSavepoint('after_jennifer').execute() + * + * await doSomethingElse(trx) + * ``` + */ releaseSavepoint( savepointName: SN, ): Command>> { From 19fb5e1d4cbf68a6a9e3e2cb4ee85463550780f7 Mon Sep 17 00:00:00 2001 From: igalklebanov Date: Mon, 29 Apr 2024 14:32:47 +0300 Subject: [PATCH 17/22] jsdocs. --- src/kysely.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/kysely.ts b/src/kysely.ts index ddb1f7760..32a21bd9c 100644 --- a/src/kysely.ts +++ b/src/kysely.ts @@ -275,7 +275,12 @@ export class Kysely * The {@link ControlledTransactionBuilder.execute} method can then be called * to start the transaction and return a {@link ControlledTransaction}. * - * A {@link ControlledTransaction} allows you to commit and rollback manually, execute savepoint commands. It extends {@link Transaction} which extends {@link Kysely}, so you can run queries inside the transaction. + * A {@link ControlledTransaction} allows you to commit and rollback manually, + * execute savepoint commands. It extends {@link Transaction} which extends {@link Kysely}, + * so you can run queries inside the transaction. Once the transaction is committed, + * or rolled back, it can't be used anymore - all queries will throw an error. + * This is to prevent accidentally running queries outside the transaction - where + * atomicity is not guaranteed anymore. * * ### Examples * From 1c21909b7e1292fee15def1e159695e72e5d29a0 Mon Sep 17 00:00:00 2001 From: igalklebanov Date: Sat, 5 Oct 2024 01:28:07 +0300 Subject: [PATCH 18/22] remove preventAwait. --- src/kysely.ts | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/kysely.ts b/src/kysely.ts index 32a21bd9c..030e2d4be 100644 --- a/src/kysely.ts +++ b/src/kysely.ts @@ -22,10 +22,7 @@ import { } from './query-builder/function-module.js' import { Log, LogConfig } from './util/log.js' import { QueryExecutorProvider } from './query-executor/query-executor-provider.js' -import { - DatabaseConnection, - QueryResult, -} from './driver/database-connection.js' +import { QueryResult } from './driver/database-connection.js' import { CompiledQuery } from './query-compiler/compiled-query.js' import { createQueryId, QueryId } from './util/query-id.js' import { Compilable, isCompilable } from './util/compilable.js' @@ -755,11 +752,6 @@ interface ControlledTransactionBuilderProps extends TransactionBuilderProps { readonly releaseConnection?: boolean } -preventAwait( - ControlledTransactionBuilder, - "don't await ControlledTransactionBuilder instances directly. To execute the transaction you need to call the `execute` method", -) - export class ControlledTransaction< DB, S extends string[] = [], @@ -1037,8 +1029,3 @@ export class Command { return await this.#cb() } } - -preventAwait( - Command, - "don't await Command instances directly. Call the `execute` method", -) From 96eb16d871ae005cb205ab1766053053f18b32a1 Mon Sep 17 00:00:00 2001 From: igalklebanov Date: Sat, 5 Oct 2024 01:37:17 +0300 Subject: [PATCH 19/22] remove Request import. --- src/dialect/mssql/mssql-dialect-config.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/dialect/mssql/mssql-dialect-config.ts b/src/dialect/mssql/mssql-dialect-config.ts index 607aa4983..d9b21ec18 100644 --- a/src/dialect/mssql/mssql-dialect-config.ts +++ b/src/dialect/mssql/mssql-dialect-config.ts @@ -1,5 +1,3 @@ -import { Request } from 'tedious' - export interface MssqlDialectConfig { /** * This dialect uses the `tarn` package to manage the connection pool to your From 731ade6d6529f1634b63e77b32bc4a59d48e426a Mon Sep 17 00:00:00 2001 From: igalklebanov Date: Sat, 5 Oct 2024 01:49:59 +0300 Subject: [PATCH 20/22] align tedious types. --- src/dialect/mssql/mssql-dialect-config.ts | 25 +++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/dialect/mssql/mssql-dialect-config.ts b/src/dialect/mssql/mssql-dialect-config.ts index d9b21ec18..cbb033949 100644 --- a/src/dialect/mssql/mssql-dialect-config.ts +++ b/src/dialect/mssql/mssql-dialect-config.ts @@ -65,14 +65,20 @@ export interface Tedious { export interface TediousConnection { beginTransaction( - callback: (error?: Error | null) => void, - transactionId?: string | undefined, + callback: ( + err: Error | null | undefined, + transactionDescriptor?: any, + ) => void, + name?: string | undefined, isolationLevel?: number | undefined, ): void cancel(): boolean close(): void - commitTransaction(callback: (error?: Error | null) => void): void - connect(callback: (error?: Error | null) => void): void + commitTransaction( + callback: (err: Error | null | undefined) => void, + name?: string | undefined, + ): void + connect(connectListener: (err?: Error) => void): void execSql(request: TediousRequest): void off(event: 'error', listener: (error: unknown) => void): this off(event: string, listener: (...args: any[]) => void): this @@ -80,12 +86,15 @@ export interface TediousConnection { on(event: string, listener: (...args: any[]) => void): this once(event: 'end', listener: () => void): this once(event: string, listener: (...args: any[]) => void): this - reset(callback: (error?: Error | null) => void): void + reset(callback: (err: Error | null | undefined) => void): void rollbackTransaction( - callback: (error?: Error | null) => void, - name?: string, + callback: (err: Error | null | undefined) => void, + name?: string | undefined, + ): void + saveTransaction( + callback: (err: Error | null | undefined) => void, + name: string, ): void - saveTransaction(callback: (error?: Error | null) => void, name: string): void } export type TediousIsolationLevel = Record From a43c3283acf4d7636969dae3b113d6724433be82 Mon Sep 17 00:00:00 2001 From: igalklebanov Date: Sat, 5 Oct 2024 17:56:15 +0300 Subject: [PATCH 21/22] tighten single connection test case. --- test/node/src/controlled-transaction.test.ts | 40 +++++++++++++------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/test/node/src/controlled-transaction.test.ts b/test/node/src/controlled-transaction.test.ts index 6896f5c03..df1a44815 100644 --- a/test/node/src/controlled-transaction.test.ts +++ b/test/node/src/controlled-transaction.test.ts @@ -1,6 +1,6 @@ import * as sinon from 'sinon' -import { Connection, ISOLATION_LEVEL } from 'tedious' -import { CompiledQuery, ControlledTransaction, IsolationLevel } from '../../../' +import { Connection } from 'tedious' +import { CompiledQuery, IsolationLevel, Kysely } from '../../../' import { DIALECTS, Database, @@ -10,6 +10,7 @@ import { expect, initTest, insertDefaultDataSet, + limit, } from './test-setup.js' for (const dialect of DIALECTS) { @@ -267,22 +268,35 @@ for (const dialect of DIALECTS) { } it('should be able to start a transaction with a single connection', async () => { - const result = await ctx.db.connection().execute(async (conn) => { + await ctx.db.connection().execute(async (conn) => { const trx = await conn.startTransaction().execute() - const result = await insertSomething(trx) + await insertSomething(trx) await trx.commit().execute() - return result + await insertSomethingElse(conn) + + const trx2 = await conn.startTransaction().execute() + + await insertSomething(trx2) + + await trx2.rollback().execute() + + await insertSomethingElse(conn) }) - expect(result.numInsertedOrUpdatedRows).to.equal(1n) - await ctx.db + const results = await ctx.db .selectFrom('person') - .where('first_name', '=', 'Foo') .select('first_name') - .executeTakeFirstOrThrow() + .orderBy('id', 'desc') + .$call(limit(3, dialect)) + .execute() + expect(results).to.eql([ + { first_name: 'Fizz' }, + { first_name: 'Fizz' }, + { first_name: 'Foo' }, + ]) }) it('should be able to savepoint and rollback to savepoint', async () => { @@ -508,8 +522,8 @@ for (const dialect of DIALECTS) { }) }) - async function insertSomething(trx: ControlledTransaction) { - return await trx + async function insertSomething(db: Kysely) { + return await db .insertInto('person') .values({ first_name: 'Foo', @@ -519,8 +533,8 @@ for (const dialect of DIALECTS) { .executeTakeFirstOrThrow() } - async function insertSomethingElse(trx: ControlledTransaction) { - return await trx + async function insertSomethingElse(db: Kysely) { + return await db .insertInto('person') .values({ first_name: 'Fizz', From c24a251505f1eecfca29ecbe730d1dbb38b5d80d Mon Sep 17 00:00:00 2001 From: igalklebanov Date: Sat, 5 Oct 2024 19:52:39 +0300 Subject: [PATCH 22/22] tighten tests further. --- src/dialect/mssql/mssql-driver.ts | 6 - src/driver/dummy-driver.ts | 12 ++ src/driver/runtime-driver.ts | 10 +- test/node/src/controlled-transaction.test.ts | 131 +++++++++++++++---- 4 files changed, 126 insertions(+), 33 deletions(-) diff --git a/src/dialect/mssql/mssql-driver.ts b/src/dialect/mssql/mssql-driver.ts index 838ee09d8..8ba60841e 100644 --- a/src/dialect/mssql/mssql-driver.ts +++ b/src/dialect/mssql/mssql-driver.ts @@ -100,12 +100,6 @@ export class MssqlDriver implements Driver { await connection.rollbackTransaction(savepointName) } - async releaseSavepoint(): Promise { - throw new Error( - 'MS SQL Server (mssql) does not support releasing savepoints', - ) - } - async releaseConnection(connection: MssqlConnection): Promise { await connection[PRIVATE_RELEASE_METHOD]() this.#pool.release(connection) diff --git a/src/driver/dummy-driver.ts b/src/driver/dummy-driver.ts index 4a4d00fe6..7a457c8bb 100644 --- a/src/driver/dummy-driver.ts +++ b/src/driver/dummy-driver.ts @@ -65,6 +65,18 @@ export class DummyDriver implements Driver { async destroy(): Promise { // Nothing to do here. } + + async releaseSavepoint(): Promise { + // Nothing to do here. + } + + async rollbackToSavepoint(): Promise { + // Nothing to do here. + } + + async savepoint(): Promise { + // Nothing to do here. + } } class DummyConnection implements DatabaseConnection { diff --git a/src/driver/runtime-driver.ts b/src/driver/runtime-driver.ts index cf7563412..d05467ccf 100644 --- a/src/driver/runtime-driver.ts +++ b/src/driver/runtime-driver.ts @@ -95,7 +95,7 @@ export class RuntimeDriver implements Driver { return this.#driver.savepoint(connection, savepointName, compileQuery) } - throw new Error('savepoints are not supported by this driver') + throw new Error('The `savepoint` method is not supported by this driver') } rollbackToSavepoint( @@ -111,7 +111,9 @@ export class RuntimeDriver implements Driver { ) } - throw new Error('savepoints are not supported by this driver') + throw new Error( + 'The `rollbackToSavepoint` method is not supported by this driver', + ) } releaseSavepoint( @@ -127,7 +129,9 @@ export class RuntimeDriver implements Driver { ) } - throw new Error('savepoints are not supported by this driver') + throw new Error( + 'The `releaseSavepoint` method is not supported by this driver', + ) } async destroy(): Promise { diff --git a/test/node/src/controlled-transaction.test.ts b/test/node/src/controlled-transaction.test.ts index df1a44815..5821ef11d 100644 --- a/test/node/src/controlled-transaction.test.ts +++ b/test/node/src/controlled-transaction.test.ts @@ -1,6 +1,14 @@ import * as sinon from 'sinon' import { Connection } from 'tedious' -import { CompiledQuery, IsolationLevel, Kysely } from '../../../' +import { + CompiledQuery, + ControlledTransaction, + Driver, + DummyDriver, + IsolationLevel, + Kysely, + SqliteDialect, +} from '../../../' import { DIALECTS, Database, @@ -501,6 +509,20 @@ for (const dialect of DIALECTS) { }) } + if (dialect === 'mssql') { + it('should throw an error when trying to release a savepoint as it is not supported', async () => { + const trx = await ctx.db.startTransaction().execute() + + await expect( + trx.releaseSavepoint('foo' as never).execute(), + ).to.be.rejectedWith( + 'The `releaseSavepoint` method is not supported by this driver', + ) + + await trx.rollback().execute() + }) + } + it('should throw an error when trying to execute a query after the transaction has been committed', async () => { const trx = await ctx.db.startTransaction().execute() @@ -508,7 +530,9 @@ for (const dialect of DIALECTS) { await trx.commit().execute() - await expect(insertSomethingElse(trx)).to.be.rejected + await expect(insertSomethingElse(trx)).to.be.rejectedWith( + 'Transaction is already committed', + ) }) it('should throw an error when trying to execute a query after the transaction has been rolled back', async () => { @@ -518,29 +542,88 @@ for (const dialect of DIALECTS) { await trx.rollback().execute() - await expect(insertSomethingElse(trx)).to.be.rejected + await expect(insertSomethingElse(trx)).to.be.rejectedWith( + 'Transaction is already rolled back', + ) }) }) +} - async function insertSomething(db: Kysely) { - return await db - .insertInto('person') - .values({ - first_name: 'Foo', - last_name: 'Barson', - gender: 'male', - }) - .executeTakeFirstOrThrow() - } - - async function insertSomethingElse(db: Kysely) { - return await db - .insertInto('person') - .values({ - first_name: 'Fizz', - last_name: 'Buzzson', - gender: 'female', - }) - .executeTakeFirstOrThrow() - } +describe('custom dialect: controlled transaction', () => { + const db = new Kysely({ + dialect: new (class extends SqliteDialect { + createDriver(): Driver { + const driver = class extends DummyDriver {} + + // @ts-ignore + driver.prototype.releaseSavepoint = undefined + // @ts-ignore + driver.prototype.rollbackToSavepoint = undefined + // @ts-ignore + driver.prototype.savepoint = undefined + + return new driver() + } + // @ts-ignore + })({}), + }) + let trx: ControlledTransaction + + before(async () => { + trx = await db.startTransaction().execute() + }) + + after(async () => { + await trx.rollback().execute() + }) + + it('should throw an error when trying to savepoint on a dialect that does not support it', async () => { + const trx = await db.startTransaction().execute() + + await expect(trx.savepoint('foo').execute()).to.be.rejectedWith( + 'The `savepoint` method is not supported by this driver', + ) + }) + + it('should throw an error when trying to rollback to a savepoint on a dialect that does not support it', async () => { + const trx = await db.startTransaction().execute() + + await expect( + trx.rollbackToSavepoint('foo' as never).execute(), + ).to.be.rejectedWith( + 'The `rollbackToSavepoint` method is not supported by this driver', + ) + }) + + it('should throw an error when trying to release a savepoint on a dialect that does not support it', async () => { + const trx = await db.startTransaction().execute() + + await expect( + trx.releaseSavepoint('foo' as never).execute(), + ).to.be.rejectedWith( + 'The `releaseSavepoint` method is not supported by this driver', + ) + }) +}) + +async function insertSomething(db: Kysely) { + return await db + .insertInto('person') + .values({ + first_name: 'Foo', + last_name: 'Barson', + gender: 'male', + }) + .executeTakeFirstOrThrow() +} + +async function insertSomethingElse(db: Kysely) { + return await db + .insertInto('person') + .values({ + first_name: 'Fizz', + last_name: 'Buzzson', + gender: 'female', + }) + .executeTakeFirstOrThrow() }