Skip to content

Commit 57726b8

Browse files
authored
Add support for read-only mode in DuckDB and SQLite (#1383)
* Add support for read-only mode in database connections This commit introduces `createConnection` methods for `DuckDb` and `Sqlite` to handle read-only mode during connection creation. It also updates test configurations and ensures proper handling of in-memory databases. * Linter * apiDump * Simplify SQLite read-only connection creation and clean up DuckDB handling Streamlined `createConnection` for SQLite using `SQLiteConfig` for read-only mode. Improved `isInMemoryDuckDb` logic for better URL handling. Updated dependencies and tests for consistency. * Linter
1 parent 14b3e80 commit 57726b8

File tree

9 files changed

+177
-23
lines changed

9 files changed

+177
-23
lines changed

dataframe-jdbc/api/dataframe-jdbc.api

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ public abstract class org/jetbrains/kotlinx/dataframe/io/db/DbType {
120120
public abstract fun buildTableMetadata (Ljava/sql/ResultSet;)Lorg/jetbrains/kotlinx/dataframe/io/TableMetadata;
121121
public abstract fun convertSqlTypeToColumnSchemaValue (Lorg/jetbrains/kotlinx/dataframe/io/TableColumnMetadata;)Lorg/jetbrains/kotlinx/dataframe/schema/ColumnSchema;
122122
public abstract fun convertSqlTypeToKType (Lorg/jetbrains/kotlinx/dataframe/io/TableColumnMetadata;)Lkotlin/reflect/KType;
123+
public fun createConnection (Lorg/jetbrains/kotlinx/dataframe/io/DbConnectionConfig;)Ljava/sql/Connection;
123124
public final fun getDbTypeInJdbcUrl ()Ljava/lang/String;
124125
public abstract fun getDriverClassName ()Ljava/lang/String;
125126
public fun getTableTypes ()Ljava/util/List;
@@ -133,6 +134,7 @@ public final class org/jetbrains/kotlinx/dataframe/io/db/DuckDb : org/jetbrains/
133134
public fun buildTableMetadata (Ljava/sql/ResultSet;)Lorg/jetbrains/kotlinx/dataframe/io/TableMetadata;
134135
public fun convertSqlTypeToColumnSchemaValue (Lorg/jetbrains/kotlinx/dataframe/io/TableColumnMetadata;)Lorg/jetbrains/kotlinx/dataframe/schema/ColumnSchema;
135136
public fun convertSqlTypeToKType (Lorg/jetbrains/kotlinx/dataframe/io/TableColumnMetadata;)Lkotlin/reflect/KType;
137+
public fun createConnection (Lorg/jetbrains/kotlinx/dataframe/io/DbConnectionConfig;)Ljava/sql/Connection;
136138
public fun getDriverClassName ()Ljava/lang/String;
137139
public fun isSystemTable (Lorg/jetbrains/kotlinx/dataframe/io/TableMetadata;)Z
138140
}
@@ -200,6 +202,7 @@ public final class org/jetbrains/kotlinx/dataframe/io/db/Sqlite : org/jetbrains/
200202
public fun buildTableMetadata (Ljava/sql/ResultSet;)Lorg/jetbrains/kotlinx/dataframe/io/TableMetadata;
201203
public fun convertSqlTypeToColumnSchemaValue (Lorg/jetbrains/kotlinx/dataframe/io/TableColumnMetadata;)Lorg/jetbrains/kotlinx/dataframe/schema/ColumnSchema;
202204
public fun convertSqlTypeToKType (Lorg/jetbrains/kotlinx/dataframe/io/TableColumnMetadata;)Lkotlin/reflect/KType;
205+
public fun createConnection (Lorg/jetbrains/kotlinx/dataframe/io/DbConnectionConfig;)Ljava/sql/Connection;
203206
public fun getDriverClassName ()Ljava/lang/String;
204207
public fun isSystemTable (Lorg/jetbrains/kotlinx/dataframe/io/TableMetadata;)Z
205208
}

