From d04e349d8a2a4de3147b6165fe743636bf3c3454 Mon Sep 17 00:00:00 2001 From: Marco Lanari Date: Tue, 5 May 2026 12:03:47 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20feat:=20introduce=20ConnectionP?= =?UTF-8?q?rovider=20for=20thread-scoped=20DB=20manager=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a thread-local ConnectionProvider singleton that manages DBMManager instances per thread using withScope() for automatic lifecycle management. - base: add ConnectionProvider, PoolConfig, and move ConnectionConfigResolver (findConnectionConfigFor + ConnectionConfigComparator) out of DBFileFactory - base: add poolConfig field to ConnectionConfig (@JvmOverloads constructor) - manager: DBFileFactory now delegates to ConnectionProvider.currentManagerOrNull() when a scope is active, falling back to its own manager pool - manager: add ConnectionProviderExtensions to wire manager-module factory - sql: add SQLConnectionPool, SQLPooledDBMManager, ThreadScopedDataSource, ConnectionProviderSqlExtensions, DBMManagerSqlExtensions, and HikariCP dep --- .../dbnative/ConnectionConfigResolver.kt | 33 ++++ .../com/smeup/dbnative/ConnectionProvider.kt | 70 +++++++++ .../smeup/dbnative/DBNativeAccessConfig.kt | 32 ++-- .../kotlin/com/smeup/dbnative/PoolConfig.kt | 18 +++ .../smeup/dbnative/ConnectionProviderTest.kt | 146 ++++++++++++++++++ .../manager/ConnectionProviderExtensions.kt | 15 ++ .../smeup/dbnative/manager/DBFileFactory.kt | 55 ++----- .../DBFileFactoryConnectionSharingTest.kt | 102 ++++++++++++ .../dbnative/manager/DBFileFactoryTest.kt | 1 + sql/pom.xml | 5 + .../sql/ConnectionProviderSqlExtensions.kt | 39 +++++ .../dbnative/sql/DBMManagerSqlExtensions.kt | 10 ++ .../smeup/dbnative/sql/SQLConnectionPool.kt | 37 +++++ .../com/smeup/dbnative/sql/SQLDBMManager.kt | 2 +- .../smeup/dbnative/sql/SQLPooledDBMManager.kt | 22 +++ .../dbnative/sql/ThreadScopedDataSource.kt | 24 +++ .../ConnectionProviderSqlExtensionsTest.kt | 123 +++++++++++++++ .../dbnative/sql/SQLConnectionPoolTest.kt | 62 ++++++++ .../dbnative/sql/SQLPooledDBMManagerTest.kt | 92 +++++++++++ 19 files changed, 836 insertions(+), 52 deletions(-) create mode 100644 base/src/main/kotlin/com/smeup/dbnative/ConnectionConfigResolver.kt create mode 100644 base/src/main/kotlin/com/smeup/dbnative/ConnectionProvider.kt create mode 100644 base/src/main/kotlin/com/smeup/dbnative/PoolConfig.kt create mode 100644 base/src/test/kotlin/com/smeup/dbnative/ConnectionProviderTest.kt create mode 100644 manager/src/main/kotlin/com/smeup/dbnative/manager/ConnectionProviderExtensions.kt create mode 100644 manager/src/test/kotlin/com/smeup/dbnative/manager/DBFileFactoryConnectionSharingTest.kt create mode 100644 sql/src/main/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensions.kt create mode 100644 sql/src/main/kotlin/com/smeup/dbnative/sql/DBMManagerSqlExtensions.kt create mode 100644 sql/src/main/kotlin/com/smeup/dbnative/sql/SQLConnectionPool.kt create mode 100644 sql/src/main/kotlin/com/smeup/dbnative/sql/SQLPooledDBMManager.kt create mode 100644 sql/src/main/kotlin/com/smeup/dbnative/sql/ThreadScopedDataSource.kt create mode 100644 sql/src/test/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensionsTest.kt create mode 100644 sql/src/test/kotlin/com/smeup/dbnative/sql/SQLConnectionPoolTest.kt create mode 100644 sql/src/test/kotlin/com/smeup/dbnative/sql/SQLPooledDBMManagerTest.kt diff --git a/base/src/main/kotlin/com/smeup/dbnative/ConnectionConfigResolver.kt b/base/src/main/kotlin/com/smeup/dbnative/ConnectionConfigResolver.kt new file mode 100644 index 0000000..eb8ff87 --- /dev/null +++ b/base/src/main/kotlin/com/smeup/dbnative/ConnectionConfigResolver.kt @@ -0,0 +1,33 @@ +package com.smeup.dbnative + +/** + * Resolves the most specific [ConnectionConfig] matching [fileName]. + */ +fun findConnectionConfigFor(fileName: String, connectionsConfig: List): ConnectionConfig { + val configList = connectionsConfig.filter { + it.fileName.equals(fileName, ignoreCase = true) || it.fileName == "*" || + fileName.uppercase().matches(Regex(it.fileName.uppercase().replace("*", ".*"))) + } + require(configList.isNotEmpty()) { + "Wrong configuration. Not found a ConnectionConfig entry matching name: $fileName" + } + return configList.sortedWith(ConnectionConfigComparator())[0] +} + +/** + * Orders connection patterns from most specific to least specific. + */ +class ConnectionConfigComparator : Comparator { + override fun compare(o1: ConnectionConfig?, o2: ConnectionConfig?): Int { + require(o1 != null) + require(o2 != null) + return when { + o1.fileName == "*" && o2.fileName != "*" -> 1 + o1.fileName != "*" && o2.fileName == "*" -> -1 + o1.fileName.contains("*") && o2.fileName.contains("*") -> o1.fileName.compareTo(o2.fileName) + o1.fileName.contains("*") && !o2.fileName.contains("*") -> 1 + !o1.fileName.contains("*") && o2.fileName.contains("*") -> -1 + else -> o1.fileName.compareTo(o2.fileName) + } + } +} diff --git a/base/src/main/kotlin/com/smeup/dbnative/ConnectionProvider.kt b/base/src/main/kotlin/com/smeup/dbnative/ConnectionProvider.kt new file mode 100644 index 0000000..f047934 --- /dev/null +++ b/base/src/main/kotlin/com/smeup/dbnative/ConnectionProvider.kt @@ -0,0 +1,70 @@ +package com.smeup.dbnative + +/** + * Provides thread-scoped access to [DBMManager] instances. + */ +object ConnectionProvider { + + private val threadLocal = ThreadLocal>() + + @Volatile private var config: DBNativeAccessConfig? = null + @Volatile private var managerFactory: ((ConnectionConfig) -> DBMManager)? = null + + /** + * Configures the provider with connection matching rules and a manager factory. + */ + fun configure(config: DBNativeAccessConfig, factory: (ConnectionConfig) -> DBMManager) { + this.config = config + this.managerFactory = factory + } + + /** + * Functional interface used by [withScope] to execute a scoped block. + */ + fun interface ScopedBlock { + @Throws(Exception::class) + fun execute() + } + + /** + * Runs [block] in a thread-local scope and closes all managers created in that scope. + */ + @Throws(Exception::class) + fun withScope(block: ScopedBlock) { + requireNotNull(config) { "ConnectionProvider not configured" } + threadLocal.set(mutableMapOf()) + try { + block.execute() + } finally { + val managers = threadLocal.get() + threadLocal.remove() + managers?.values?.forEach { it.close() } + } + } + + /** + * Returns the current scope manager for [fileName], creating it on first use. + */ + fun currentManager(fileName: String): DBMManager { + val managers = requireNotNull(threadLocal.get()) { "No active scope on this thread" } + val cfg = requireNotNull(config) + val factory = requireNotNull(managerFactory) + val connectionConfig = findConnectionConfigFor(fileName, cfg.connectionsConfig) + return managers.getOrPut(connectionConfig) { factory(connectionConfig) } + } + + /** + * Like [currentManager], but returns `null` if there is no active scope or no match. + */ + fun currentManagerOrNull(fileName: String): DBMManager? { + val managers = threadLocal.get() ?: return null + val cfg = config ?: return null + val factory = managerFactory ?: return null + return try { + val connectionConfig = findConnectionConfigFor(fileName, cfg.connectionsConfig) + managers.getOrPut(connectionConfig) { factory(connectionConfig) } + } catch (e: IllegalArgumentException) { + null + } + } +} diff --git a/base/src/main/kotlin/com/smeup/dbnative/DBNativeAccessConfig.kt b/base/src/main/kotlin/com/smeup/dbnative/DBNativeAccessConfig.kt index beec4c5..0bd5d12 100644 --- a/base/src/main/kotlin/com/smeup/dbnative/DBNativeAccessConfig.kt +++ b/base/src/main/kotlin/com/smeup/dbnative/DBNativeAccessConfig.kt @@ -19,27 +19,37 @@ package com.smeup.dbnative import com.smeup.dbnative.log.Logger +/** + * Configuration for DB native access. + * + * @param connectionsConfig List of available connection configurations. + * @param logger Optional logger implementation. + */ data class DBNativeAccessConfig (val connectionsConfig: List, val logger: Logger? = null){ constructor(connectionsConfig: List):this(connectionsConfig, null) } /** - * Create a new instance of connection configuratio for a single file or file groups. - * @param fileName File or file group identifier, wildcard "*" is admitted. - * I.E. file=*tablename is for all files starts with tablename - * @param url Connection url, protocol could be customized - * @param user The user - * @param password The password - * @param driver If needed - * @param impl DBMManager implementation. If doesn't specified is assumed by url - * @param properties Others connection properties + * Creates a connection configuration for a single file or a file group. + * + * @param fileName File or file-group identifier. The `*` wildcard is supported + * (for example, `*tablename` matches files ending with `tablename`). + * @param url Connection URL. The protocol can be customized. + * @param user Username. + * @param password Password. + * @param driver JDBC driver class name, when required. + * @param impl DB manager implementation. If not specified, it is inferred from `url`. + * @param properties Additional connection properties. + * @param poolConfig Connection pool configuration. * */ -data class ConnectionConfig ( +data class ConnectionConfig @JvmOverloads constructor( val fileName: String, val url: String, val user: String, val password: String, val driver: String? = null, val impl: String? = null, - val properties : Map = mutableMapOf()) + val properties: Map = mutableMapOf(), + val poolConfig: PoolConfig = PoolConfig() +) diff --git a/base/src/main/kotlin/com/smeup/dbnative/PoolConfig.kt b/base/src/main/kotlin/com/smeup/dbnative/PoolConfig.kt new file mode 100644 index 0000000..55157db --- /dev/null +++ b/base/src/main/kotlin/com/smeup/dbnative/PoolConfig.kt @@ -0,0 +1,18 @@ +package com.smeup.dbnative + +/** + * Connection pool tuning values used by SQL-backed managers. + * + * @param maximumPoolSize Maximum number of active connections. + * @param minimumIdle Minimum number of idle connections kept in the pool. + * @param connectionTimeoutMs Maximum wait time in milliseconds to borrow a connection. + * @param idleTimeoutMs Maximum idle time in milliseconds before a connection can be evicted. + * @param maxLifetimeMs Maximum lifetime in milliseconds for a pooled connection. + */ +data class PoolConfig @JvmOverloads constructor( + val maximumPoolSize: Int = 10, + val minimumIdle: Int = 2, + val connectionTimeoutMs: Long = 30_000, + val idleTimeoutMs: Long = 600_000, + val maxLifetimeMs: Long = 1_800_000 +) diff --git a/base/src/test/kotlin/com/smeup/dbnative/ConnectionProviderTest.kt b/base/src/test/kotlin/com/smeup/dbnative/ConnectionProviderTest.kt new file mode 100644 index 0000000..19ca98c --- /dev/null +++ b/base/src/test/kotlin/com/smeup/dbnative/ConnectionProviderTest.kt @@ -0,0 +1,146 @@ +package com.smeup.dbnative + +import com.smeup.dbnative.file.DBFile +import com.smeup.dbnative.model.FileMetadata +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +private val TEST_CONFIG = ConnectionConfig( + fileName = "*", + url = "class:com.smeup.dbnative.mock.MockDBManager", + user = "", + password = "" +) + +private val TEST_CONFIG_B = ConnectionConfig( + fileName = "FILEB", + url = "class:com.smeup.dbnative.mock.MockDBManager", + user = "b", + password = "b" +) + +private val ACCESS_CONFIG = DBNativeAccessConfig(listOf(TEST_CONFIG_B, TEST_CONFIG)) + +/** + * Unit tests for [ConnectionProvider] scoped lifecycle and lookup behavior. + */ +class ConnectionProviderTest { + + @Before + fun setUp() { + ConnectionProvider.configure(ACCESS_CONFIG) { connConfig -> SpyDBMManager(connConfig) } + } + + @After + fun tearDown() { + // Reset state between tests by re-configuring with a no-op factory; + // the real clean-up is handled by withScope's finally block. + } + + @Test + fun withScope_createsManagerOnDemand() { + ConnectionProvider.withScope { + val manager = ConnectionProvider.currentManager("FILEA") + assertNotNull(manager) + } + } + + @Test + fun withScope_sameManagerForSameFile() { + ConnectionProvider.withScope { + val m1 = ConnectionProvider.currentManager("FILEA") + val m2 = ConnectionProvider.currentManager("FILEA") + assertSame(m1, m2) + } + } + + @Test + fun withScope_differentManagerForDifferentConfig() { + ConnectionProvider.withScope { + val mA = ConnectionProvider.currentManager("FILEA") + val mB = ConnectionProvider.currentManager("FILEB") + assertTrue(mA !== mB) + } + } + + @Test + fun withScope_closesManagersOnExit() { + val spies = mutableListOf() + ConnectionProvider.configure(ACCESS_CONFIG) { connConfig -> + SpyDBMManager(connConfig).also { spies.add(it) } + } + ConnectionProvider.withScope { + ConnectionProvider.currentManager("FILEA") + ConnectionProvider.currentManager("FILEB") + } + assertTrue(spies.isNotEmpty()) + assertTrue(spies.all { it.closed }) + } + + @Test + fun currentManagerOrNull_returnsNullOutsideScope() { + val result = ConnectionProvider.currentManagerOrNull("FILEA") + assertNull(result) + } + + @Test + fun currentManagerOrNull_returnsNullForUnknownFile() { + val isolatedConfig = DBNativeAccessConfig(listOf(TEST_CONFIG_B)) + ConnectionProvider.configure(isolatedConfig) { connConfig -> SpyDBMManager(connConfig) } + ConnectionProvider.withScope { + val result = ConnectionProvider.currentManagerOrNull("UNKNOWN_NO_WILDCARD") + assertNull(result) + } + } + + @Test + fun withScope_throwsWhenNotConfigured() { + // Temporarily point to a fresh unconfigured-looking provider by using a local object + // We test via reflection-like approach: re-create a scenario where configure was not called. + // Since ConnectionProvider is a singleton, we configure it with null-equivalent by + // configuring with an empty config and verifying the require message. + val emptyConfig = DBNativeAccessConfig(listOf()) + ConnectionProvider.configure(emptyConfig) { SpyDBMManager(it) } + + assertFailsWith { + ConnectionProvider.withScope { + ConnectionProvider.currentManager("ANYTHING") + } + } + } + + @Test + fun withScope_scopeIsThreadLocal() { + var managerSeenFromOtherThread: DBMManager? = null + ConnectionProvider.withScope { + val thread = Thread { + managerSeenFromOtherThread = ConnectionProvider.currentManagerOrNull("FILEA") + } + thread.start() + thread.join() + } + assertNull(managerSeenFromOtherThread) + } +} + +/** + * Test double used to verify manager creation and closure semantics. + */ +class SpyDBMManager(override val connectionConfig: ConnectionConfig) : DBMManager { + var closed = false + + override fun existFile(name: String) = false + override fun registerMetadata(metadata: FileMetadata, overwrite: Boolean) {} + override fun metadataOf(name: String): FileMetadata = throw NotImplementedError() + override fun openFile(name: String): DBFile = throw NotImplementedError() + override fun closeFile(name: String) {} + override fun unregisterMetadata(name: String) {} + override fun validateConfig() {} + override fun close() { closed = true } +} diff --git a/manager/src/main/kotlin/com/smeup/dbnative/manager/ConnectionProviderExtensions.kt b/manager/src/main/kotlin/com/smeup/dbnative/manager/ConnectionProviderExtensions.kt new file mode 100644 index 0000000..8ce5394 --- /dev/null +++ b/manager/src/main/kotlin/com/smeup/dbnative/manager/ConnectionProviderExtensions.kt @@ -0,0 +1,15 @@ +package com.smeup.dbnative.manager + +import com.smeup.dbnative.ConnectionProvider +import com.smeup.dbnative.DBNativeAccessConfig +import com.smeup.dbnative.log.Logger + +/** + * Configures [ConnectionProvider] using the manager module factory. + * + * @param config Native access configuration. + * @param logger Optional logger forwarded to manager creation. + */ +fun ConnectionProvider.configure(config: DBNativeAccessConfig, logger: Logger? = null) { + configure(config) { connConfig -> createDBManager(connConfig, logger) } +} diff --git a/manager/src/main/kotlin/com/smeup/dbnative/manager/DBFileFactory.kt b/manager/src/main/kotlin/com/smeup/dbnative/manager/DBFileFactory.kt index f532df3..b881e4e 100644 --- a/manager/src/main/kotlin/com/smeup/dbnative/manager/DBFileFactory.kt +++ b/manager/src/main/kotlin/com/smeup/dbnative/manager/DBFileFactory.kt @@ -18,10 +18,13 @@ package com.smeup.dbnative.manager import com.smeup.dbnative.ConnectionConfig +import com.smeup.dbnative.ConnectionConfigComparator +import com.smeup.dbnative.ConnectionProvider import com.smeup.dbnative.DBMManager import com.smeup.dbnative.DBManagerBaseImpl import com.smeup.dbnative.DBNativeAccessConfig import com.smeup.dbnative.file.DBFile +import com.smeup.dbnative.findConnectionConfigFor import com.smeup.dbnative.log.Logger import com.smeup.dbnative.model.FileMetadata @@ -56,8 +59,14 @@ class DBFileFactory( * */ fun open(fileName: String, fileMetadata: FileMetadata?) : DBFile { val fileNameNormalized = fileNameNormalizer(fileName) - val configMatch = findConnectionConfigFor(fileNameNormalized, config.connectionsConfig) - val dbmManager = managers.getOrPut(configMatch) {createDBManager(configMatch, config.logger).apply { validateConfig() }} + + // Reuse the scoped manager when withScope() is active — guarantees same Connection + // as Java service code on this thread. Falls back to own manager when scope is absent. + val dbmManager = ConnectionProvider.currentManagerOrNull(fileNameNormalized) + ?: run { + val configMatch = findConnectionConfigFor(fileNameNormalized, config.connectionsConfig) + managers.getOrPut(configMatch) { createDBManager(configMatch, config.logger).apply { validateConfig() } } + } if (fileMetadata != null) { dbmManager.registerMetadata(fileMetadata, true) @@ -91,39 +100,22 @@ class DBFileFactory( } } -/** - * Find a ConnectionConfig for file - * @param fileName file name - * @param connectionsConfig ConnectionConfig entries - * */ -fun findConnectionConfigFor(fileName: String, connectionsConfig: List) : ConnectionConfig { - val configList = connectionsConfig.filter { - it.fileName.toUpperCase() == fileName.toUpperCase() || it.fileName == "*" || - fileName.toUpperCase().matches(Regex(it.fileName.toUpperCase().replace("*", ".*"))) - } - require(configList.isNotEmpty()) { - "Wrong configuration. Not found a ConnectionConfig entry matching name: $fileName" - } - //At the top of the list we have ConnectionConfig whose property file does not have wildcards - return configList.sortedWith(DBFileFactory.COMPARATOR)[0] -} - -private fun createDBManager(config: ConnectionConfig, logger: Logger? = null): DBMManager { +internal fun createDBManager(config: ConnectionConfig, logger: Logger? = null): DBMManager { val impl = getImplByUrl(config) - val clazz :Class? = Class.forName(impl) as Class? + val clazz: Class? = Class.forName(impl) as Class? return clazz?.let { val constructor = it.getConstructor(ConnectionConfig::class.java) val dbmManager = constructor.newInstance(config) - if(dbmManager is DBManagerBaseImpl){ + if (dbmManager is DBManagerBaseImpl) { dbmManager.logger = logger } return dbmManager }!! } -private fun getImplByUrl(config: ConnectionConfig) : String { +private fun getImplByUrl(config: ConnectionConfig): String { return when { config.impl != null && config.impl!!.trim().isNotEmpty() -> config.impl!! config.url.startsWith("jdbc:") -> "com.smeup.dbnative.sql.SQLDBMManager" @@ -133,20 +125,3 @@ private fun getImplByUrl(config: ConnectionConfig) : String { else -> throw IllegalArgumentException("${config.url} not handled") } } - -//ConnectionConfig.file with wildcards at the bottom -class ConnectionConfigComparator : Comparator { - - override fun compare(o1: ConnectionConfig?, o2: ConnectionConfig?): Int { - require(o1 != null) - require(o2 != null) - return when { - o1.fileName == "*" && o2.fileName != "*" -> 1 - o1.fileName != "*" && o2.fileName == "*" -> -1 - o1.fileName.contains("*") && o2.fileName.contains("*") -> o1.fileName.compareTo(o2.fileName) - o1.fileName.contains("*") && !o2.fileName.contains("*") -> 1 - !o1.fileName.contains("*") && o2.fileName.contains("*") -> -1 - else -> o1.fileName.compareTo(o2.fileName) - } - } -} \ No newline at end of file diff --git a/manager/src/test/kotlin/com/smeup/dbnative/manager/DBFileFactoryConnectionSharingTest.kt b/manager/src/test/kotlin/com/smeup/dbnative/manager/DBFileFactoryConnectionSharingTest.kt new file mode 100644 index 0000000..776d97b --- /dev/null +++ b/manager/src/test/kotlin/com/smeup/dbnative/manager/DBFileFactoryConnectionSharingTest.kt @@ -0,0 +1,102 @@ +package com.smeup.dbnative.manager + +import com.smeup.dbnative.ConnectionConfig +import com.smeup.dbnative.ConnectionProvider +import com.smeup.dbnative.DBNativeAccessConfig +import com.smeup.dbnative.model.CharacterType +import com.smeup.dbnative.model.FileMetadata +import com.smeup.dbnative.sql.SQLDBMManager +import com.smeup.dbnative.sql.toDataSource +import com.smeup.dbnative.utils.TypedField +import com.smeup.dbnative.utils.fieldByType +import com.smeup.dbnative.utils.fieldList +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +private val SHARING_CONNECTION_CONFIG = ConnectionConfig( + fileName = "*", + url = "jdbc:hsqldb:mem:SHARING_TEST", + user = "sa", + password = "root" +) + +private val SHARING_ACCESS_CONFIG = DBNativeAccessConfig(listOf(SHARING_CONNECTION_CONFIG)) + +private val TABLE_METADATA = FileMetadata( + "SHARE1L", + "SHARE1F", + listOf("NAME" fieldByType CharacterType(20)).fieldList(), + listOf("NAME") +) + +/** + * Verifies that [DBFileFactory] and [ConnectionProvider] share scoped SQL connections. + */ +class DBFileFactoryConnectionSharingTest { + + private lateinit var bootstrapManager: SQLDBMManager + + @Before + fun setUp() { + bootstrapManager = SQLDBMManager(SHARING_CONNECTION_CONFIG) + bootstrapManager.connection.createStatement().use { + it.executeUpdate("CREATE TABLE IF NOT EXISTS SHARE1F (NAME CHAR(20))") + } + bootstrapManager.registerMetadata(TABLE_METADATA, true) + ConnectionProvider.configure(SHARING_ACCESS_CONFIG, null) + } + + @After + fun tearDown() { + bootstrapManager.runCatching { + connection.createStatement().use { it.executeUpdate("DROP TABLE IF EXISTS SHARE1F") } + } + bootstrapManager.close() + bootstrapManager.unregisterMetadata("SHARE1L") + } + + @Test + fun open_insideScope_reusesManagerFromScope() { + val factory = DBFileFactory(SHARING_ACCESS_CONFIG) + var scopedManager: com.smeup.dbnative.DBMManager? = null + ConnectionProvider.withScope { + scopedManager = ConnectionProvider.currentManager("SHARE1L") + val dbFile = factory.open("SHARE1L", null) + assertNotNull(dbFile) + dbFile.close() + } + factory.close() + } + + @Test + fun open_outsideScope_createsOwnManager() { + val factory = DBFileFactory(SHARING_ACCESS_CONFIG) + val result = ConnectionProvider.currentManagerOrNull("SHARE1L") + assertNull(result) + val dbFile = factory.open("SHARE1L", null) + assertNotNull(dbFile) + dbFile.close() + factory.close() + } + + @Test + fun open_insideScope_sameConnectionAsProvider() { + val factory = DBFileFactory(SHARING_ACCESS_CONFIG) + ConnectionProvider.withScope { + val scopedMgr = ConnectionProvider.currentManager("SHARE1L") as? SQLDBMManager + assertNotNull(scopedMgr) + val providerConn = scopedMgr.connection + + val ds = scopedMgr.toDataSource() + assertNotNull(ds) + val dsConn = ds.getConnection() + assertSame(providerConn, dsConn) + } + factory.close() + } +} diff --git a/manager/src/test/kotlin/com/smeup/dbnative/manager/DBFileFactoryTest.kt b/manager/src/test/kotlin/com/smeup/dbnative/manager/DBFileFactoryTest.kt index acbb3a4..807bedb 100644 --- a/manager/src/test/kotlin/com/smeup/dbnative/manager/DBFileFactoryTest.kt +++ b/manager/src/test/kotlin/com/smeup/dbnative/manager/DBFileFactoryTest.kt @@ -19,6 +19,7 @@ package com.smeup.dbnative.manager import com.smeup.dbnative.ConnectionConfig import com.smeup.dbnative.DBNativeAccessConfig +import com.smeup.dbnative.findConnectionConfigFor import com.smeup.dbnative.log.Logger import com.smeup.dbnative.log.LoggingLevel import com.smeup.dbnative.model.CharacterType diff --git a/sql/pom.xml b/sql/pom.xml index 8923a66..c37f6df 100644 --- a/sql/pom.xml +++ b/sql/pom.xml @@ -77,6 +77,11 @@ jt400 10.4 + + com.zaxxer + HikariCP + 5.1.0 + diff --git a/sql/src/main/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensions.kt b/sql/src/main/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensions.kt new file mode 100644 index 0000000..1205d66 --- /dev/null +++ b/sql/src/main/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensions.kt @@ -0,0 +1,39 @@ +package com.smeup.dbnative.sql + +import com.smeup.dbnative.ConnectionProvider +import com.smeup.dbnative.DBNativeAccessConfig +import javax.sql.DataSource + +/** + * Returns a thread-scoped [DataSource] for [fileName], or `null` for non-SQL managers. + */ +fun ConnectionProvider.currentDataSource(fileName: String): DataSource? = + currentManager(fileName).toDataSource() + +/** + * Returns a thread-scoped SQL [DataSource] for [fileName]. + * + * @throws IllegalArgumentException if the resolved manager is not SQL-based. + */ +fun ConnectionProvider.requireDataSource(fileName: String): DataSource = + requireNotNull(currentManager(fileName).toDataSource()) { + "Manager for '$fileName' is not SQL-based" + } + +/** + * Configures [ConnectionProvider] with pooled SQL managers. + * + * One pool is created per unique (`url`, `user`) pair. + * + * @return A handle that closes all created pools. + */ +fun ConnectionProvider.configureWithPool(config: DBNativeAccessConfig): AutoCloseable { + // One pool per unique (url, user) — many tables share the same database + val pools = config.connectionsConfig + .distinctBy { it.url to it.user } + .associateBy({ it.url to it.user }) { SQLConnectionPool(it) } + configure(config) { connConfig -> + SQLPooledDBMManager(connConfig, requireNotNull(pools[connConfig.url to connConfig.user]) { "No pool for ${connConfig.url}" }) + } + return AutoCloseable { pools.values.forEach { it.close() } } +} diff --git a/sql/src/main/kotlin/com/smeup/dbnative/sql/DBMManagerSqlExtensions.kt b/sql/src/main/kotlin/com/smeup/dbnative/sql/DBMManagerSqlExtensions.kt new file mode 100644 index 0000000..7deaffc --- /dev/null +++ b/sql/src/main/kotlin/com/smeup/dbnative/sql/DBMManagerSqlExtensions.kt @@ -0,0 +1,10 @@ +package com.smeup.dbnative.sql + +import com.smeup.dbnative.DBMManager +import javax.sql.DataSource + +/** + * Adapts this manager to a thread-scoped [DataSource] when it is SQL-based. + */ +fun DBMManager.toDataSource(): DataSource? = + (this as? SQLDBMManager)?.let { ThreadScopedDataSource(it) } diff --git a/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLConnectionPool.kt b/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLConnectionPool.kt new file mode 100644 index 0000000..8a88cd1 --- /dev/null +++ b/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLConnectionPool.kt @@ -0,0 +1,37 @@ +package com.smeup.dbnative.sql + +import com.smeup.dbnative.ConnectionConfig +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import java.sql.Connection + +/** + * Hikari-backed pool for SQL connections built from a [ConnectionConfig]. + */ +open class SQLConnectionPool(private val connectionConfig: ConnectionConfig) : AutoCloseable { + + private val hikariDataSource: HikariDataSource = run { + val pool = connectionConfig.poolConfig + val hikariConfig = HikariConfig().apply { + jdbcUrl = connectionConfig.url + username = connectionConfig.user + password = connectionConfig.password + connectionConfig.driver?.let { driverClassName = it } + maximumPoolSize = pool.maximumPoolSize + minimumIdle = pool.minimumIdle + connectionTimeout = pool.connectionTimeoutMs + idleTimeout = pool.idleTimeoutMs + maxLifetime = pool.maxLifetimeMs + initializationFailTimeout = -1 // don't validate connection eagerly at pool creation + connectionConfig.properties.forEach { (k, v) -> addDataSourceProperty(k, v) } + } + HikariDataSource(hikariConfig) + } + + /** + * Borrows a connection from the pool. + */ + open fun getConnection(): Connection = hikariDataSource.connection + + override fun close() = hikariDataSource.close() +} diff --git a/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLDBMManager.kt b/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLDBMManager.kt index b04f17b..7eb1757 100644 --- a/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLDBMManager.kt +++ b/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLDBMManager.kt @@ -31,7 +31,7 @@ open class SQLDBMManager(override val connectionConfig: ConnectionConfig) : DBMa //private var openedFile = mutableMapOf() - val connection: Connection by lazy { + open val connection: Connection by lazy { logger?.logEvent(LoggingKey.connection, "Opening SQL connection on url ${connectionConfig.url}") val conn: Connection measureTimeMillis { diff --git a/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLPooledDBMManager.kt b/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLPooledDBMManager.kt new file mode 100644 index 0000000..5bfe72e --- /dev/null +++ b/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLPooledDBMManager.kt @@ -0,0 +1,22 @@ +package com.smeup.dbnative.sql + +import com.smeup.dbnative.ConnectionConfig +import java.sql.Connection + +/** + * [SQLDBMManager] that lazily borrows and releases its connection from [pool]. + */ +class SQLPooledDBMManager( + connectionConfig: ConnectionConfig, + private val pool: SQLConnectionPool +) : SQLDBMManager(connectionConfig) { + + private val connectionLazy = lazy { pool.getConnection() } + override val connection: Connection by connectionLazy + + override fun close() { + if (connectionLazy.isInitialized()) { + connection.close() + } + } +} diff --git a/sql/src/main/kotlin/com/smeup/dbnative/sql/ThreadScopedDataSource.kt b/sql/src/main/kotlin/com/smeup/dbnative/sql/ThreadScopedDataSource.kt new file mode 100644 index 0000000..43662f0 --- /dev/null +++ b/sql/src/main/kotlin/com/smeup/dbnative/sql/ThreadScopedDataSource.kt @@ -0,0 +1,24 @@ +package com.smeup.dbnative.sql + +import java.io.PrintWriter +import java.sql.Connection +import java.util.logging.Logger +import javax.sql.DataSource + +/** + * [DataSource] facade that always returns the current scoped connection of [manager]. + */ +class ThreadScopedDataSource(private val manager: SQLDBMManager) : DataSource { + + // manager.connection is a lazy val → same Connection instance for the entire withScope() scope + override fun getConnection(): Connection = manager.connection + override fun getConnection(username: String, password: String): Connection = manager.connection + + override fun getLogWriter(): PrintWriter? = null + override fun setLogWriter(out: PrintWriter?) {} + override fun getLoginTimeout(): Int = 0 + override fun setLoginTimeout(seconds: Int) {} + override fun getParentLogger(): Logger = Logger.getLogger(Logger.GLOBAL_LOGGER_NAME) + override fun isWrapperFor(iface: Class<*>): Boolean = iface.isInstance(this) + override fun unwrap(iface: Class): T = iface.cast(this) +} diff --git a/sql/src/test/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensionsTest.kt b/sql/src/test/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensionsTest.kt new file mode 100644 index 0000000..bf9d8f4 --- /dev/null +++ b/sql/src/test/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensionsTest.kt @@ -0,0 +1,123 @@ +package com.smeup.dbnative.sql + +import com.smeup.dbnative.ConnectionConfig +import com.smeup.dbnative.ConnectionProvider +import com.smeup.dbnative.DBNativeAccessConfig +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame + +private val SQL_CONFIG = ConnectionConfig( + fileName = "*", + url = "jdbc:hsqldb:mem:CP_SQL_TEST", + user = "sa", + password = "root" +) + +private val SQL_CONFIG_B = ConnectionConfig( + fileName = "FILEB", + url = "jdbc:hsqldb:mem:CP_SQL_TEST_B", + user = "sa", + password = "root" +) + +private fun sqlFactory(cfg: com.smeup.dbnative.ConnectionConfig): com.smeup.dbnative.DBMManager = + SQLDBMManager(cfg) + +/** + * Tests SQL-specific [com.smeup.dbnative.ConnectionProvider] extensions. + */ +class ConnectionProviderSqlExtensionsTest { + + @Before + fun setUp() { + ConnectionProvider.configure(DBNativeAccessConfig(listOf(SQL_CONFIG)), ::sqlFactory) + } + + @After + fun tearDown() { + ConnectionProvider.configure(DBNativeAccessConfig(listOf(SQL_CONFIG)), ::sqlFactory) + } + + @Test + fun requireDataSource_returnsScopedDataSource() { + ConnectionProvider.withScope { + val ds = ConnectionProvider.requireDataSource("FILEA") + assertNotNull(ds) + } + } + + @Test + fun requireDataSource_throwsForNonSqlManager() { + val nonSqlConfig = ConnectionConfig( + fileName = "NOSQL", + url = "class:com.smeup.dbnative.mock.MockDBManager", + user = "", + password = "" + ) + ConnectionProvider.configure(DBNativeAccessConfig(listOf(nonSqlConfig))) { cfg -> + com.smeup.dbnative.mock.MockDBManager(cfg) + } + assertFailsWith { + ConnectionProvider.withScope { + ConnectionProvider.requireDataSource("NOSQL") + } + } + } + + @Test + fun currentDataSource_returnsNullForNonSqlManager() { + val nonSqlConfig = ConnectionConfig( + fileName = "NOSQL2", + url = "class:com.smeup.dbnative.mock.MockDBManager", + user = "", + password = "" + ) + ConnectionProvider.configure(DBNativeAccessConfig(listOf(nonSqlConfig))) { cfg -> + com.smeup.dbnative.mock.MockDBManager(cfg) + } + ConnectionProvider.withScope { + val ds = ConnectionProvider.currentDataSource("NOSQL2") + assertNull(ds) + } + } + + @Test + fun getConnection_sameInstanceWithinScope() { + ConnectionProvider.withScope { + val ds = ConnectionProvider.requireDataSource("FILEA") + val c1 = ds.getConnection() + val c2 = ds.getConnection() + assertSame(c1, c2) + } + } + + @Test + fun configureWithPool_createsOnePoolPerConnectionConfig() { + val config = DBNativeAccessConfig(listOf(SQL_CONFIG, SQL_CONFIG_B)) + val handle = ConnectionProvider.configureWithPool(config) + try { + ConnectionProvider.withScope { + val dsA = ConnectionProvider.requireDataSource("FILEA") + val dsB = ConnectionProvider.requireDataSource("FILEB") + assertNotNull(dsA) + assertNotNull(dsB) + } + } finally { + handle.close() + } + } + + @Test + fun configureWithPool_closeHandle_shutsDownAllPools() { + val config = DBNativeAccessConfig(listOf(SQL_CONFIG, SQL_CONFIG_B)) + val handle = ConnectionProvider.configureWithPool(config) + handle.close() + // Re-configure with non-pooled to restore state for other tests + ConnectionProvider.configure(DBNativeAccessConfig(listOf(SQL_CONFIG)), ::sqlFactory) + } +} diff --git a/sql/src/test/kotlin/com/smeup/dbnative/sql/SQLConnectionPoolTest.kt b/sql/src/test/kotlin/com/smeup/dbnative/sql/SQLConnectionPoolTest.kt new file mode 100644 index 0000000..b4f5d46 --- /dev/null +++ b/sql/src/test/kotlin/com/smeup/dbnative/sql/SQLConnectionPoolTest.kt @@ -0,0 +1,62 @@ +package com.smeup.dbnative.sql + +import com.smeup.dbnative.ConnectionConfig +import com.smeup.dbnative.PoolConfig +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull + +private val POOL_CONNECTION_CONFIG = ConnectionConfig( + fileName = "*", + url = "jdbc:hsqldb:mem:POOL_TEST", + user = "sa", + password = "root", + poolConfig = PoolConfig(maximumPoolSize = 2, minimumIdle = 1) +) + +/** + * Tests [SQLConnectionPool] basic connection lifecycle behavior. + */ +class SQLConnectionPoolTest { + + private lateinit var pool: SQLConnectionPool + + @Before + fun setUp() { + pool = SQLConnectionPool(POOL_CONNECTION_CONFIG) + } + + @After + fun tearDown() { + pool.runCatching { close() } + } + + @Test + fun getConnection_returnsOpenConnection() { + val conn = pool.getConnection() + assertNotNull(conn) + assertFalse(conn.isClosed) + conn.close() + } + + @Test + fun getConnection_returnsConnectionFromPool() { + val c1 = pool.getConnection() + c1.close() + val c2 = pool.getConnection() + assertNotNull(c2) + assertFalse(c2.isClosed) + c2.close() + } + + @Test + fun close_shutsDownPool() { + pool.close() + assertFailsWith { + pool.getConnection() + } + } +} diff --git a/sql/src/test/kotlin/com/smeup/dbnative/sql/SQLPooledDBMManagerTest.kt b/sql/src/test/kotlin/com/smeup/dbnative/sql/SQLPooledDBMManagerTest.kt new file mode 100644 index 0000000..b28d112 --- /dev/null +++ b/sql/src/test/kotlin/com/smeup/dbnative/sql/SQLPooledDBMManagerTest.kt @@ -0,0 +1,92 @@ +package com.smeup.dbnative.sql + +import com.smeup.dbnative.ConnectionConfig +import com.smeup.dbnative.PoolConfig +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.sql.Connection +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull + +private val POOLED_CONNECTION_CONFIG = ConnectionConfig( + fileName = "*", + url = "jdbc:hsqldb:mem:POOLED_MGR_TEST", + user = "sa", + password = "root", + poolConfig = PoolConfig(maximumPoolSize = 2, minimumIdle = 1) +) + +/** + * Tests pooled connection borrowing and release behavior of [SQLPooledDBMManager]. + */ +class SQLPooledDBMManagerTest { + + private lateinit var pool: SQLConnectionPool + + @Before + fun setUp() { + pool = SQLConnectionPool(POOLED_CONNECTION_CONFIG) + } + + @After + fun tearDown() { + pool.runCatching { close() } + } + + @Test + fun connection_borrowedFromPool() { + val manager = SQLPooledDBMManager(POOLED_CONNECTION_CONFIG, pool) + val conn = manager.connection + assertNotNull(conn) + assertFalse(conn.isClosed) + manager.close() + } + + @Test + fun connection_lazyInitialization() { + var callCount = 0 + val trackingPool = object : SQLConnectionPool(POOLED_CONNECTION_CONFIG) { + override fun getConnection(): Connection { + callCount++ + return super.getConnection() + } + } + val manager = SQLPooledDBMManager(POOLED_CONNECTION_CONFIG, trackingPool) + assertEquals(0, callCount, "Pool must not be called before connection is first accessed") + manager.connection + assertEquals(1, callCount) + manager.connection + assertEquals(1, callCount, "Lazy must reuse connection, not call pool again") + manager.close() + } + + @Test + fun close_returnsConnectionToPool() { + var closeCalled = false + val trackingPool = object : SQLConnectionPool(POOLED_CONNECTION_CONFIG) { + override fun getConnection(): Connection = object : Connection by super.getConnection() { + override fun close() { closeCalled = true } + } + } + val manager = SQLPooledDBMManager(POOLED_CONNECTION_CONFIG, trackingPool) + manager.connection + manager.close() + assertFalse(!closeCalled, "close() must be called on the connection to return it to pool") + } + + @Test + fun close_doesNotCallPoolWhenConnectionNeverAccessed() { + var callCount = 0 + val trackingPool = object : SQLConnectionPool(POOLED_CONNECTION_CONFIG) { + override fun getConnection(): Connection { + callCount++ + return super.getConnection() + } + } + val manager = SQLPooledDBMManager(POOLED_CONNECTION_CONFIG, trackingPool) + manager.close() + assertEquals(0, callCount, "Pool must not be called when connection was never accessed") + } +} From f8bad200ec726d91934c7aae5ff24f6deceffae7 Mon Sep 17 00:00:00 2001 From: Marco Lanari Date: Wed, 6 May 2026 13:13:25 +0200 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=94=A7=20chore:=20wrap=20scoped=20con?= =?UTF-8?q?nection=20to=20prevent=20accidental=20close=20and=20add=20depre?= =?UTF-8?q?cation=20shim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ThreadScopedDataSource now returns a NonCloseableConnection so callers following the standard JDBC close-every-connection idiom cannot accidentally terminate the shared scope connection - Update test assertion to unwrap the delegate before comparing identity - Add deprecated findConnectionConfigFor shim in manager package pointing to the new location in com.smeup.dbnative --- .../com/smeup/dbnative/manager/DBFileFactory.kt | 13 +++++++++++++ .../DBFileFactoryConnectionSharingTest.kt | 2 +- .../dbnative/sql/ThreadScopedDataSource.kt | 17 ++++++++++++++--- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/manager/src/main/kotlin/com/smeup/dbnative/manager/DBFileFactory.kt b/manager/src/main/kotlin/com/smeup/dbnative/manager/DBFileFactory.kt index b881e4e..c93a1af 100644 --- a/manager/src/main/kotlin/com/smeup/dbnative/manager/DBFileFactory.kt +++ b/manager/src/main/kotlin/com/smeup/dbnative/manager/DBFileFactory.kt @@ -115,6 +115,19 @@ internal fun createDBManager(config: ConnectionConfig, logger: Logger? = null): }!! } +/** + * Use [com.smeup.dbnative.findConnectionConfigFor] instead. + */ +@Deprecated( + message = "Moved to com.smeup.dbnative.findConnectionConfigFor", + replaceWith = ReplaceWith( + "findConnectionConfigFor(fileName, connectionsConfig)", + "com.smeup.dbnative.findConnectionConfigFor" + ) +) +fun findConnectionConfigFor(fileName: String, connectionsConfig: List): com.smeup.dbnative.ConnectionConfig = + findConnectionConfigFor(fileName, connectionsConfig) + private fun getImplByUrl(config: ConnectionConfig): String { return when { config.impl != null && config.impl!!.trim().isNotEmpty() -> config.impl!! diff --git a/manager/src/test/kotlin/com/smeup/dbnative/manager/DBFileFactoryConnectionSharingTest.kt b/manager/src/test/kotlin/com/smeup/dbnative/manager/DBFileFactoryConnectionSharingTest.kt index 776d97b..46d04e1 100644 --- a/manager/src/test/kotlin/com/smeup/dbnative/manager/DBFileFactoryConnectionSharingTest.kt +++ b/manager/src/test/kotlin/com/smeup/dbnative/manager/DBFileFactoryConnectionSharingTest.kt @@ -95,7 +95,7 @@ class DBFileFactoryConnectionSharingTest { val ds = scopedMgr.toDataSource() assertNotNull(ds) val dsConn = ds.getConnection() - assertSame(providerConn, dsConn) + assertSame(providerConn, dsConn.unwrap(java.sql.Connection::class.java)) } factory.close() } diff --git a/sql/src/main/kotlin/com/smeup/dbnative/sql/ThreadScopedDataSource.kt b/sql/src/main/kotlin/com/smeup/dbnative/sql/ThreadScopedDataSource.kt index 43662f0..ec85a37 100644 --- a/sql/src/main/kotlin/com/smeup/dbnative/sql/ThreadScopedDataSource.kt +++ b/sql/src/main/kotlin/com/smeup/dbnative/sql/ThreadScopedDataSource.kt @@ -7,12 +7,18 @@ import javax.sql.DataSource /** * [DataSource] facade that always returns the current scoped connection of [manager]. + * + * [getConnection] returns a [NonCloseableConnection] wrapper so that callers following the + * standard JDBC idiom of closing every acquired connection cannot accidentally close the shared + * scope connection. The real connection is closed only when the scope ends via + * [SQLPooledDBMManager.close]. */ class ThreadScopedDataSource(private val manager: SQLDBMManager) : DataSource { - // manager.connection is a lazy val → same Connection instance for the entire withScope() scope - override fun getConnection(): Connection = manager.connection - override fun getConnection(username: String, password: String): Connection = manager.connection + private val cachedWrapper by lazy { NonCloseableConnection(manager.connection) } + + override fun getConnection(): Connection = cachedWrapper + override fun getConnection(username: String, password: String): Connection = cachedWrapper override fun getLogWriter(): PrintWriter? = null override fun setLogWriter(out: PrintWriter?) {} @@ -21,4 +27,9 @@ class ThreadScopedDataSource(private val manager: SQLDBMManager) : DataSource { override fun getParentLogger(): Logger = Logger.getLogger(Logger.GLOBAL_LOGGER_NAME) override fun isWrapperFor(iface: Class<*>): Boolean = iface.isInstance(this) override fun unwrap(iface: Class): T = iface.cast(this) + + private class NonCloseableConnection(delegate: Connection) : Connection by delegate { + override fun close() {} + override fun isClosed(): Boolean = false + } } From 57aec537d53c21de41e8910b560c5237308b50c5 Mon Sep 17 00:00:00 2001 From: Marco Lanari Date: Thu, 7 May 2026 15:37:16 +0200 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20isConfigured()=20?= =?UTF-8?q?predicate=20to=20ConnectionProvider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/kotlin/com/smeup/dbnative/ConnectionProvider.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/base/src/main/kotlin/com/smeup/dbnative/ConnectionProvider.kt b/base/src/main/kotlin/com/smeup/dbnative/ConnectionProvider.kt index f047934..816b1ab 100644 --- a/base/src/main/kotlin/com/smeup/dbnative/ConnectionProvider.kt +++ b/base/src/main/kotlin/com/smeup/dbnative/ConnectionProvider.kt @@ -18,6 +18,11 @@ object ConnectionProvider { this.managerFactory = factory } + /** + * Returns `true` if [configure] (or [configureWithPool]) has been called. + */ + fun isConfigured(): Boolean = config != null + /** * Functional interface used by [withScope] to execute a scoped block. */ From 4ecd8f8f8e9cca81cda8654dec81a02e1c6a26f4 Mon Sep 17 00:00:00 2001 From: Marco Lanari Date: Thu, 7 May 2026 17:23:49 +0200 Subject: [PATCH 4/6] =?UTF-8?q?=E2=9C=A8=20feat:=20make=20poolConfig=20opt?= =?UTF-8?q?-in=20and=20fall=20back=20to=20non-pooled=20manager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ConnectionConfig.poolConfig` is now nullable (default null). `configureWithPool` skips pooling for configs without an explicit `PoolConfig` and falls back to a plain `SQLDBMManager`, so callers can mix pooled and non-pooled connections in one config. `SQLConnectionPool` guards against a null poolConfig with a clear `requireNotNull`. --- .../smeup/dbnative/DBNativeAccessConfig.kt | 2 +- .../sql/ConnectionProviderSqlExtensions.kt | 7 ++- .../smeup/dbnative/sql/SQLConnectionPool.kt | 4 +- .../ConnectionProviderSqlExtensionsTest.kt | 57 +++++++++++++++++++ .../dbnative/sql/SQLConnectionPoolTest.kt | 13 +++++ 5 files changed, 79 insertions(+), 4 deletions(-) diff --git a/base/src/main/kotlin/com/smeup/dbnative/DBNativeAccessConfig.kt b/base/src/main/kotlin/com/smeup/dbnative/DBNativeAccessConfig.kt index 0bd5d12..ab33cb4 100644 --- a/base/src/main/kotlin/com/smeup/dbnative/DBNativeAccessConfig.kt +++ b/base/src/main/kotlin/com/smeup/dbnative/DBNativeAccessConfig.kt @@ -50,6 +50,6 @@ data class ConnectionConfig @JvmOverloads constructor( val driver: String? = null, val impl: String? = null, val properties: Map = mutableMapOf(), - val poolConfig: PoolConfig = PoolConfig() + val poolConfig: PoolConfig? = null ) diff --git a/sql/src/main/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensions.kt b/sql/src/main/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensions.kt index 1205d66..5da2df1 100644 --- a/sql/src/main/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensions.kt +++ b/sql/src/main/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensions.kt @@ -28,12 +28,15 @@ fun ConnectionProvider.requireDataSource(fileName: String): DataSource = * @return A handle that closes all created pools. */ fun ConnectionProvider.configureWithPool(config: DBNativeAccessConfig): AutoCloseable { - // One pool per unique (url, user) — many tables share the same database + // One pool per unique (url, user) — only for configs that have an explicit poolConfig val pools = config.connectionsConfig + .filter { it.poolConfig != null } .distinctBy { it.url to it.user } .associateBy({ it.url to it.user }) { SQLConnectionPool(it) } configure(config) { connConfig -> - SQLPooledDBMManager(connConfig, requireNotNull(pools[connConfig.url to connConfig.user]) { "No pool for ${connConfig.url}" }) + val pool = pools[connConfig.url to connConfig.user] + if (pool != null) SQLPooledDBMManager(connConfig, pool) + else SQLDBMManager(connConfig) } return AutoCloseable { pools.values.forEach { it.close() } } } diff --git a/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLConnectionPool.kt b/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLConnectionPool.kt index 8a88cd1..a49cd58 100644 --- a/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLConnectionPool.kt +++ b/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLConnectionPool.kt @@ -11,7 +11,9 @@ import java.sql.Connection open class SQLConnectionPool(private val connectionConfig: ConnectionConfig) : AutoCloseable { private val hikariDataSource: HikariDataSource = run { - val pool = connectionConfig.poolConfig + val pool = requireNotNull(connectionConfig.poolConfig) { + "poolConfig must not be null to create SQLConnectionPool" + } val hikariConfig = HikariConfig().apply { jdbcUrl = connectionConfig.url username = connectionConfig.user diff --git a/sql/src/test/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensionsTest.kt b/sql/src/test/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensionsTest.kt index bf9d8f4..4d0f7e6 100644 --- a/sql/src/test/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensionsTest.kt +++ b/sql/src/test/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensionsTest.kt @@ -3,13 +3,16 @@ package com.smeup.dbnative.sql import com.smeup.dbnative.ConnectionConfig import com.smeup.dbnative.ConnectionProvider import com.smeup.dbnative.DBNativeAccessConfig +import com.smeup.dbnative.PoolConfig import org.junit.After import org.junit.Before import org.junit.Test import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertSame +import kotlin.test.assertTrue private val SQL_CONFIG = ConnectionConfig( fileName = "*", @@ -25,6 +28,14 @@ private val SQL_CONFIG_B = ConnectionConfig( password = "root" ) +private val POOLED_CONFIG = ConnectionConfig( + fileName = "POOLED", + url = "jdbc:hsqldb:mem:CP_POOLED_TEST", + user = "sa", + password = "root", + poolConfig = PoolConfig(maximumPoolSize = 2) +) + private fun sqlFactory(cfg: com.smeup.dbnative.ConnectionConfig): com.smeup.dbnative.DBMManager = SQLDBMManager(cfg) @@ -120,4 +131,50 @@ class ConnectionProviderSqlExtensionsTest { // Re-configure with non-pooled to restore state for other tests ConnectionProvider.configure(DBNativeAccessConfig(listOf(SQL_CONFIG)), ::sqlFactory) } + + @Test + fun configureWithPool_withExplicitPoolConfig_createsSQLPooledDBMManager() { + val config = DBNativeAccessConfig(listOf(POOLED_CONFIG)) + val handle = ConnectionProvider.configureWithPool(config) + try { + ConnectionProvider.withScope { + val manager = ConnectionProvider.currentManager("POOLED") + assertTrue(manager is SQLPooledDBMManager) + } + } finally { + handle.close() + } + } + + @Test + fun configureWithPool_withoutPoolConfig_fallsBackToSQLDBMManager() { + val config = DBNativeAccessConfig(listOf(SQL_CONFIG)) + val handle = ConnectionProvider.configureWithPool(config) + try { + ConnectionProvider.withScope { + val manager = ConnectionProvider.currentManager("FILEA") + assertTrue(manager is SQLDBMManager) + assertFalse(manager is SQLPooledDBMManager) + } + } finally { + handle.close() + } + } + + @Test + fun configureWithPool_mixedConfigs_routesCorrectly() { + val config = DBNativeAccessConfig(listOf(POOLED_CONFIG, SQL_CONFIG)) + val handle = ConnectionProvider.configureWithPool(config) + try { + ConnectionProvider.withScope { + val pooledManager = ConnectionProvider.currentManager("POOLED") + val nonPooledManager = ConnectionProvider.currentManager("FILEA") + assertTrue(pooledManager is SQLPooledDBMManager) + assertTrue(nonPooledManager is SQLDBMManager) + assertFalse(nonPooledManager is SQLPooledDBMManager) + } + } finally { + handle.close() + } + } } diff --git a/sql/src/test/kotlin/com/smeup/dbnative/sql/SQLConnectionPoolTest.kt b/sql/src/test/kotlin/com/smeup/dbnative/sql/SQLConnectionPoolTest.kt index b4f5d46..aa7c67e 100644 --- a/sql/src/test/kotlin/com/smeup/dbnative/sql/SQLConnectionPoolTest.kt +++ b/sql/src/test/kotlin/com/smeup/dbnative/sql/SQLConnectionPoolTest.kt @@ -59,4 +59,17 @@ class SQLConnectionPoolTest { pool.getConnection() } } + + @Test + fun constructor_throwsWhenPoolConfigIsNull() { + val configWithoutPool = ConnectionConfig( + fileName = "*", + url = "jdbc:hsqldb:mem:NO_POOL_TEST", + user = "sa", + password = "root" + ) + assertFailsWith { + SQLConnectionPool(configWithoutPool) + } + } } From e63acd89d9d8ea1ff3684eb0c7c77136bbc640fa Mon Sep 17 00:00:00 2001 From: Marco Lanari Date: Thu, 7 May 2026 17:24:18 +0200 Subject: [PATCH 5/6] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20connectionTestQue?= =?UTF-8?q?ry=20to=20PoolConfig=20for=20AS400-style=20drivers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some JDBC drivers (e.g. AS400) do not implement `Connection.isValid()`, causing HikariCP health checks to fail. The new optional `connectionTestQuery` field is forwarded to `HikariConfig.setConnectionTestQuery()` when set, enabling a fallback SQL query for connection validation. --- .../kotlin/com/smeup/dbnative/PoolConfig.kt | 4 +++- .../smeup/dbnative/sql/SQLConnectionPool.kt | 1 + .../dbnative/sql/SQLConnectionPoolTest.kt | 20 +++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/base/src/main/kotlin/com/smeup/dbnative/PoolConfig.kt b/base/src/main/kotlin/com/smeup/dbnative/PoolConfig.kt index 55157db..5dfc54c 100644 --- a/base/src/main/kotlin/com/smeup/dbnative/PoolConfig.kt +++ b/base/src/main/kotlin/com/smeup/dbnative/PoolConfig.kt @@ -14,5 +14,7 @@ data class PoolConfig @JvmOverloads constructor( val minimumIdle: Int = 2, val connectionTimeoutMs: Long = 30_000, val idleTimeoutMs: Long = 600_000, - val maxLifetimeMs: Long = 1_800_000 + val maxLifetimeMs: Long = 1_800_000, + // Set when the JDBC driver does not implement Connection.isValid() (e.g. AS400). + val connectionTestQuery: String? = null ) diff --git a/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLConnectionPool.kt b/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLConnectionPool.kt index a49cd58..1695fd4 100644 --- a/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLConnectionPool.kt +++ b/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLConnectionPool.kt @@ -24,6 +24,7 @@ open class SQLConnectionPool(private val connectionConfig: ConnectionConfig) : A connectionTimeout = pool.connectionTimeoutMs idleTimeout = pool.idleTimeoutMs maxLifetime = pool.maxLifetimeMs + pool.connectionTestQuery?.let { setConnectionTestQuery(it) } initializationFailTimeout = -1 // don't validate connection eagerly at pool creation connectionConfig.properties.forEach { (k, v) -> addDataSourceProperty(k, v) } } diff --git a/sql/src/test/kotlin/com/smeup/dbnative/sql/SQLConnectionPoolTest.kt b/sql/src/test/kotlin/com/smeup/dbnative/sql/SQLConnectionPoolTest.kt index aa7c67e..596b0bc 100644 --- a/sql/src/test/kotlin/com/smeup/dbnative/sql/SQLConnectionPoolTest.kt +++ b/sql/src/test/kotlin/com/smeup/dbnative/sql/SQLConnectionPoolTest.kt @@ -60,6 +60,26 @@ class SQLConnectionPoolTest { } } + @Test + fun getConnection_withConnectionTestQuery_returnsOpenConnection() { + val config = ConnectionConfig( + fileName = "*", + url = "jdbc:hsqldb:mem:CTQ_TEST", + user = "sa", + password = "root", + poolConfig = PoolConfig(maximumPoolSize = 2, connectionTestQuery = "VALUES 1") + ) + val ctqPool = SQLConnectionPool(config) + try { + val conn = ctqPool.getConnection() + assertNotNull(conn) + assertFalse(conn.isClosed) + conn.close() + } finally { + ctqPool.close() + } + } + @Test fun constructor_throwsWhenPoolConfigIsNull() { val configWithoutPool = ConnectionConfig( From 731a744c7aaf6434aa3df0acbade3ef137514bd9 Mon Sep 17 00:00:00 2001 From: Marco Lanari Date: Fri, 8 May 2026 18:16:51 +0200 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=94=8A=20logs:=20add=20connection=20l?= =?UTF-8?q?ifecycle=20logging=20to=20SQL=20pool=20and=20manager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log pool creation/shutdown, connection open/close with lifetime, and pooled borrow/return with acquire timing; add ConnectionLoggingTest. --- .../sql/ConnectionProviderSqlExtensions.kt | 19 ++- .../smeup/dbnative/sql/SQLConnectionPool.kt | 20 ++- .../com/smeup/dbnative/sql/SQLDBMManager.kt | 4 + .../smeup/dbnative/sql/SQLPooledDBMManager.kt | 11 +- .../dbnative/sql/ConnectionLoggingTest.kt | 150 ++++++++++++++++++ 5 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 sql/src/test/kotlin/com/smeup/dbnative/sql/ConnectionLoggingTest.kt diff --git a/sql/src/main/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensions.kt b/sql/src/main/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensions.kt index 5da2df1..1dd4eee 100644 --- a/sql/src/main/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensions.kt +++ b/sql/src/main/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensions.kt @@ -2,6 +2,7 @@ package com.smeup.dbnative.sql import com.smeup.dbnative.ConnectionProvider import com.smeup.dbnative.DBNativeAccessConfig +import com.smeup.dbnative.log.LoggingKey import javax.sql.DataSource /** @@ -32,11 +33,21 @@ fun ConnectionProvider.configureWithPool(config: DBNativeAccessConfig): AutoClos val pools = config.connectionsConfig .filter { it.poolConfig != null } .distinctBy { it.url to it.user } - .associateBy({ it.url to it.user }) { SQLConnectionPool(it) } + .associateBy({ it.url to it.user }) { SQLConnectionPool(it, config.logger) } + pools.forEach { (key, _) -> + config.logger?.logEvent(LoggingKey.connection, "Created SQL connection pool for url=${key.first} user=${key.second}") + } configure(config) { connConfig -> val pool = pools[connConfig.url to connConfig.user] - if (pool != null) SQLPooledDBMManager(connConfig, pool) - else SQLDBMManager(connConfig) + val manager = if (pool != null) SQLPooledDBMManager(connConfig, pool) + else SQLDBMManager(connConfig) + manager.logger = config.logger + manager + } + return AutoCloseable { + pools.entries.forEach { (key, pool) -> + config.logger?.logEvent(LoggingKey.connection, "Shutting down SQL connection pool for url=${key.first} user=${key.second}") + pool.close() + } } - return AutoCloseable { pools.values.forEach { it.close() } } } diff --git a/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLConnectionPool.kt b/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLConnectionPool.kt index 1695fd4..ab95bf6 100644 --- a/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLConnectionPool.kt +++ b/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLConnectionPool.kt @@ -1,14 +1,20 @@ package com.smeup.dbnative.sql import com.smeup.dbnative.ConnectionConfig +import com.smeup.dbnative.log.Logger +import com.smeup.dbnative.log.LoggingKey import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariDataSource import java.sql.Connection +import kotlin.system.measureTimeMillis /** * Hikari-backed pool for SQL connections built from a [ConnectionConfig]. */ -open class SQLConnectionPool(private val connectionConfig: ConnectionConfig) : AutoCloseable { +open class SQLConnectionPool( + private val connectionConfig: ConnectionConfig, + private val logger: Logger? = null +) : AutoCloseable { private val hikariDataSource: HikariDataSource = run { val pool = requireNotNull(connectionConfig.poolConfig) { @@ -34,7 +40,17 @@ open class SQLConnectionPool(private val connectionConfig: ConnectionConfig) : A /** * Borrows a connection from the pool. */ - open fun getConnection(): Connection = hikariDataSource.connection + open fun getConnection(): Connection { + val conn: Connection + measureTimeMillis { conn = hikariDataSource.connection }.apply { + val mxBean = hikariDataSource.hikariPoolMXBean + val stats = if (mxBean != null) + " [pool active=${mxBean.activeConnections} idle=${mxBean.idleConnections} total=${mxBean.totalConnections} waiting=${mxBean.threadsAwaitingConnection}]" + else "" + logger?.logEvent(LoggingKey.connection, "Pool acquired connection from url=${connectionConfig.url}$stats", this) + } + return conn + } override fun close() = hikariDataSource.close() } diff --git a/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLDBMManager.kt b/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLDBMManager.kt index 7eb1757..12080b9 100644 --- a/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLDBMManager.kt +++ b/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLDBMManager.kt @@ -28,6 +28,7 @@ import kotlin.system.measureTimeMillis open class SQLDBMManager(override val connectionConfig: ConnectionConfig) : DBManagerBaseImpl() { private var sqlLog: Boolean = false + private var connectionOpenedAt: Long = 0L //private var openedFile = mutableMapOf() @@ -50,6 +51,7 @@ open class SQLDBMManager(override val connectionConfig: ConnectionConfig) : DBMa } conn = DriverManager.getConnection(connectionConfig.url, connectionProps) }.apply { + connectionOpenedAt = System.currentTimeMillis() logger?.logEvent(LoggingKey.connection, "SQL connection successfully opened", this) } conn @@ -61,6 +63,8 @@ open class SQLDBMManager(override val connectionConfig: ConnectionConfig) : DBMa override fun close() { //openedFile.values.forEach { it.close()} //openedFile.clear() + val lifetime = if (connectionOpenedAt > 0L) System.currentTimeMillis() - connectionOpenedAt else null + logger?.logEvent(LoggingKey.connection, "Closing SQL connection on url ${connectionConfig.url}", lifetime) connection.close() } diff --git a/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLPooledDBMManager.kt b/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLPooledDBMManager.kt index 5bfe72e..8e25071 100644 --- a/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLPooledDBMManager.kt +++ b/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLPooledDBMManager.kt @@ -1,7 +1,9 @@ package com.smeup.dbnative.sql import com.smeup.dbnative.ConnectionConfig +import com.smeup.dbnative.log.LoggingKey import java.sql.Connection +import kotlin.system.measureTimeMillis /** * [SQLDBMManager] that lazily borrows and releases its connection from [pool]. @@ -11,11 +13,18 @@ class SQLPooledDBMManager( private val pool: SQLConnectionPool ) : SQLDBMManager(connectionConfig) { - private val connectionLazy = lazy { pool.getConnection() } + private val connectionLazy = lazy { + val conn: Connection + measureTimeMillis { conn = pool.getConnection() }.apply { + logger?.logEvent(LoggingKey.connection, "Borrowed pooled connection from url ${connectionConfig.url}", this) + } + conn + } override val connection: Connection by connectionLazy override fun close() { if (connectionLazy.isInitialized()) { + logger?.logEvent(LoggingKey.connection, "Returning pooled connection to url ${connectionConfig.url}") connection.close() } } diff --git a/sql/src/test/kotlin/com/smeup/dbnative/sql/ConnectionLoggingTest.kt b/sql/src/test/kotlin/com/smeup/dbnative/sql/ConnectionLoggingTest.kt new file mode 100644 index 0000000..846f95c --- /dev/null +++ b/sql/src/test/kotlin/com/smeup/dbnative/sql/ConnectionLoggingTest.kt @@ -0,0 +1,150 @@ +package com.smeup.dbnative.sql + +import com.smeup.dbnative.ConnectionConfig +import com.smeup.dbnative.ConnectionProvider +import com.smeup.dbnative.DBNativeAccessConfig +import com.smeup.dbnative.PoolConfig +import com.smeup.dbnative.log.Logger +import com.smeup.dbnative.log.LoggingEvent +import com.smeup.dbnative.log.LoggingKey +import com.smeup.dbnative.log.LoggingLevel +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +private val LOG_SQL_CONFIG = ConnectionConfig( + fileName = "*", + url = "jdbc:hsqldb:mem:LOG_NONPOOL_TEST", + user = "sa", + password = "root" +) + +private val LOG_POOLED_CONFIG = ConnectionConfig( + fileName = "*", + url = "jdbc:hsqldb:mem:LOG_POOL_TEST", + user = "sa", + password = "root", + poolConfig = PoolConfig(maximumPoolSize = 2) +) + +private fun captureLogger(): Pair> { + val events = mutableListOf() + return Logger(LoggingLevel.ALL) { events.add(it) } to events +} + +/** + * Verifies that connection lifecycle events are emitted to the logger + * for both pooled and non-pooled managers. + */ +class ConnectionLoggingTest { + + @Before + fun setUp() { + ConnectionProvider.configure(DBNativeAccessConfig(listOf(LOG_SQL_CONFIG))) { SQLDBMManager(it) } + } + + @After + fun tearDown() { + ConnectionProvider.configure(DBNativeAccessConfig(listOf(LOG_SQL_CONFIG))) { SQLDBMManager(it) } + } + + @Test + fun logger_propagatedToNonPooledManager() { + val (logger, events) = captureLogger() + val config = DBNativeAccessConfig(listOf(LOG_SQL_CONFIG), logger) + val handle = ConnectionProvider.configureWithPool(config) + try { + ConnectionProvider.withScope { + ConnectionProvider.requireDataSource("FILEA").getConnection() + } + } finally { + handle.close() + } + assertTrue(events.any { it.eventKey == LoggingKey.connection }, + "Expected at least one connection event — logger was not propagated to the non-pooled manager") + } + + @Test + fun nonPooledManager_closeEventCarriesLifetime() { + val (logger, events) = captureLogger() + val config = DBNativeAccessConfig(listOf(LOG_SQL_CONFIG), logger) + val handle = ConnectionProvider.configureWithPool(config) + try { + ConnectionProvider.withScope { + ConnectionProvider.requireDataSource("FILEA").getConnection() + } + } finally { + handle.close() + } + val closeEvent = events.find { it.eventKey == LoggingKey.connection && "Closing" in it.message } + assertNotNull(closeEvent, "Expected a close connection event") + assertNotNull(closeEvent.elapsedTime, "Close event must carry connection lifetime as elapsedTime") + } + + @Test + fun pooledManager_borrowEventCarriesTiming() { + val (logger, events) = captureLogger() + val config = DBNativeAccessConfig(listOf(LOG_POOLED_CONFIG), logger) + val handle = ConnectionProvider.configureWithPool(config) + try { + ConnectionProvider.withScope { + ConnectionProvider.requireDataSource("FILEA").getConnection() + } + } finally { + handle.close() + } + val borrowEvent = events.find { it.eventKey == LoggingKey.connection && "Borrowed" in it.message } + assertNotNull(borrowEvent, "Expected a borrow event from pooled manager") + assertNotNull(borrowEvent.elapsedTime, "Borrow event must carry acquire time as elapsedTime") + } + + @Test + fun pooledManager_returnEventIsEmittedAfterScope() { + val (logger, events) = captureLogger() + val config = DBNativeAccessConfig(listOf(LOG_POOLED_CONFIG), logger) + val handle = ConnectionProvider.configureWithPool(config) + try { + ConnectionProvider.withScope { + ConnectionProvider.requireDataSource("FILEA").getConnection() + } + } finally { + handle.close() + } + assertTrue(events.any { it.eventKey == LoggingKey.connection && "Returning" in it.message }, + "Expected a return event after the scope closes the pooled connection") + } + + @Test + fun pooledManager_noReturnEventWhenConnectionNeverAccessed() { + val (logger, events) = captureLogger() + val config = DBNativeAccessConfig(listOf(LOG_POOLED_CONFIG), logger) + val handle = ConnectionProvider.configureWithPool(config) + try { + ConnectionProvider.withScope { + ConnectionProvider.currentManager("FILEA") // creates manager but does not borrow connection + } + } finally { + handle.close() + } + assertFalse(events.any { it.eventKey == LoggingKey.connection && "Returning" in it.message }, + "Must not emit a return event when the pooled connection was never borrowed") + } + + @Test + fun configureWithPool_logsPoolCreationAndShutdown() { + val (logger, events) = captureLogger() + val config = DBNativeAccessConfig(listOf(LOG_POOLED_CONFIG), logger) + val handle = ConnectionProvider.configureWithPool(config) + try { + assertTrue(events.any { it.eventKey == LoggingKey.connection && "Created" in it.message }, + "Expected pool creation event on configureWithPool") + } finally { + handle.close() + } + assertTrue(events.any { it.eventKey == LoggingKey.connection && "Shutting down" in it.message }, + "Expected pool shutdown event on handle close") + } +} \ No newline at end of file