Skip to content

Commit a81307f

Browse files
authored
Added readOnly mode (#1325)
* Introduce `DbConnectionConfig` with `readOnly` support & refactor JDBC connection handling - Added `DbConnectionConfig` to manage JDBC connection parameters, including `readOnly` mode for safe, read-only operations. - Refactored JDBC handling to use `withReadOnlyConnection` utility, ensuring connections are properly closed, rolled back, and safe from unintended writes. - Updated related methods (`readSqlTable`, `readSqlQuery`, etc.) to utilize the new utility. - Enhanced documentation with detailed examples and behavior clarifications for `DbConnectionConfig`. - Added tests for `withReadOnlyConnection` to validate read-only mode and connection rollback behavior. * Linter * Fix apiDump and review comments
1 parent ecf48c5 commit a81307f

File tree

3 files changed

+210
-33
lines changed

3 files changed

+210
-33
lines changed

dataframe-jdbc/api/dataframe-jdbc.api

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
public final class org/jetbrains/kotlinx/dataframe/io/DbConnectionConfig {
2-
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
3-
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
2+
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V
3+
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
44
public final fun component1 ()Ljava/lang/String;
55
public final fun component2 ()Ljava/lang/String;
66
public final fun component3 ()Ljava/lang/String;
7-
public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/jetbrains/kotlinx/dataframe/io/DbConnectionConfig;
8-
public static synthetic fun copy$default (Lorg/jetbrains/kotlinx/dataframe/io/DbConnectionConfig;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lorg/jetbrains/kotlinx/dataframe/io/DbConnectionConfig;
7+
public final fun component4 ()Z
8+
public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)Lorg/jetbrains/kotlinx/dataframe/io/DbConnectionConfig;
9+
public static synthetic fun copy$default (Lorg/jetbrains/kotlinx/dataframe/io/DbConnectionConfig;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)Lorg/jetbrains/kotlinx/dataframe/io/DbConnectionConfig;
910
public fun equals (Ljava/lang/Object;)Z
1011
public final fun getPassword ()Ljava/lang/String;
12+
public final fun getReadOnly ()Z
1113
public final fun getUrl ()Ljava/lang/String;
1214
public final fun getUser ()Ljava/lang/String;
1315
public fun hashCode ()I

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

Lines changed: 188 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import java.sql.Ref
2222
import java.sql.ResultSet
2323
import java.sql.ResultSetMetaData
2424
import java.sql.RowId
25+
import java.sql.SQLException
2526
import java.sql.SQLXML
2627
import java.sql.Time
2728
import java.sql.Timestamp
@@ -104,13 +105,105 @@ public data class TableColumnMetadata(
104105
public data class TableMetadata(val name: String, val schemaName: String?, val catalogue: String?)
105106

106107
/**
107-
* Represents the configuration for a database connection.
108+
* Represents the configuration for an internally managed JDBC database connection.
108109
*
109-
* @property [url] the URL of the database. Keep it in the following form jdbc:subprotocol:subnam
110-
* @property [user] the username used for authentication (optional, default is empty string).
111-
* @property [password] the password used for authentication (optional, default is empty string).
110+
* This class defines connection parameters used by the library to create a `Connection`
111+
* when the user does not provide one explicitly. It is designed for safe, read-only access by default.
112+
*
113+
* @property url The JDBC URL of the database, e.g., `"jdbc:postgresql://localhost:5432/mydb"`.
114+
* Must follow the standard format: `jdbc:subprotocol:subname`.
115+
*
116+
* @property user The username used for authentication.
117+
* Optional, default is an empty string.
118+
*
119+
* @property password The password used for authentication.
120+
* Optional, default is an empty string.
121+
*
122+
* @property readOnly If `true` (default), the library will create the connection in read-only mode.
123+
* This enables the following behavior:
124+
* - `Connection.setReadOnly(true)`
125+
* - `Connection.setAutoCommit(false)`
126+
* - automatic `rollback()` at the end of execution
127+
*
128+
* If `false`, the connection will be created with JDBC defaults (usually read-write),
129+
* but the library will still reject any queries that appear to modify data
130+
* (e.g. contain `INSERT`, `UPDATE`, `DELETE`, etc.).
131+
*
132+
* Note: Connections created using this configuration are managed entirely by the library.
133+
* Users do not have access to the underlying `Connection` instance and cannot commit or close it manually.
134+
*
135+
* ### Examples:
136+
*
137+
* ```kotlin
138+
* // Safe read-only connection (default)
139+
* val config = DbConnectionConfig("jdbc:sqlite::memory:")
140+
* val df = DataFrame.readSqlQuery(config, "SELECT * FROM books")
141+
*
142+
* // Use default JDBC connection settings (still protected against mutations)
143+
* val config = DbConnectionConfig(
144+
* url = "jdbc:sqlite::memory:",
145+
* readOnly = false
146+
* )
147+
* ```
112148
*/
113-
public data class DbConnectionConfig(val url: String, val user: String = "", val password: String = "")
149+
public data class DbConnectionConfig(
150+
val url: String,
151+
val user: String = "",
152+
val password: String = "",
153+
val readOnly: Boolean = true,
154+
)
155+
156+
/**
157+
* Executes the given block with a managed JDBC connection created from [DbConnectionConfig].
158+
*
159+
* If [DbConnectionConfig.readOnly] is `true` (default), the connection will be:
160+
* - explicitly marked as read-only
161+
* - used with auto-commit disabled
162+
* - rolled back after execution to prevent unintended modifications
163+
*
164+
* This utility guarantees proper closing of the connection and safe rollback in read-only mode.
165+
* It should be used when the user does not manually manage JDBC connections.
166+
*
167+
* @param [dbConfig] The configuration used to create the connection.
168+
* @param [dbType] Optional database type (not used here but can be passed through for logging or future extensions).
169+
* @param [block] A lambda with receiver that runs with an open and managed [Connection].
170+
* @return The result of the [block] execution.
171+
*/
172+
internal inline fun <T> withReadOnlyConnection(
173+
dbConfig: DbConnectionConfig,
174+
dbType: DbType? = null,
175+
block: (Connection) -> T,
176+
): T {
177+
val connection = DriverManager.getConnection(dbConfig.url, dbConfig.user, dbConfig.password)
178+
179+
val originalAutoCommit = connection.autoCommit
180+
val originalReadOnly = connection.isReadOnly
181+
182+
return connection.use { conn ->
183+
try {
184+
if (dbConfig.readOnly) {
185+
conn.autoCommit = false
186+
conn.isReadOnly = true
187+
}
188+
189+
block(conn)
190+
} finally {
191+
if (dbConfig.readOnly) {
192+
try {
193+
conn.rollback()
194+
} catch (e: SQLException) {
195+
logger.warn(e) {
196+
"Failed to rollback read-only transaction (url=${dbConfig.url})"
197+
}
198+
}
199+
}
200+
201+
// Restore original settings (relevant in pooled environments)
202+
conn.autoCommit = originalAutoCommit
203+
conn.isReadOnly = originalReadOnly
204+
}
205+
}
206+
}
114207

115208
/**
116209
* Reads data from an SQL table and converts it into a DataFrame.
@@ -124,6 +217,15 @@ public data class DbConnectionConfig(val url: String, val user: String = "", val
124217
* @param [strictValidation] if `true`, the method validates that the provided table name is in a valid format.
125218
* Default is `true` for strict validation.
126219
* @return the DataFrame containing the data from the SQL table.
220+
*
221+
* ### Default Behavior:
222+
* If [DbConnectionConfig.readOnly] is `true` (which is the default), the connection will be:
223+
* - explicitly set as read-only via `Connection.setReadOnly(true)`
224+
* - used with `autoCommit = false`
225+
* - automatically rolled back after reading, ensuring no changes to the database
226+
*
227+
* Even if [DbConnectionConfig.readOnly] is set to `false`, the library still prevents data-modifying queries
228+
* and only permits safe `SELECT` operations internally.
127229
*/
128230
public fun DataFrame.Companion.readSqlTable(
129231
dbConfig: DbConnectionConfig,
@@ -132,11 +234,10 @@ public fun DataFrame.Companion.readSqlTable(
132234
inferNullability: Boolean = true,
133235
dbType: DbType? = null,
134236
strictValidation: Boolean = true,
135-
): AnyFrame {
136-
DriverManager.getConnection(dbConfig.url, dbConfig.user, dbConfig.password).use { connection ->
137-
return readSqlTable(connection, tableName, limit, inferNullability, dbType, strictValidation)
237+
): AnyFrame =
238+
withReadOnlyConnection(dbConfig, dbType) { conn ->
239+
readSqlTable(conn, tableName, limit, inferNullability, dbType, strictValidation)
138240
}
139-
}
140241

141242
/**
142243
* Reads data from an SQL table and converts it into a DataFrame.
@@ -203,6 +304,15 @@ public fun DataFrame.Companion.readSqlTable(
203304
* @param [strictValidation] if `true`, the method validates that the provided query is in a valid format.
204305
* Default is `true` for strict validation.
205306
* @return the DataFrame containing the result of the SQL query.
307+
*
308+
* ### Default Behavior:
309+
* If [DbConnectionConfig.readOnly] is `true` (which is the default), the connection will be:
310+
* - explicitly set as read-only via `Connection.setReadOnly(true)`
311+
* - used with `autoCommit = false`
312+
* - automatically rolled back after reading, ensuring no changes to the database
313+
*
314+
* Even if [DbConnectionConfig.readOnly] is set to `false`, the library still prevents data-modifying queries
315+
* and only permits safe `SELECT` operations internally.
206316
*/
207317

208318
public fun DataFrame.Companion.readSqlQuery(
@@ -212,11 +322,10 @@ public fun DataFrame.Companion.readSqlQuery(
212322
inferNullability: Boolean = true,
213323
dbType: DbType? = null,
214324
strictValidation: Boolean = true,
215-
): AnyFrame {
216-
DriverManager.getConnection(dbConfig.url, dbConfig.user, dbConfig.password).use { connection ->
217-
return readSqlQuery(connection, sqlQuery, limit, inferNullability, dbType, strictValidation)
325+
): AnyFrame =
326+
withReadOnlyConnection(dbConfig, dbType) { conn ->
327+
readSqlQuery(conn, sqlQuery, limit, inferNullability, dbType, strictValidation)
218328
}
219-
}
220329

221330
/**
222331
* Converts the result of an SQL query to the DataFrame.
@@ -281,6 +390,15 @@ public fun DataFrame.Companion.readSqlQuery(
281390
* @param [strictValidation] if `true`, the method validates that the provided query or table name is in a valid format.
282391
* Default is `true` for strict validation.
283392
* @return the DataFrame containing the result of the SQL query.
393+
*
394+
* ### Default Behavior:
395+
* If [DbConnectionConfig.readOnly] is `true` (which is the default), the connection will be:
396+
* - explicitly set as read-only via `Connection.setReadOnly(true)`
397+
* - used with `autoCommit = false`
398+
* - automatically rolled back after reading, ensuring no changes to the database
399+
*
400+
* Even if [DbConnectionConfig.readOnly] is set to `false`, the library still prevents data-modifying queries
401+
* and only permits safe `SELECT` operations internally.
284402
*/
285403
public fun DbConnectionConfig.readDataFrame(
286404
sqlQueryOrTableName: String,
@@ -638,18 +756,26 @@ public fun ResultSet.readDataFrame(
638756
* @param [dbType] the type of database, could be a custom object, provided by user, optional, default is `null`,
639757
* in that case the [dbType] will be recognized from the [dbConfig].
640758
* @return a map of [String] to [AnyFrame] objects representing the non-system tables from the database.
759+
*
760+
* ### Default Behavior:
761+
* If [DbConnectionConfig.readOnly] is `true` (which is the default), the connection will be:
762+
* - explicitly set as read-only via `Connection.setReadOnly(true)`
763+
* - used with `autoCommit = false`
764+
* - automatically rolled back after reading, ensuring no changes to the database
765+
*
766+
* Even if [DbConnectionConfig.readOnly] is set to `false`, the library still prevents data-modifying queries
767+
* and only permits safe `SELECT` operations internally.
641768
*/
642769
public fun DataFrame.Companion.readAllSqlTables(
643770
dbConfig: DbConnectionConfig,
644771
catalogue: String? = null,
645772
limit: Int = DEFAULT_LIMIT,
646773
inferNullability: Boolean = true,
647774
dbType: DbType? = null,
648-
): Map<String, AnyFrame> {
649-
DriverManager.getConnection(dbConfig.url, dbConfig.user, dbConfig.password).use { connection ->
650-
return readAllSqlTables(connection, catalogue, limit, inferNullability, dbType)
775+
): Map<String, AnyFrame> =
776+
withReadOnlyConnection(dbConfig, dbType) { connection ->
777+
readAllSqlTables(connection, catalogue, limit, inferNullability, dbType)
651778
}
652-
}
653779

654780
/**
655781
* Reads all non-system tables from a database and returns them
@@ -712,16 +838,24 @@ public fun DataFrame.Companion.readAllSqlTables(
712838
* @param [dbType] the type of database, could be a custom object, provided by user, optional, default is `null`,
713839
* in that case the [dbType] will be recognized from the [dbConfig].
714840
* @return the [DataFrameSchema] object representing the schema of the SQL table
841+
*
842+
* ### Default Behavior:
843+
* If [DbConnectionConfig.readOnly] is `true` (which is the default), the connection will be:
844+
* - explicitly set as read-only via `Connection.setReadOnly(true)`
845+
* - used with `autoCommit = false`
846+
* - automatically rolled back after reading, ensuring no changes to the database
847+
*
848+
* Even if [DbConnectionConfig.readOnly] is set to `false`, the library still prevents data-modifying queries
849+
* and only permits safe `SELECT` operations internally.
715850
*/
716851
public fun DataFrame.Companion.getSchemaForSqlTable(
717852
dbConfig: DbConnectionConfig,
718853
tableName: String,
719854
dbType: DbType? = null,
720-
): DataFrameSchema {
721-
DriverManager.getConnection(dbConfig.url, dbConfig.user, dbConfig.password).use { connection ->
722-
return getSchemaForSqlTable(connection, tableName, dbType)
855+
): DataFrameSchema =
856+
withReadOnlyConnection(dbConfig, dbType) { connection ->
857+
getSchemaForSqlTable(connection, tableName, dbType)
723858
}
724-
}
725859

726860
/**
727861
* Retrieves the schema for an SQL table using the provided database connection.
@@ -760,16 +894,24 @@ public fun DataFrame.Companion.getSchemaForSqlTable(
760894
* @param [dbType] the type of database, could be a custom object, provided by user, optional, default is `null`,
761895
* in that case the [dbType] will be recognized from the [dbConfig].
762896
* @return the schema of the SQL query as a [DataFrameSchema] object.
897+
*
898+
* ### Default Behavior:
899+
* If [DbConnectionConfig.readOnly] is `true` (which is the default), the connection will be:
900+
* - explicitly set as read-only via `Connection.setReadOnly(true)`
901+
* - used with `autoCommit = false`
902+
* - automatically rolled back after reading, ensuring no changes to the database
903+
*
904+
* Even if [DbConnectionConfig.readOnly] is set to `false`, the library still prevents data-modifying queries
905+
* and only permits safe `SELECT` operations internally.
763906
*/
764907
public fun DataFrame.Companion.getSchemaForSqlQuery(
765908
dbConfig: DbConnectionConfig,
766909
sqlQuery: String,
767910
dbType: DbType? = null,
768-
): DataFrameSchema {
769-
DriverManager.getConnection(dbConfig.url, dbConfig.user, dbConfig.password).use { connection ->
770-
return getSchemaForSqlQuery(connection, sqlQuery, dbType)
911+
): DataFrameSchema =
912+
withReadOnlyConnection(dbConfig, dbType) { connection ->
913+
getSchemaForSqlQuery(connection, sqlQuery, dbType)
771914
}
772-
}
773915

774916
/**
775917
* Retrieves the schema of an SQL query result using the provided database connection.
@@ -804,6 +946,15 @@ public fun DataFrame.Companion.getSchemaForSqlQuery(
804946
* @param [dbType] the type of database, could be a custom object, provided by user, optional, default is `null`,
805947
* in that case the [dbType] will be recognized from the [DbConnectionConfig].
806948
* @return the schema of the SQL query as a [DataFrameSchema] object.
949+
*
950+
* ### Default Behavior:
951+
* If [DbConnectionConfig.readOnly] is `true` (which is the default), the connection will be:
952+
* - explicitly set as read-only via `Connection.setReadOnly(true)`
953+
* - used with `autoCommit = false`
954+
* - automatically rolled back after reading, ensuring no changes to the database
955+
*
956+
* Even if [DbConnectionConfig.readOnly] is set to `false`, the library still prevents data-modifying queries
957+
* and only permits safe `SELECT` operations internally.
807958
*/
808959
public fun DbConnectionConfig.getDataFrameSchema(
809960
sqlQueryOrTableName: String,
@@ -869,15 +1020,23 @@ public fun ResultSet.getDataFrameSchema(dbType: DbType): DataFrameSchema = DataF
8691020
* @param [dbType] the type of database, could be a custom object, provided by user, optional, default is `null`,
8701021
* in that case the [dbType] will be recognized from the [dbConfig].
8711022
* @return a map of [String, DataFrameSchema] objects representing the table name and its schema for each non-system table.
1023+
*
1024+
* ### Default Behavior:
1025+
* If [DbConnectionConfig.readOnly] is `true` (which is the default), the connection will be:
1026+
* - explicitly set as read-only via `Connection.setReadOnly(true)`
1027+
* - used with `autoCommit = false`
1028+
* - automatically rolled back after reading, ensuring no changes to the database
1029+
*
1030+
* Even if [DbConnectionConfig.readOnly] is set to `false`, the library still prevents data-modifying queries
1031+
* and only permits safe `SELECT` operations internally.
8721032
*/
8731033
public fun DataFrame.Companion.getSchemaForAllSqlTables(
8741034
dbConfig: DbConnectionConfig,
8751035
dbType: DbType? = null,
876-
): Map<String, DataFrameSchema> {
877-
DriverManager.getConnection(dbConfig.url, dbConfig.user, dbConfig.password).use { connection ->
878-
return getSchemaForAllSqlTables(connection, dbType)
1036+
): Map<String, DataFrameSchema> =
1037+
withReadOnlyConnection(dbConfig, dbType) { connection ->
1038+
getSchemaForAllSqlTables(connection, dbType)
8791039
}
880-
}
8811040

8821041
/**
8831042
* Retrieves the schemas of all non-system tables in the database using the provided database connection.

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import org.jetbrains.kotlinx.dataframe.io.readDataFrame
2525
import org.jetbrains.kotlinx.dataframe.io.readResultSet
2626
import org.jetbrains.kotlinx.dataframe.io.readSqlQuery
2727
import org.jetbrains.kotlinx.dataframe.io.readSqlTable
28+
import org.jetbrains.kotlinx.dataframe.io.withReadOnlyConnection
2829
import org.junit.AfterClass
2930
import org.junit.BeforeClass
3031
import org.junit.Test
@@ -1169,4 +1170,19 @@ class JdbcTest {
11691170
saleDataSchema1.columns.size shouldBe 3
11701171
saleDataSchema1.columns["amount"]!!.type shouldBe typeOf<BigDecimal>()
11711172
}
1173+
1174+
@Test
1175+
fun `withReadOnlyConnection sets readOnly and rolls back after execution`() {
1176+
val config = DbConnectionConfig("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", readOnly = true)
1177+
1178+
var wasExecuted = false
1179+
val result = withReadOnlyConnection(config) { conn ->
1180+
wasExecuted = true
1181+
conn.autoCommit shouldBe false
1182+
42
1183+
}
1184+
1185+
wasExecuted shouldBe true
1186+
result shouldBe 42
1187+
}
11721188
}

0 commit comments

Comments
 (0)