dataframe-jdbc/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ repositories {
1717
dependencies {
1818
api(projects.core)
1919
compileOnly(libs.duckdb.jdbc)
20+
compileOnly(libs.sqlite)
2021
implementation(libs.kotlinLogging)
2122
testImplementation(libs.mariadb)
2223
testImplementation(libs.sqlite)

dataframe-jdbc/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/db/DbType.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package org.jetbrains.kotlinx.dataframe.io.db
22

3+
import org.jetbrains.kotlinx.dataframe.io.DbConnectionConfig
34
import org.jetbrains.kotlinx.dataframe.io.TableColumnMetadata
45
import org.jetbrains.kotlinx.dataframe.io.TableMetadata
56
import org.jetbrains.kotlinx.dataframe.io.getSchemaForAllSqlTables
67
import org.jetbrains.kotlinx.dataframe.io.readAllSqlTables
78
import org.jetbrains.kotlinx.dataframe.schema.ColumnSchema
9+
import java.sql.Connection
810
import java.sql.DatabaseMetaData
11+
import java.sql.DriverManager
912
import java.sql.ResultSet
1013
import kotlin.reflect.KType
1114

@@ -75,4 +78,22 @@ public abstract class DbType(public val dbTypeInJdbcUrl: String) {
7578
* @return A new SQL query with the limit clause added.
7679
*/
7780
public open fun sqlQueryLimit(sqlQuery: String, limit: Int = 1): String = "$sqlQuery LIMIT $limit"
81+
82+
/**
83+
* Creates a database connection using the provided configuration.
84+
* This method is only called when working with [DbConnectionConfig] (internally managed connections).
85+
*
86+
* Some databases (like [Sqlite]) require read-only mode to be set during connection creation
87+
* rather than after the connection is established.
88+
*
89+
* @param [dbConfig] The database configuration containing URL, credentials, and read-only flag.
90+
* @return A configured [Connection] instance.
91+
*/
92+
public open fun createConnection(dbConfig: DbConnectionConfig): Connection {
93+
val connection = DriverManager.getConnection(dbConfig.url, dbConfig.user, dbConfig.password)
94+
if (dbConfig.readOnly) {
95+
connection.isReadOnly = true
96+
}
97+
return connection
98+
}
7899
}

dataframe-jdbc/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/db/DuckDb.kt

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.jetbrains.kotlinx.dataframe.io.db
22

3+
import io.github.oshai.kotlinlogging.KotlinLogging
34
import org.duckdb.DuckDBColumnType
45
import org.duckdb.DuckDBColumnType.ARRAY
56
import org.duckdb.DuckDBColumnType.BIGINT
@@ -39,6 +40,7 @@ import org.duckdb.DuckDBColumnType.VARCHAR
3940
import org.duckdb.DuckDBResultSetMetaData
4041
import org.duckdb.JsonNode
4142
import org.jetbrains.kotlinx.dataframe.DataFrame
43+
import org.jetbrains.kotlinx.dataframe.io.DbConnectionConfig
4244
import org.jetbrains.kotlinx.dataframe.io.TableColumnMetadata
4345
import org.jetbrains.kotlinx.dataframe.io.TableMetadata
4446
import org.jetbrains.kotlinx.dataframe.io.db.DuckDb.convertSqlTypeToKType
@@ -49,21 +51,26 @@ import java.math.BigDecimal
4951
import java.math.BigInteger
5052
import java.sql.Array
5153
import java.sql.Blob
54+
import java.sql.Connection
5255
import java.sql.DatabaseMetaData
56+
import java.sql.DriverManager
5357
import java.sql.ResultSet
5458
import java.sql.Struct
5559
import java.sql.Timestamp
5660
import java.time.LocalDate
5761
import java.time.LocalTime
5862
import java.time.OffsetDateTime
5963
import java.time.OffsetTime
64+
import java.util.Properties
6065
import java.util.UUID
6166
import kotlin.reflect.KType
6267
import kotlin.reflect.KTypeProjection
6368
import kotlin.reflect.full.createType
6469
import kotlin.reflect.full.withNullability
6570
import kotlin.reflect.typeOf
6671

72+
private val logger = KotlinLogging.logger {}
73+
6774
/**
6875
* Represents the [DuckDB](http://duckdb.org/) database type.
6976
*
@@ -216,4 +223,39 @@ public object DuckDb : DbType("duckdb") {
216223
tables.getString("TABLE_SCHEM"),
217224
tables.getString("TABLE_CAT"),
218225
)
226+
227+
/**
228+
* Creates a database connection using the provided configuration.
229+
*
230+
* DuckDB does not support changing read-only status after connection creation,
231+
* but supports read-only mode through connection parameters.
232+
*
233+
* @param [dbConfig] The database configuration containing URL, credentials, and read-only flag.
234+
* @return A configured [java.sql.Connection] instance.
235+
*/
236+
override fun createConnection(dbConfig: DbConnectionConfig): Connection {
237+
val properties = Properties().apply {
238+
dbConfig.user.takeIf { it.isNotEmpty() }?.let { setProperty("user", it) }
239+
dbConfig.password.takeIf { it.isNotEmpty() }?.let { setProperty("password", it) }
240+
241+
// Handle DuckDB limitation: in-memory databases cannot be opened in read-only mode
242+
if (dbConfig.readOnly && !dbConfig.url.isInMemoryDuckDb()) {
243+
setProperty("access_mode", "read_only")
244+
} else if (dbConfig.readOnly) {
245+
logger.warn {
246+
"Cannot create read-only in-memory DuckDB database (url=${dbConfig.url}). " +
247+
"In-memory databases require write access for initialization. Connection will be created without read-only mode."
248+
}
249+
}
250+
}
251+
252+
return DriverManager.getConnection(dbConfig.url, properties)
253+
}
254+
255+
/**
256+
* Checks if the DuckDB URL represents an in-memory database.
257+
* In-memory DuckDB URLs are either "jdbc:duckdb:" or "jdbc:duckdb:" followed only by whitespace.
258+
*/
259+
private fun String.isInMemoryDuckDb(): Boolean =
260+
this.trim() == "jdbc:duckdb:" || matches("jdbc:duckdb:\\s*$".toRegex())
219261
}

dataframe-jdbc/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/db/Sqlite.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package org.jetbrains.kotlinx.dataframe.io.db
22

3+
import org.jetbrains.kotlinx.dataframe.io.DbConnectionConfig
34
import org.jetbrains.kotlinx.dataframe.io.TableColumnMetadata
45
import org.jetbrains.kotlinx.dataframe.io.TableMetadata
56
import org.jetbrains.kotlinx.dataframe.schema.ColumnSchema
7+
import org.sqlite.SQLiteConfig
8+
import java.sql.Connection
9+
import java.sql.DriverManager
610
import java.sql.ResultSet
711
import kotlin.reflect.KType
812

@@ -28,4 +32,13 @@ public object Sqlite : DbType("sqlite") {
2832
)
2933

3034
override fun convertSqlTypeToKType(tableColumnMetadata: TableColumnMetadata): KType? = null
35+
36+
override fun createConnection(dbConfig: DbConnectionConfig): Connection =
37+
if (dbConfig.readOnly) {
38+
val config = SQLiteConfig()
39+
config.setReadOnly(true)
40+
config.createConnection(dbConfig.url)
41+
} else {
42+
DriverManager.getConnection(dbConfig.url, dbConfig.user, dbConfig.password)
43+
}
3144
}

