diff --git a/build.sbt b/build.sbt index b0ee2d7e2..53ed70860 100644 --- a/build.sbt +++ b/build.sbt @@ -45,7 +45,8 @@ lazy val root = project mysql, oracle, postgres, - sqlserver + sqlserver, + jdbc_hikaricp ) lazy val core = crossProject(JSPlatform, JVMPlatform) @@ -131,6 +132,23 @@ lazy val jdbc = project .settings(testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework")) .dependsOn(core.jvm) +lazy val jdbc_hikaricp = project + .in(file("jdbc-hikaricp")) + .settings(stdSettings("zio-sql-jdbc-hickaricp")) + .settings(buildInfoSettings("zio.sql.jdbc-hickaricp")) + .settings( + libraryDependencies ++= Seq( + "com.zaxxer" % "HikariCP" % "5.0.1", + "dev.zio" %% "zio-test" % zioVersion % Test, + "dev.zio" %% "zio-test-sbt" % zioVersion % Test, + "org.testcontainers" % "mysql" % testcontainersVersion % Test, + "mysql" % "mysql-connector-java" % "8.0.29" % Test, + "com.dimafeng" %% "testcontainers-scala-mysql" % testcontainersScalaVersion % Test + ) + ) + .settings(testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework")) + .dependsOn(jdbc) + lazy val mysql = project .in(file("mysql")) .dependsOn(jdbc % "compile->compile;test->test") diff --git a/jdbc-hikaricp/src/main/scala/zio/sql/HikariConnectionPool.scala b/jdbc-hikaricp/src/main/scala/zio/sql/HikariConnectionPool.scala new file mode 100644 index 000000000..b72a28464 --- /dev/null +++ b/jdbc-hikaricp/src/main/scala/zio/sql/HikariConnectionPool.scala @@ -0,0 +1,35 @@ +package zio.sql +import com.zaxxer.hikari.{ HikariConfig, HikariDataSource } +import zio.{ Scope, ZIO, ZLayer } + +import java.sql.{ Connection, SQLException } + +class HikariConnectionPool private (hikariDataSource: HikariDataSource) extends ConnectionPool { + + private[sql] val dataSource = hikariDataSource + + /** + * Retrieves a JDBC java.sql.Connection as a [[ZIO[Scope, Exception, Connection]]] resource. + * The managed resource will safely acquire and release the connection, and + * may be interrupted or timed out if necessary. + */ + override def connection: ZIO[Scope, Exception, Connection] = + ZIO.acquireRelease(ZIO.attemptBlocking(hikariDataSource.getConnection).refineToOrDie[SQLException])(con => + ZIO.attemptBlocking(hikariDataSource.evictConnection(con)).orDie + ) +} + +object HikariConnectionPool { + + private[sql] def initDataSource(config: HikariConfig): ZIO[Scope, Throwable, HikariDataSource] = + ZIO.acquireRelease(ZIO.attemptBlocking(new HikariDataSource(config)))(ds => ZIO.attemptBlocking(ds.close()).orDie) + + val live: ZLayer[HikariConnectionPoolConfig, Throwable, HikariConnectionPool] = + ZLayer.scoped { + for { + config <- ZIO.service[HikariConnectionPoolConfig] + dataSource <- initDataSource(config.toHikariConfig) + pool = new HikariConnectionPool(dataSource) + } yield pool + } +} diff --git a/jdbc-hikaricp/src/main/scala/zio/sql/HikariConnectionPoolConfig.scala b/jdbc-hikaricp/src/main/scala/zio/sql/HikariConnectionPoolConfig.scala new file mode 100644 index 000000000..45ab36908 --- /dev/null +++ b/jdbc-hikaricp/src/main/scala/zio/sql/HikariConnectionPoolConfig.scala @@ -0,0 +1,58 @@ +package zio.sql + +import com.zaxxer.hikari.HikariConfig + +/** + * Configuration information for the connection pool. + * + * @param url The JDBC connection string. + * @param properties JDBC connection properties (username / password could go here). + * @param poolSize The size of the pool. + * @param connectionTimeout Maximum number of milliseconds that a client will wait for a connection from the pool. + * If this time is exceeded without a connection becoming available, a SQLException will be thrown from javax.sql.DataSource.getConnection(). + * @param idleTimeout This property controls the maximum amount of time (in milliseconds) that a connection is allowed to sit idle in the pool. + * Whether a connection is retired as idle or not is subject to a maximum variation of +30 seconds, and average variation of +15 seconds. + * A connection will never be retired as idle before this timeout. A value of 0 means that idle connections are never removed from the pool. + * @param initializationFailTimeout the number of milliseconds before the + * pool initialization fails, or 0 to validate connection setup but continue with + * pool start, or less than zero to skip all initialization checks and start the + * pool without delay. + * @param maxLifetime This property controls the maximum lifetime of a connection in the pool. + * When a connection reaches this timeout, even if recently used, it will be retired from the pool. + * An in-use connection will never be retired, only when it is idle will it be removed. Should be bigger then 30000 + * @param minimumIdle The property controls the minimum number of idle connections that HikariCP tries to maintain in the pool, including both idle and in-use connections. + * If the idle connections dip below this value, HikariCP will make a best effort to restore them quickly and efficiently. + * @param connectionInitSql the SQL to execute on new connections + * Set the SQL string that will be executed on all new connections when they are + * created, before they are added to the pool. If this query fails, it will be + * treated as a failed connection attempt. + */ +final case class HikariConnectionPoolConfig( + url: String, + userName: String, + password: String, + poolSize: Int = 10, + autoCommit: Boolean = true, + connectionTimeout: Option[Long] = None, + idleTimeout: Option[Long] = None, + initializationFailTimeout: Option[Long] = None, + maxLifetime: Option[Long] = None, + minimumIdle: Option[Int] = None, + connectionInitSql: Option[String] = None +) { + private[sql] def toHikariConfig = { + val hikariConfig = new HikariConfig() + hikariConfig.setJdbcUrl(this.url) + hikariConfig.setAutoCommit(this.autoCommit) + hikariConfig.setMaximumPoolSize(this.poolSize) + hikariConfig.setUsername(userName) + hikariConfig.setPassword(password) + connectionTimeout.foreach(hikariConfig.setConnectionTimeout) + idleTimeout.foreach(hikariConfig.setIdleTimeout) + initializationFailTimeout.foreach(hikariConfig.setInitializationFailTimeout) + maxLifetime.foreach(hikariConfig.setMaxLifetime) + minimumIdle.foreach(hikariConfig.setMinimumIdle) + connectionInitSql.foreach(hikariConfig.setConnectionInitSql) + hikariConfig + } +} diff --git a/jdbc-hikaricp/src/test/scala/zio/sql/HikariConnectionPoolSpec.scala b/jdbc-hikaricp/src/test/scala/zio/sql/HikariConnectionPoolSpec.scala new file mode 100644 index 000000000..31a2a5f5b --- /dev/null +++ b/jdbc-hikaricp/src/test/scala/zio/sql/HikariConnectionPoolSpec.scala @@ -0,0 +1,131 @@ +package zio.sql + +import zio.test.TestAspect.{ sequential, timeout, withLiveClock } +import zio.test.{ TestEnvironment, _ } +import zio.{ durationInt, ZIO, ZLayer } + +object HikariConnectionPoolSpec extends ZIOSpecDefault { + + val mySqlConfigLayer: ZLayer[Any, Throwable, MySqlConfig] = + ZLayer.scoped { + MySqlTestContainer + .mysql() + .map(a => + MySqlConfig( + url = a.jdbcUrl, + username = a.username, + password = a.password + ) + ) + } + + val hikariPoolConfigLayer: ZLayer[MySqlConfig, Nothing, HikariConnectionPoolConfig] = + ZLayer.fromFunction((conf: MySqlConfig) => + HikariConnectionPoolConfig(url = conf.url, userName = conf.username, password = conf.password) + ) + val poolLayer: ZLayer[HikariConnectionPoolConfig, Nothing, HikariConnectionPool] = HikariConnectionPool.live.orDie + + override def spec: Spec[TestEnvironment, Any] = + specLayered.provideCustomShared(mySqlConfigLayer.orDie) + + def specLayered: Spec[TestEnvironment with MySqlConfig, Any] = + suite("Hikaricp module")( + test("Pool size should be configurable") { + val poolSize = 20 + (for { + cp <- ZIO.service[HikariConnectionPool] + } yield assertTrue(cp.dataSource.getMaximumPoolSize == poolSize)) + .provideSomeLayer[TestEnvironment with MySqlConfig]( + hikariPoolConfigLayer.map(_.update(_.copy(poolSize = poolSize))) >>> poolLayer + ) + } @@ timeout(10.seconds) @@ withLiveClock, + test("Pool size should have 10 connections by default") { + (for { + cp <- ZIO.service[HikariConnectionPool] + _ <- ZIO.replicateZIO(10)(ZIO.scoped(cp.connection)) + } yield assertTrue(cp.dataSource.getMaximumPoolSize == 10)) + .provideSomeLayer[TestEnvironment with MySqlConfig](hikariPoolConfigLayer >>> poolLayer) + } @@ timeout(10.minutes) @@ withLiveClock, + test("It should be possible to acquire connections from the pool") { + val poolSize = 20 + (for { + cp <- ZIO.service[HikariConnectionPool] + _ <- + ZIO.collectAllParDiscard(ZIO.replicate(poolSize)(ZIO.scoped(cp.connection *> ZIO.sleep(500.millisecond)))) + } yield assert("")(Assertion.anything)).provideSomeLayer[TestEnvironment with MySqlConfig]( + hikariPoolConfigLayer.map(_.update(_.copy(poolSize = poolSize))) >>> poolLayer + ) + } @@ timeout(10.seconds) @@ withLiveClock, + test("Auto commit should be configurable") { + val autoCommit = false + (for { + cp <- ZIO.service[HikariConnectionPool] + } yield assertTrue(cp.dataSource.isAutoCommit == autoCommit)) + .provideSomeLayer[TestEnvironment with MySqlConfig]( + hikariPoolConfigLayer.map(_.update(_.copy(autoCommit = autoCommit))) >>> poolLayer + ) + } @@ timeout(10.seconds) @@ withLiveClock, + test("Auto commit should be true by default") { + (for { + cp <- ZIO.service[HikariConnectionPool] + } yield assertTrue(cp.dataSource.isAutoCommit)) + .provideSomeLayer[TestEnvironment with MySqlConfig](hikariPoolConfigLayer >>> poolLayer) + } @@ timeout(10.seconds) @@ withLiveClock, + test("Connection timeout should be configurable") { + val connectionTimeout = 2000L + (for { + cp <- ZIO.service[HikariConnectionPool] + } yield assertTrue(cp.dataSource.getConnectionTimeout == connectionTimeout)) + .provideSomeLayer[TestEnvironment with MySqlConfig]( + hikariPoolConfigLayer.map(_.update(_.copy(connectionTimeout = Some(connectionTimeout)))) >>> poolLayer + ) + } @@ timeout(10.seconds) @@ withLiveClock, + test("Idle timeout should be configurable") { + val idleTimeout = 2000L + (for { + cp <- ZIO.service[HikariConnectionPool] + } yield assertTrue(cp.dataSource.getIdleTimeout == idleTimeout)) + .provideSomeLayer[TestEnvironment with MySqlConfig]( + hikariPoolConfigLayer.map(_.update(_.copy(idleTimeout = Some(idleTimeout)))) >>> poolLayer + ) + } @@ timeout(10.seconds) @@ withLiveClock, + test("initialization fail timeout should be configurable") { + val initializationFailTimeout = 2000L + (for { + cp <- ZIO.service[HikariConnectionPool] + } yield assertTrue(cp.dataSource.getInitializationFailTimeout == initializationFailTimeout)) + .provideSomeLayer[TestEnvironment with MySqlConfig]( + hikariPoolConfigLayer.map( + _.update(_.copy(initializationFailTimeout = Some(initializationFailTimeout))) + ) >>> poolLayer + ) + } @@ timeout(10.seconds) @@ withLiveClock, + test("max lifetime should be configurable") { + val maxLifetime = 40000L + (for { + cp <- ZIO.service[HikariConnectionPool] + } yield assertTrue(cp.dataSource.getMaxLifetime == maxLifetime)) + .provideSomeLayer[TestEnvironment with MySqlConfig]( + hikariPoolConfigLayer.map(_.update(_.copy(maxLifetime = Some(maxLifetime)))) >>> poolLayer + ) + } @@ timeout(10.seconds) @@ withLiveClock, + test("minimum idle should be configurable") { + val minimumIdle = 2 + (for { + cp <- ZIO.service[HikariConnectionPool] + } yield assertTrue(cp.dataSource.getMinimumIdle == minimumIdle)) + .provideSomeLayer[TestEnvironment with MySqlConfig]( + hikariPoolConfigLayer.map(_.update(_.copy(minimumIdle = Some(minimumIdle)))) >>> poolLayer + ) + } @@ timeout(10.seconds) @@ withLiveClock, + test("connection init SQL should be configurable") { + val initialSql = "SELECT 1 FROM test.test" + (for { + cp <- ZIO.service[HikariConnectionPool] + } yield assertTrue(cp.dataSource.getConnectionInitSql == initialSql)) + .provideSomeLayer[TestEnvironment with MySqlConfig]( + hikariPoolConfigLayer.map(_.update(_.copy(connectionInitSql = Some(initialSql)))) >>> poolLayer + ) + } @@ timeout(10.seconds) @@ withLiveClock + ) @@ sequential +} diff --git a/jdbc-hikaricp/src/test/scala/zio/sql/MySqlTestContainer.scala b/jdbc-hikaricp/src/test/scala/zio/sql/MySqlTestContainer.scala new file mode 100644 index 000000000..16ec7cc17 --- /dev/null +++ b/jdbc-hikaricp/src/test/scala/zio/sql/MySqlTestContainer.scala @@ -0,0 +1,23 @@ +package zio.sql + +import com.dimafeng.testcontainers.MySQLContainer +import org.testcontainers.utility.DockerImageName +import zio._ + +final case class MySqlConfig(username: String, password: String, url: String) +object MySqlTestContainer { + + def mysql(imageName: String = "mysql"): ZIO[Scope, Throwable, MySQLContainer] = + ZIO.acquireRelease { + ZIO.attemptBlocking { + val c = new MySQLContainer( + mysqlImageVersion = Option(imageName).map(DockerImageName.parse) + ).configure { a => + a.withInitScript("test_schema.sql") + () + } + c.start() + c + } + }(container => ZIO.attemptBlocking(container.stop()).orDie) +}