Skip to content

Commit

Permalink
feat!: EXPOSED-555 Allow read-only suspendable transactions
Browse files Browse the repository at this point in the history
Regular transaction management allow callers to configure if new transactions should be read-only. This change adds the same behavior to suspendable transactions.
  • Loading branch information
RenanKummer committed Oct 14, 2024
1 parent b0a7aed commit 16280ea
Show file tree
Hide file tree
Showing 6 changed files with 89 additions and 12 deletions.
8 changes: 4 additions & 4 deletions exposed-core/api/exposed-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -3691,10 +3691,10 @@ public final class org/jetbrains/exposed/sql/transactions/TransactionStore : kot
}

public final class org/jetbrains/exposed/sql/transactions/experimental/SuspendedKt {
public static final fun newSuspendedTransaction (Lkotlin/coroutines/CoroutineContext;Lorg/jetbrains/exposed/sql/Database;Ljava/lang/Integer;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun newSuspendedTransaction$default (Lkotlin/coroutines/CoroutineContext;Lorg/jetbrains/exposed/sql/Database;Ljava/lang/Integer;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public static final fun suspendedTransactionAsync (Lkotlin/coroutines/CoroutineContext;Lorg/jetbrains/exposed/sql/Database;Ljava/lang/Integer;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun suspendedTransactionAsync$default (Lkotlin/coroutines/CoroutineContext;Lorg/jetbrains/exposed/sql/Database;Ljava/lang/Integer;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public static final fun newSuspendedTransaction (Lkotlin/coroutines/CoroutineContext;Lorg/jetbrains/exposed/sql/Database;Ljava/lang/Integer;Ljava/lang/Boolean;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun newSuspendedTransaction$default (Lkotlin/coroutines/CoroutineContext;Lorg/jetbrains/exposed/sql/Database;Ljava/lang/Integer;Ljava/lang/Boolean;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public static final fun suspendedTransactionAsync (Lkotlin/coroutines/CoroutineContext;Lorg/jetbrains/exposed/sql/Database;Ljava/lang/Integer;Ljava/lang/Boolean;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun suspendedTransactionAsync$default (Lkotlin/coroutines/CoroutineContext;Lorg/jetbrains/exposed/sql/Database;Ljava/lang/Integer;Ljava/lang/Boolean;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public static final fun withSuspendTransaction (Lorg/jetbrains/exposed/sql/Transaction;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun withSuspendTransaction$default (Lorg/jetbrains/exposed/sql/Transaction;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,10 @@ suspend fun <T> newSuspendedTransaction(
context: CoroutineContext? = null,
db: Database? = null,
transactionIsolation: Int? = null,
readOnly: Boolean? = null,
statement: suspend Transaction.() -> T
): T =
withTransactionScope(context, null, db, transactionIsolation) {
withTransactionScope(context, null, db, transactionIsolation, readOnly) {
suspendedTransactionAsyncInternal(true, statement).await()
}

Expand Down Expand Up @@ -102,10 +103,11 @@ suspend fun <T> suspendedTransactionAsync(
context: CoroutineContext? = null,
db: Database? = null,
transactionIsolation: Int? = null,
readOnly: Boolean? = null,
statement: suspend Transaction.() -> T
): Deferred<T> {
val currentTransaction = TransactionManager.currentOrNull()
return withTransactionScope(context, null, db, transactionIsolation) {
return withTransactionScope(context, null, db, transactionIsolation, readOnly) {
suspendedTransactionAsyncInternal(!holdsSameTransaction(currentTransaction), statement)
}
}
Expand All @@ -129,6 +131,7 @@ private suspend fun <T> withTransactionScope(
currentTransaction: Transaction?,
db: Database? = null,
transactionIsolation: Int?,
readOnly: Boolean? = null,
body: suspend TransactionScope.() -> T
): T {
val currentScope = coroutineContext[TransactionScope]
Expand All @@ -137,7 +140,10 @@ private suspend fun <T> withTransactionScope(
val manager = currentDatabase?.transactionManager ?: TransactionManager.manager

val tx = lazy(LazyThreadSafetyMode.NONE) {
currentTransaction ?: manager.newTransaction(transactionIsolation ?: manager.defaultIsolationLevel)
currentTransaction ?: manager.newTransaction(
isolation = transactionIsolation ?: manager.defaultIsolationLevel,
readOnly = readOnly ?: manager.defaultReadOnly
)
}

val element = TransactionCoroutineElement(tx, manager)
Expand Down
1 change: 1 addition & 0 deletions exposed-tests/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ dependencies {
compileOnly(libs.h2)
testCompileOnly(libs.sqlite.jdbc)
testImplementation(libs.logcaptor)
testImplementation(libs.kotlinx.coroutines.test)
}

tasks.withType<Test>().configureEach {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.jetbrains.exposed.sql.tests.postgresql

import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import kotlinx.coroutines.test.runTest
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.exceptions.ExposedSQLException
import org.jetbrains.exposed.sql.Database
Expand All @@ -13,6 +14,7 @@ import org.jetbrains.exposed.sql.tests.shared.assertEquals
import org.jetbrains.exposed.sql.tests.shared.assertTrue
import org.jetbrains.exposed.sql.tests.shared.expectException
import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.jetbrains.exposed.sql.transactions.transaction
import org.junit.Assert
import org.junit.Assume
Expand Down Expand Up @@ -94,4 +96,41 @@ class ConnectionPoolTests : LogDbInTestName() {

TransactionManager.closeAndUnregister(hikariPG)
}

@Test
fun testSuspendedReadOnlyModeWithHikariAndPostgres() = runTest {
Assume.assumeTrue(TestDB.POSTGRESQL in TestDB.enabledDialects())

val testTable = object : IntIdTable("HIKARI_TESTER") { }

fun Transaction.getReadOnlyMode(): Boolean {
val mode = exec("SHOW transaction_read_only;") {
it.next()
it.getBoolean(1)
}
assertNotNull(mode)
return mode
}

// read only mode should be set directly by hikari config
newSuspendedTransaction(db = hikariPG) {
assertTrue(getReadOnlyMode())

// table cannot be created in read-only mode
expectException<ExposedSQLException> {
SchemaUtils.create(testTable)
}
}

// transaction setting should override hikari config
newSuspendedTransaction(transactionIsolation = Connection.TRANSACTION_SERIALIZABLE, readOnly = false, db = hikariPG) {
Assert.assertFalse(getReadOnlyMode())

// table can now be created and dropped
SchemaUtils.create(testTable)
SchemaUtils.drop(testTable)
}

TransactionManager.closeAndUnregister(hikariPG)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package org.jetbrains.exposed.sql.tests.shared

import kotlinx.coroutines.test.runTest
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.exceptions.ExposedSQLException
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.insert
Expand All @@ -9,6 +11,7 @@ import org.jetbrains.exposed.sql.tests.DatabaseTestsBase
import org.jetbrains.exposed.sql.tests.TestDB
import org.jetbrains.exposed.sql.tests.shared.dml.DMLTestsData
import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.jetbrains.exposed.sql.transactions.inTopLevelTransaction
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.transactions.transactionManager
Expand Down Expand Up @@ -48,11 +51,7 @@ class ThreadLocalManagerTest : DatabaseTestsBase() {

@Test
fun testReadOnly() {
// Explanation: MariaDB driver never set readonly to true, MSSQL silently ignores the call, SQLite does not
// promise anything, H2 has very limited functionality
val excludeSettings = TestDB.ALL_H2 + TestDB.ALL_MARIADB +
listOf(TestDB.SQLITE, TestDB.SQLSERVER, TestDB.ORACLE)
withTables(excludeSettings = excludeSettings, RollbackTable) {
withTables(excludeSettings = READ_ONLY_EXCLUDED_VENDORS, RollbackTable) {
assertFails {
inTopLevelTransaction(db.transactionManager.defaultIsolationLevel, true) {
maxAttempts = 1
Expand All @@ -61,8 +60,39 @@ class ThreadLocalManagerTest : DatabaseTestsBase() {
}.message?.run { assertTrue(contains("read-only")) } ?: fail("message should not be null")
}
}

@Test
fun testSuspendedReadOnly() = runTest {
Assume.assumeFalse(dialect in READ_ONLY_EXCLUDED_VENDORS)

val database = dialect.connect()
newSuspendedTransaction(db = database, readOnly = true) {
expectException<ExposedSQLException> {
SchemaUtils.create(RollbackTable)
}
}

transaction(db = database) {
SchemaUtils.create(RollbackTable)
}

newSuspendedTransaction(db = database, readOnly = true) {
expectException<ExposedSQLException> {
RollbackTable.insert { it[value] = "random-something" }
}
}

transaction(db = database) {
SchemaUtils.drop(RollbackTable)
}
}
}

object RollbackTable : IntIdTable("rollbackTable") {
val value = varchar("value", 20)
}

// Explanation: MariaDB driver never set readonly to true, MSSQL silently ignores the call, SQLite does not
// promise anything, H2 has very limited functionality
private val READ_ONLY_EXCLUDED_VENDORS =
TestDB.ALL_H2 + TestDB.ALL_MARIADB + listOf(TestDB.SQLITE, TestDB.SQLSERVER, TestDB.ORACLE)
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ detekt-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-form

kotlinx-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinCoroutines" }
kotlinx-coroutines-debug = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-debug", version.ref = "kotlinCoroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinCoroutines" }
kotlinx-jvm-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime-jvm", version.ref = "kotlinx-datetime" }
kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }

Expand Down

0 comments on commit 16280ea

Please sign in to comment.