dataframe-jdbc/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/readJdbc.kt

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import org.jetbrains.kotlinx.dataframe.api.toDataFrame
99
import org.jetbrains.kotlinx.dataframe.impl.schema.DataFrameSchemaImpl
1010
import org.jetbrains.kotlinx.dataframe.io.db.DbType
1111
import org.jetbrains.kotlinx.dataframe.io.db.extractDBTypeFromConnection
12+
import org.jetbrains.kotlinx.dataframe.io.db.extractDBTypeFromUrl
1213
import org.jetbrains.kotlinx.dataframe.schema.ColumnSchema
1314
import org.jetbrains.kotlinx.dataframe.schema.DataFrameSchema
1415
import java.math.BigDecimal
@@ -174,16 +175,13 @@ internal inline fun <T> withReadOnlyConnection(
174175
dbType: DbType? = null,
175176
block: (Connection) -> T,
176177
): T {
177-
val connection = DriverManager.getConnection(dbConfig.url, dbConfig.user, dbConfig.password)
178-
179-
val originalAutoCommit = connection.autoCommit
180-
val originalReadOnly = connection.isReadOnly
178+
val actualDbType = dbType ?: extractDBTypeFromUrl(dbConfig.url)
179+
val connection = actualDbType.createConnection(dbConfig)
181180

182181
return connection.use { conn ->
183182
try {
184183
if (dbConfig.readOnly) {
185184
conn.autoCommit = false
186-
conn.isReadOnly = true
187185
}
188186

189187
block(conn)
@@ -197,10 +195,6 @@ internal inline fun <T> withReadOnlyConnection(
197195
}
198196
}
199197
}
200-
201-
// Restore original settings (relevant in pooled environments)
202-
conn.autoCommit = originalAutoCommit
203-
conn.isReadOnly = originalReadOnly
204198
}
205199
}
206200
}

