Skip to content

Commit

Permalink
feat: Allow read-only transactions in Postgres and MySQL (#1342)
Browse files Browse the repository at this point in the history
* feat: Allow read-only transactions in Postgres and MySQL

Closes #1341

* use `accessMode` instead

* add tests

* access mode type and validation, rearranging stuff a bit.

Co-authored-by: Martin Adámek <[email protected]>

* rearranging stuff a bit.

Co-authored-by: Martin Adámek <[email protected]>

* align/simplify tests a bit

Co-authored-by: Martin Adámek <[email protected]>

---------

Co-authored-by: igalklebanov <[email protected]>
Co-authored-by: Martin Adámek <[email protected]>
  • Loading branch information
3 people authored Feb 9, 2025
1 parent bc82a70 commit 7694816
Show file tree
Hide file tree
Showing 6 changed files with 235 additions and 92 deletions.
18 changes: 12 additions & 6 deletions src/dialect/mysql/mysql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,19 @@ export class MysqlDriver implements Driver {
connection: DatabaseConnection,
settings: TransactionSettings,
): Promise<void> {
if (settings.isolationLevel) {
if (settings.isolationLevel || settings.accessMode) {
let sql = 'set transaction'

if (settings.isolationLevel) {
sql += ` isolation level ${settings.isolationLevel}`
}

if (settings.accessMode) {
sql += ` ${settings.accessMode}`
}

// On MySQL this sets the isolation level of the next transaction.
await connection.executeQuery(
CompiledQuery.raw(
`set transaction isolation level ${settings.isolationLevel}`,
),
)
await connection.executeQuery(CompiledQuery.raw(sql))
}

await connection.executeQuery(CompiledQuery.raw('begin'))
Expand Down
18 changes: 12 additions & 6 deletions src/dialect/postgres/postgres-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,18 @@ export class PostgresDriver implements Driver {
connection: DatabaseConnection,
settings: TransactionSettings,
): Promise<void> {
if (settings.isolationLevel) {
await connection.executeQuery(
CompiledQuery.raw(
`start transaction isolation level ${settings.isolationLevel}`,
),
)
if (settings.isolationLevel || settings.accessMode) {
let sql = 'start transaction'

if (settings.isolationLevel) {
sql += ` isolation level ${settings.isolationLevel}`
}

if (settings.accessMode) {
sql += ` ${settings.accessMode}`
}

await connection.executeQuery(CompiledQuery.raw(sql))
} else {
await connection.executeQuery(CompiledQuery.raw('begin'))
}
Expand Down
25 changes: 25 additions & 0 deletions src/driver/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,14 @@ export interface Driver {
}

export interface TransactionSettings {
readonly accessMode?: AccessMode
readonly isolationLevel?: IsolationLevel
}

export const TRANSACTION_ACCESS_MODES = ['read only', 'read write'] as const

export type AccessMode = ArrayItemType<typeof TRANSACTION_ACCESS_MODES>

export const TRANSACTION_ISOLATION_LEVELS = [
'read uncommitted',
'read committed',
Expand All @@ -89,3 +94,23 @@ export const TRANSACTION_ISOLATION_LEVELS = [
] as const

export type IsolationLevel = ArrayItemType<typeof TRANSACTION_ISOLATION_LEVELS>

export function validateTransactionSettings(
settings: TransactionSettings,
): void {
if (
settings.accessMode &&
!TRANSACTION_ACCESS_MODES.includes(settings.accessMode)
) {
throw new Error(`invalid transaction access mode ${settings.accessMode}`)
}

if (
settings.isolationLevel &&
!TRANSACTION_ISOLATION_LEVELS.includes(settings.isolationLevel)
) {
throw new Error(
`invalid transaction isolation level ${settings.isolationLevel}`,
)
}
}
38 changes: 21 additions & 17 deletions src/kysely.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { SingleConnectionProvider } from './driver/single-connection-provider.js
import {
Driver,
IsolationLevel,
TransactionSettings,
TRANSACTION_ISOLATION_LEVELS,
AccessMode,
validateTransactionSettings,
} from './driver/driver.js'
import {
createFunctionModule,
Expand Down Expand Up @@ -706,6 +706,13 @@ export class TransactionBuilder<DB> {
this.#props = freeze(props)
}

setAccessMode(accessMode: AccessMode): TransactionBuilder<DB> {
return new TransactionBuilder({
...this.#props,
accessMode,
})
}

setIsolationLevel(isolationLevel: IsolationLevel): TransactionBuilder<DB> {
return new TransactionBuilder({
...this.#props,
Expand All @@ -714,8 +721,8 @@ export class TransactionBuilder<DB> {
}

async execute<T>(callback: (trx: Transaction<DB>) => Promise<T>): Promise<T> {
const { isolationLevel, ...kyselyProps } = this.#props
const settings = { isolationLevel }
const { isolationLevel, accessMode, ...kyselyProps } = this.#props
const settings = { isolationLevel, accessMode }

validateTransactionSettings(settings)

Expand Down Expand Up @@ -744,27 +751,24 @@ export class TransactionBuilder<DB> {
}

interface TransactionBuilderProps extends KyselyProps {
readonly accessMode?: AccessMode
readonly isolationLevel?: IsolationLevel
}

function validateTransactionSettings(settings: TransactionSettings): void {
if (
settings.isolationLevel &&
!TRANSACTION_ISOLATION_LEVELS.includes(settings.isolationLevel)
) {
throw new Error(
`invalid transaction isolation level ${settings.isolationLevel}`,
)
}
}

export class ControlledTransactionBuilder<DB> {
readonly #props: ControlledTransactionBuilderProps

constructor(props: ControlledTransactionBuilderProps) {
this.#props = freeze(props)
}

setAccessMode(accessMode: AccessMode): ControlledTransactionBuilder<DB> {
return new ControlledTransactionBuilder({
...this.#props,
accessMode,
})
}

setIsolationLevel(
isolationLevel: IsolationLevel,
): ControlledTransactionBuilder<DB> {
Expand All @@ -775,8 +779,8 @@ export class ControlledTransactionBuilder<DB> {
}

async execute(): Promise<ControlledTransaction<DB>> {
const { isolationLevel, ...props } = this.#props
const settings = { isolationLevel }
const { isolationLevel, accessMode, ...props } = this.#props
const settings = { isolationLevel, accessMode }

validateTransactionSettings(settings)

Expand Down
100 changes: 90 additions & 10 deletions test/node/src/controlled-transaction.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import * as sinon from 'sinon'
import { Connection } from 'tedious'
import { Connection, ISOLATION_LEVEL } from 'tedious'
import {
CompiledQuery,
ControlledTransaction,
Driver,
DummyDriver,
IsolationLevel,
Kysely,
SqliteDialect,
TRANSACTION_ACCESS_MODES,
} from '../../../'
import {
DIALECTS,
Expand Down Expand Up @@ -249,6 +249,42 @@ for (const dialect of DIALECTS) {
expect(person).to.be.undefined
})

if (dialect === 'postgres' || dialect === 'mysql') {
for (const accessMode of TRANSACTION_ACCESS_MODES) {
it(`should set the transaction access mode as "${accessMode}"`, async () => {
const trx = await ctx.db
.startTransaction()
.setAccessMode(accessMode)
.execute()

await trx.selectFrom('person').selectAll().execute()

await trx.commit().execute()

expect(
executedQueries.map((it) => ({
sql: it.sql,
parameters: it.parameters,
})),
).to.eql(
{
postgres: [
{ sql: `start transaction ${accessMode}`, parameters: [] },
{ sql: 'select * from "person"', parameters: [] },
{ sql: 'commit', parameters: [] },
],
mysql: [
{ sql: `set transaction ${accessMode}`, parameters: [] },
{ sql: 'begin', parameters: [] },
{ sql: 'select * from `person`', parameters: [] },
{ sql: 'commit', parameters: [] },
],
}[dialect],
)
})
}
}

if (dialect === 'postgres' || dialect === 'mysql' || dialect === 'mssql') {
for (const isolationLevel of [
'read uncommitted',
Expand All @@ -263,16 +299,60 @@ for (const dialect of DIALECTS) {
.setIsolationLevel(isolationLevel)
.execute()

await trx
.insertInto('person')
.values({
first_name: 'Foo',
last_name: 'Barson',
gender: 'male',
})
.execute()
await insertSomething(trx)

await trx.commit().execute()

if (dialect === 'mssql') {
expect(tediousBeginTransactionSpy.calledOnce).to.be.true
expect(tediousBeginTransactionSpy.getCall(0).args[1]).to.not.be
.undefined
expect(tediousBeginTransactionSpy.getCall(0).args[2]).to.equal(
ISOLATION_LEVEL[
isolationLevel.replace(' ', '_').toUpperCase() as any
],
)
expect(tediousCommitTransactionSpy.calledOnce).to.be.true
}

expect(
executedQueries.map((it) => ({
sql: it.sql,
parameters: it.parameters,
})),
).to.eql(
{
postgres: [
{
sql: `start transaction isolation level ${isolationLevel}`,
parameters: [],
},
{
sql: 'insert into "person" ("first_name", "last_name", "gender") values ($1, $2, $3)',
parameters: ['Foo', 'Barson', 'male'],
},
{ sql: 'commit', parameters: [] },
],
mysql: [
{
sql: `set transaction isolation level ${isolationLevel}`,
parameters: [],
},
{ sql: 'begin', parameters: [] },
{
sql: 'insert into `person` (`first_name`, `last_name`, `gender`) values (?, ?, ?)',
parameters: ['Foo', 'Barson', 'male'],
},
{ sql: 'commit', parameters: [] },
],
mssql: [
{
sql: 'insert into "person" ("first_name", "last_name", "gender") values (@1, @2, @3)',
parameters: ['Foo', 'Barson', 'male'],
},
],
}[dialect],
)
})
}
}
Expand Down
Loading

0 comments on commit 7694816

Please sign in to comment.