Tighten SQLite transaction failures and tests
This commit is contained in:
parent
5f6f6b9ae4
commit
6d340824e4
@ -68,14 +68,19 @@ private class JdbcSqliteDatabaseBackend(
|
||||
try {
|
||||
connection.autoCommit = false
|
||||
val tx = JdbcSqliteTransactionBackend(core, connection)
|
||||
return try {
|
||||
val result = block(tx)
|
||||
connection.commit()
|
||||
result
|
||||
val result = try {
|
||||
block(tx)
|
||||
} catch (e: Throwable) {
|
||||
rollbackQuietly(connection)
|
||||
throw e
|
||||
throw finishFailedTransaction(scope, core, e) {
|
||||
rollbackOrThrow(scope, core, connection)
|
||||
}
|
||||
}
|
||||
try {
|
||||
connection.commit()
|
||||
} catch (e: SQLException) {
|
||||
throw mapSqlException(scope, core, e)
|
||||
}
|
||||
return result
|
||||
} catch (e: SQLException) {
|
||||
throw mapSqlException(scope, core, e)
|
||||
} finally {
|
||||
@ -180,15 +185,22 @@ private class JdbcSqliteTransactionBackend(
|
||||
} catch (e: SQLException) {
|
||||
throw mapSqlUsage(scope, core, "Nested transactions are not supported by this SQLite backend", e)
|
||||
}
|
||||
return try {
|
||||
val result = block(JdbcSqliteTransactionBackend(core, connection))
|
||||
connection.releaseSavepoint(savepoint)
|
||||
result
|
||||
val nested = JdbcSqliteTransactionBackend(core, connection)
|
||||
val result = try {
|
||||
block(nested)
|
||||
} catch (e: Throwable) {
|
||||
rollbackQuietly(connection, savepoint)
|
||||
throw e
|
||||
throw finishFailedTransaction(scope, core, e) {
|
||||
rollbackToSavepointOrThrow(scope, core, connection, savepoint)
|
||||
releaseSavepointOrThrow(scope, core, connection, savepoint)
|
||||
}
|
||||
}
|
||||
try {
|
||||
connection.releaseSavepoint(savepoint)
|
||||
} catch (e: SQLException) {
|
||||
throw mapSqlException(scope, core, e)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun bindParams(statement: PreparedStatement, params: List<Obj>, scope: ScopeFacade, core: SqliteCoreModule) {
|
||||
@ -383,13 +395,70 @@ private fun sqlExecutionFailure(scope: ScopeFacade, core: SqliteCoreModule, mess
|
||||
)
|
||||
}
|
||||
|
||||
private fun rollbackQuietly(connection: Connection, savepoint: java.sql.Savepoint? = null) {
|
||||
private fun rollbackOrThrow(scope: ScopeFacade, core: SqliteCoreModule, connection: Connection) {
|
||||
try {
|
||||
if (savepoint == null) connection.rollback() else connection.rollback(savepoint)
|
||||
} catch (_: SQLException) {
|
||||
connection.rollback()
|
||||
} catch (e: SQLException) {
|
||||
throw mapSqlException(scope, core, e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rollbackToSavepointOrThrow(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
connection: Connection,
|
||||
savepoint: java.sql.Savepoint,
|
||||
) {
|
||||
try {
|
||||
connection.rollback(savepoint)
|
||||
} catch (e: SQLException) {
|
||||
throw mapSqlException(scope, core, e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseSavepointOrThrow(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
connection: Connection,
|
||||
savepoint: java.sql.Savepoint,
|
||||
) {
|
||||
try {
|
||||
connection.releaseSavepoint(savepoint)
|
||||
} catch (e: SQLException) {
|
||||
throw mapSqlException(scope, core, e)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun finishFailedTransaction(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
failure: Throwable,
|
||||
rollback: () -> Unit,
|
||||
): Throwable {
|
||||
return try {
|
||||
rollback()
|
||||
failure
|
||||
} catch (rollbackFailure: Throwable) {
|
||||
if (isRollbackSignal(failure, core)) {
|
||||
attachSecondaryFailure(rollbackFailure, failure)
|
||||
rollbackFailure
|
||||
} else {
|
||||
attachSecondaryFailure(failure, rollbackFailure)
|
||||
failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isRollbackSignal(failure: Throwable, core: SqliteCoreModule): Boolean {
|
||||
val errorObject = (failure as? ExecutionError)?.errorObject ?: return false
|
||||
return errorObject.isInstanceOf(core.rollbackException)
|
||||
}
|
||||
|
||||
private fun attachSecondaryFailure(primary: Throwable, secondary: Throwable) {
|
||||
if (primary === secondary) return
|
||||
primary.addSuppressed(secondary)
|
||||
}
|
||||
|
||||
private fun mapOpenException(scope: ScopeFacade, core: SqliteCoreModule, e: SQLException): Nothing {
|
||||
val message = e.message ?: "SQLite open failed"
|
||||
val lower = message.lowercase()
|
||||
|
||||
@ -411,6 +411,88 @@ class LyngSqliteModuleTest {
|
||||
assertEquals("Bool|Date|12:34:56|Bool", summary.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUnsupportedParameterTypeFailsWithSqlUsageException() = runTest {
|
||||
val scope = Script.newScope()
|
||||
val db = openMemoryDb(scope)
|
||||
|
||||
val error = assertFailsWith<ExecutionError> {
|
||||
db.invokeInstanceMethod(
|
||||
scope,
|
||||
"transaction",
|
||||
ObjExternCallable.fromBridge {
|
||||
val tx = requiredArg<Obj>(0)
|
||||
tx.invokeInstanceMethod(
|
||||
requireScope(),
|
||||
"execute",
|
||||
ObjString("create table sample(value text not null)")
|
||||
)
|
||||
tx.invokeInstanceMethod(
|
||||
requireScope(),
|
||||
"execute",
|
||||
ObjString("insert into sample(value) values(?)"),
|
||||
emptyMapObj()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
||||
assertTrue(error.errorMessage.contains("Unsupported SQLite parameter type"), error.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTimestampAndDatetimeRejectTimezoneBearingText() = runTest {
|
||||
val scope = Script.newScope()
|
||||
withTempDb(scope) { db ->
|
||||
db.invokeInstanceMethod(
|
||||
scope,
|
||||
"transaction",
|
||||
ObjExternCallable.fromBridge {
|
||||
val tx = requiredArg<Obj>(0)
|
||||
tx.invokeInstanceMethod(
|
||||
requireScope(),
|
||||
"execute",
|
||||
ObjString("create table sample(ts TIMESTAMP not null, dt DATETIME not null)")
|
||||
)
|
||||
tx.invokeInstanceMethod(
|
||||
requireScope(),
|
||||
"execute",
|
||||
ObjString("insert into sample(ts, dt) values(?, ?)"),
|
||||
ObjString("2024-05-06T07:08:09Z"),
|
||||
ObjString("2024-05-06T10:11:12+03:00")
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
val timestampError = assertFailsWith<ExecutionError> {
|
||||
db.invokeInstanceMethod(
|
||||
scope,
|
||||
"transaction",
|
||||
ObjExternCallable.fromBridge {
|
||||
val tx = requiredArg<Obj>(0)
|
||||
tx.invokeInstanceMethod(requireScope(), "select", ObjString("select ts from sample"))
|
||||
}
|
||||
)
|
||||
}
|
||||
assertEquals("SqlExecutionException", timestampError.errorObject.objClass.className)
|
||||
assertTrue(timestampError.errorMessage.contains("must not contain a timezone offset"), timestampError.errorMessage)
|
||||
|
||||
val datetimeError = assertFailsWith<ExecutionError> {
|
||||
db.invokeInstanceMethod(
|
||||
scope,
|
||||
"transaction",
|
||||
ObjExternCallable.fromBridge {
|
||||
val tx = requiredArg<Obj>(0)
|
||||
tx.invokeInstanceMethod(requireScope(), "select", ObjString("select dt from sample"))
|
||||
}
|
||||
)
|
||||
}
|
||||
assertEquals("SqlExecutionException", datetimeError.errorObject.objClass.className)
|
||||
assertTrue(datetimeError.errorMessage.contains("must not contain a timezone offset"), datetimeError.errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReadOnlyOpenPreventsWrites() = runTest {
|
||||
val scope = Script.newScope()
|
||||
|
||||
@ -309,6 +309,88 @@ class LyngSqliteModuleNativeTest {
|
||||
assertEquals("Bool|Date|12:34:56|Bool", summary.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUnsupportedParameterTypeFailsWithSqlUsageException() = runTest {
|
||||
val scope = Script.newScope()
|
||||
val db = openMemoryDb(scope)
|
||||
|
||||
val error = assertFailsWith<ExecutionError> {
|
||||
db.invokeInstanceMethod(
|
||||
scope,
|
||||
"transaction",
|
||||
ObjExternCallable.fromBridge {
|
||||
val tx = requiredArg<Obj>(0)
|
||||
tx.invokeInstanceMethod(
|
||||
requireScope(),
|
||||
"execute",
|
||||
ObjString("create table sample(value text not null)")
|
||||
)
|
||||
tx.invokeInstanceMethod(
|
||||
requireScope(),
|
||||
"execute",
|
||||
ObjString("insert into sample(value) values(?)"),
|
||||
emptyMapObj()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
||||
assertTrue(error.errorMessage.contains("Unsupported SQLite parameter type"), error.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTimestampAndDatetimeRejectTimezoneBearingText() = runTest {
|
||||
val scope = Script.newScope()
|
||||
withTempDb(scope) { db ->
|
||||
db.invokeInstanceMethod(
|
||||
scope,
|
||||
"transaction",
|
||||
ObjExternCallable.fromBridge {
|
||||
val tx = requiredArg<Obj>(0)
|
||||
tx.invokeInstanceMethod(
|
||||
requireScope(),
|
||||
"execute",
|
||||
ObjString("create table sample(ts TIMESTAMP not null, dt DATETIME not null)")
|
||||
)
|
||||
tx.invokeInstanceMethod(
|
||||
requireScope(),
|
||||
"execute",
|
||||
ObjString("insert into sample(ts, dt) values(?, ?)"),
|
||||
ObjString("2024-05-06T07:08:09Z"),
|
||||
ObjString("2024-05-06T10:11:12+03:00")
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
val timestampError = assertFailsWith<ExecutionError> {
|
||||
db.invokeInstanceMethod(
|
||||
scope,
|
||||
"transaction",
|
||||
ObjExternCallable.fromBridge {
|
||||
val tx = requiredArg<Obj>(0)
|
||||
tx.invokeInstanceMethod(requireScope(), "select", ObjString("select ts from sample"))
|
||||
}
|
||||
)
|
||||
}
|
||||
assertEquals("SqlExecutionException", timestampError.errorObject.objClass.className)
|
||||
assertTrue(timestampError.errorMessage.contains("must not contain a timezone offset"), timestampError.errorMessage)
|
||||
|
||||
val datetimeError = assertFailsWith<ExecutionError> {
|
||||
db.invokeInstanceMethod(
|
||||
scope,
|
||||
"transaction",
|
||||
ObjExternCallable.fromBridge {
|
||||
val tx = requiredArg<Obj>(0)
|
||||
tx.invokeInstanceMethod(requireScope(), "select", ObjString("select dt from sample"))
|
||||
}
|
||||
)
|
||||
}
|
||||
assertEquals("SqlExecutionException", datetimeError.errorObject.objClass.className)
|
||||
assertTrue(datetimeError.errorMessage.contains("must not contain a timezone offset"), datetimeError.errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReadOnlyOpenPreventsWrites() = runTest {
|
||||
val scope = Script.newScope()
|
||||
|
||||
@ -119,14 +119,15 @@ private class NativeSqliteDatabaseBackend(
|
||||
try {
|
||||
handle.execUnit(scope, core, "begin")
|
||||
val tx = NativeSqliteTransactionBackend(core, handle, savepoints)
|
||||
return try {
|
||||
val result = block(tx)
|
||||
handle.execUnit(scope, core, "commit")
|
||||
result
|
||||
val result = try {
|
||||
block(tx)
|
||||
} catch (e: Throwable) {
|
||||
handle.execUnitQuietly("rollback")
|
||||
throw e
|
||||
throw finishFailedTransaction(scope, core, e) {
|
||||
handle.execUnit(scope, core, "rollback")
|
||||
}
|
||||
}
|
||||
handle.execUnit(scope, core, "commit")
|
||||
return result
|
||||
} finally {
|
||||
handle.close()
|
||||
}
|
||||
@ -149,16 +150,18 @@ private class NativeSqliteTransactionBackend(
|
||||
override suspend fun <T> transaction(scope: ScopeFacade, block: suspend (SqliteTransactionBackend) -> T): T {
|
||||
val savepoint = "lyng_sp_${savepoints.next()}"
|
||||
handle.execUnit(scope, core, "savepoint $savepoint")
|
||||
return try {
|
||||
val result = block(NativeSqliteTransactionBackend(core, handle, savepoints))
|
||||
handle.execUnit(scope, core, "release savepoint $savepoint")
|
||||
result
|
||||
val nested = NativeSqliteTransactionBackend(core, handle, savepoints)
|
||||
val result = try {
|
||||
block(nested)
|
||||
} catch (e: Throwable) {
|
||||
handle.execUnitQuietly("rollback to savepoint $savepoint")
|
||||
handle.execUnitQuietly("release savepoint $savepoint")
|
||||
throw e
|
||||
throw finishFailedTransaction(scope, core, e) {
|
||||
handle.execUnit(scope, core, "rollback to savepoint $savepoint")
|
||||
handle.execUnit(scope, core, "release savepoint $savepoint")
|
||||
}
|
||||
}
|
||||
handle.execUnit(scope, core, "release savepoint $savepoint")
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private class SavepointCounter {
|
||||
@ -229,17 +232,6 @@ private class NativeSqliteHandle(
|
||||
}
|
||||
}
|
||||
|
||||
fun execUnitQuietly(sql: String) {
|
||||
memScoped {
|
||||
val stmt = lyng_sqlite3_prepare(db, sql) ?: return@memScoped
|
||||
try {
|
||||
sqlite3_step(stmt)
|
||||
} finally {
|
||||
sqlite3_finalize(stmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
sqlite3_close_v2(db)
|
||||
}
|
||||
@ -600,3 +592,33 @@ private fun sqlExecutionError(scope: ScopeFacade, core: SqliteCoreModule, messag
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
private inline fun finishFailedTransaction(
|
||||
scope: ScopeFacade,
|
||||
core: SqliteCoreModule,
|
||||
failure: Throwable,
|
||||
rollback: () -> Unit,
|
||||
): Throwable {
|
||||
return try {
|
||||
rollback()
|
||||
failure
|
||||
} catch (rollbackFailure: Throwable) {
|
||||
if (isRollbackSignal(failure, core)) {
|
||||
attachSecondaryFailure(rollbackFailure, failure)
|
||||
rollbackFailure
|
||||
} else {
|
||||
attachSecondaryFailure(failure, rollbackFailure)
|
||||
failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isRollbackSignal(failure: Throwable, core: SqliteCoreModule): Boolean {
|
||||
val errorObject = (failure as? ExecutionError)?.errorObject ?: return false
|
||||
return errorObject.isInstanceOf(core.rollbackException)
|
||||
}
|
||||
|
||||
private fun attachSecondaryFailure(primary: Throwable, secondary: Throwable) {
|
||||
if (primary === secondary) return
|
||||
primary.addSuppressed(secondary)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user