dataframe-jdbc/src/test/kotlin/org/jetbrains/kotlinx/dataframe/io/h2/h2Test.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1173,7 +1173,7 @@ class JdbcTest {
11731173

11741174
@Test
11751175
fun `withReadOnlyConnection sets readOnly and rolls back after execution`() {
1176-
val config = DbConnectionConfig("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", readOnly = true)
1176+
val config = DbConnectionConfig("jdbc:h2:mem:test;MODE=MySQL;DB_CLOSE_DELAY=-1", readOnly = true)
11771177

11781178
var wasExecuted = false
11791179
val result = withReadOnlyConnection(config) { conn ->

dataframe-jdbc/src/test/kotlin/org/jetbrains/kotlinx/dataframe/io/local/duckDbTest.kt

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,19 @@ import org.jetbrains.kotlinx.dataframe.io.readResultSet
3131
import org.jetbrains.kotlinx.dataframe.io.readSqlQuery
3232
import org.jetbrains.kotlinx.dataframe.io.readSqlTable
3333
import org.jetbrains.kotlinx.dataframe.schema.DataFrameSchema
34-
import org.junit.Ignore
3534
import org.junit.Test
3635
import java.math.BigDecimal
3736
import java.math.BigInteger
3837
import java.nio.ByteBuffer
38+
import java.nio.file.Files
3939
import java.sql.Blob
4040
import java.sql.DriverManager
4141
import java.sql.Timestamp
4242
import java.time.LocalDate
4343
import java.time.LocalTime
4444
import java.time.OffsetDateTime
4545
import java.util.UUID
46+
import kotlin.io.path.createTempDirectory
4647

4748
private const val URL = "jdbc:duckdb:"
4849

@@ -624,12 +625,35 @@ class DuckDbTest {
624625
}
625626
}
626627

627-
// TODO Issue #1365
628-
@Ignore
629628
@Test
630629
fun `change read mode`() {
631-
val config = DbConnectionConfig("jdbc:duckdb:", readOnly = true)
630+
// Test in-memory database (cannot be read-only)
631+
val config = DbConnectionConfig("jdbc:duckdb:")
632632
val df = config.readDataFrame("SELECT 1, 2, 3")
633633
df.values().toList() shouldBe listOf(1, 2, 3)
634634
}
635+
636+
@Test
637+
fun `change read mode with persistent database`() {
638+
// Test read-only mode with a temporary file
639+
val tempDir = createTempDirectory("duckdb-test-")
640+
val dbPath = tempDir.resolve("test.duckdb")
641+
try {
642+
// First, create the database with actual data using plain JDBC to allow DDL/DML
643+
DriverManager.getConnection("jdbc:duckdb:${dbPath.toAbsolutePath()}").use { connection ->
644+
connection.createStatement().use { st ->
645+
st.executeUpdate("CREATE TABLE test_data(col1 INTEGER, col2 INTEGER, col3 INTEGER)")
646+
st.executeUpdate("INSERT INTO test_data VALUES (1, 2, 3)")
647+
}
648+
}
649+
650+
// Now test read-only access via our API
651+
val config = DbConnectionConfig("jdbc:duckdb:${dbPath.toAbsolutePath()}", readOnly = true)
652+
val df = config.readDataFrame("SELECT col1, col2, col3 FROM test_data")
653+
df.values().toList() shouldBe listOf(1, 2, 3)
654+
} finally {
655+
Files.deleteIfExists(dbPath)
656+
Files.deleteIfExists(tempDir)
657+
}
658+
}
635659
}

0 commit comments

Comments
 (0)