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..816b1ab --- /dev/null +++ b/base/src/main/kotlin/com/smeup/dbnative/ConnectionProvider.kt @@ -0,0 +1,75 @@ +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 + } + + /** + * Returns `true` if [configure] (or [configureWithPool]) has been called. + */ + fun isConfigured(): Boolean = config != null + + /** + * 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..ab33cb4 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? = null +) 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..5dfc54c --- /dev/null +++ b/base/src/main/kotlin/com/smeup/dbnative/PoolConfig.kt @@ -0,0 +1,20 @@ +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, + // Set when the JDBC driver does not implement Connection.isValid() (e.g. AS400). + val connectionTestQuery: String? = null +) 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..c93a1af 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,35 @@ 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 { +/** + * 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!! config.url.startsWith("jdbc:") -> "com.smeup.dbnative.sql.SQLDBMManager" @@ -133,20 +138,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..46d04e1 --- /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.unwrap(java.sql.Connection::class.java)) + } + 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..1dd4eee --- /dev/null +++ b/sql/src/main/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensions.kt @@ -0,0 +1,53 @@ +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 + +/** + * 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) — 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, 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] + 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() + } + } +} 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..ab95bf6 --- /dev/null +++ b/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLConnectionPool.kt @@ -0,0 +1,56 @@ +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, + private val logger: Logger? = null +) : AutoCloseable { + + private val hikariDataSource: HikariDataSource = run { + val pool = requireNotNull(connectionConfig.poolConfig) { + "poolConfig must not be null to create SQLConnectionPool" + } + 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 + pool.connectionTestQuery?.let { setConnectionTestQuery(it) } + 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 { + 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 b04f17b..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,10 +28,11 @@ 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() - 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 { @@ -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 new file mode 100644 index 0000000..8e25071 --- /dev/null +++ b/sql/src/main/kotlin/com/smeup/dbnative/sql/SQLPooledDBMManager.kt @@ -0,0 +1,31 @@ +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]. + */ +class SQLPooledDBMManager( + connectionConfig: ConnectionConfig, + private val pool: SQLConnectionPool +) : SQLDBMManager(connectionConfig) { + + 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/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..ec85a37 --- /dev/null +++ b/sql/src/main/kotlin/com/smeup/dbnative/sql/ThreadScopedDataSource.kt @@ -0,0 +1,35 @@ +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]. + * + * [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 { + + 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?) {} + 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) + + private class NonCloseableConnection(delegate: Connection) : Connection by delegate { + override fun close() {} + override fun isClosed(): Boolean = false + } +} 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 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..4d0f7e6 --- /dev/null +++ b/sql/src/test/kotlin/com/smeup/dbnative/sql/ConnectionProviderSqlExtensionsTest.kt @@ -0,0 +1,180 @@ +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 = "*", + 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 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) + +/** + * 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) + } + + @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 new file mode 100644 index 0000000..596b0bc --- /dev/null +++ b/sql/src/test/kotlin/com/smeup/dbnative/sql/SQLConnectionPoolTest.kt @@ -0,0 +1,95 @@ +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() + } + } + + @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( + fileName = "*", + url = "jdbc:hsqldb:mem:NO_POOL_TEST", + user = "sa", + password = "root" + ) + assertFailsWith { + SQLConnectionPool(configWithoutPool) + } + } +} 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") + } +}