diff --git a/AGENTS.md b/AGENTS.md
index 4cca474..d5d4443 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,3 +11,9 @@
Use asciidoc.
Use one-sentence-per-line convention.
+
+## Database tests
+
+The `boot-db-jdbc-postgresql` and `boot-db-jdbc-mysql` tests run real servers with Testcontainers.
+They are skipped where Docker is unavailable, but not on CI, where `CI` is set.
+With rootless Podman, run them with `DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock TESTCONTAINERS_RYUK_DISABLED=true`.
diff --git a/README.adoc b/README.adoc
index 93e8867..cfe4fc1 100644
--- a/README.adoc
+++ b/README.adoc
@@ -65,6 +65,8 @@ An application makes two choices, each its own dependency:
* A *driver*, which supplies the `JdbcConfig`: a driver module's typed config, or the generic `JdbcGenericConfig` (any database by JDBC URL, with the driver added separately).
** `boot-db-jdbc-sqlite`: `SqliteConfig` takes a file `path`, and defaults to WAL journaling, enforced foreign keys, a busy timeout, and a single-connection pool.
+** `boot-db-jdbc-postgresql`: `PostgresqlConfig` takes a `host`, `port`, `database`, and credentials, plus typed driver settings such as `sslMode`, `currentSchema`, and `applicationName`.
+** `boot-db-jdbc-mysql`: `MysqlConfig` takes a `host`, `port`, `database`, and credentials, plus typed driver settings such as `sslMode`, and defaults to Connector/J's prepared statement cache.
* A *pool*, which supplies the `ConnectionPoolFactory`, configured with its own typed settings.
** `boot-db-pool-hikari`: HikariCP, configured by `HikariPoolConfig`. For HikariCP settings it doesn't cover, add a `HikariConfigCustomizer` to its set binding (Kodein `inBindSet`, Metro `@IntoSet`).
@@ -73,7 +75,7 @@ On top of the pool, two optional modules use its `DataSource`:
* `boot-db-flyway`: `FlywayMigrationService` runs Flyway's `migrate` when the app starts, just after the pool, so a failed migration fails the boot and nothing uses an unmigrated schema.
Migrations come from `classpath:db/migration` unless `FlywayConfig.locations` says otherwise.
For Flyway settings `FlywayConfig` doesn't cover, add a `FlywayConfigurationCustomizer` to its set binding.
-flyway-core supports SQLite itself; other databases need their Flyway database module, which their driver modules will bring.
+flyway-core supports SQLite itself; the PostgreSQL and MySQL driver modules bring their Flyway database modules.
* `boot-db-exposed`: `ExposedDatabaseService` owns an Exposed `Database` over the pool, configured by `ExposedConfig` (default isolation level, read-only, retries, query timeout, and so on), which applies to that `Database` only. On shutdown, before the pool closes, it unregisters the `Database` from Exposed's process-wide registry.
`db.withTransaction { … }` runs a suspending transaction on the `Database`'s dispatcher (`Dispatchers.IO` by default); called inside another, it joins it, so several calls make one unit of work.
`SchemaVerificationService` checks, after the migrations, that the schema matches the Exposed tables registered in its `Set
` binding, and fails the boot listing the SQL the schema needs (or only warns, with `schemaVerification = WARN`).
@@ -102,7 +104,7 @@ db {
----
With vanilla wiring, construct `JdbcDataSourceService(binder.config("db.jdbc"), HikariConnectionPoolFactory(binder.config("db.pool")))`, pass it to `boot` as an app service, and hand its `dataSource` to whatever needs connections: e.g. `FlywayMigrationService(dataSource, binder.config("db.flyway"))`, and an `ExposedDatabaseService(dataSource, exposedConfig)`, as a service too, whose `database` goes to a `SchemaVerificationService` for its tables and to your repositories.
-With Kodein, import `jdbcDataSourceModule`, a driver config module (`sqliteConfigModule` or `jdbcGenericConfigModule`), and a pool module (`hikariPoolModule`), plus `flywayMigrationModule` and `exposedDatabaseModule` as needed; register tables with `inBindSet { add { singleton { MyTable } } }`.
+With Kodein, import `jdbcDataSourceModule`, a driver config module (`sqliteConfigModule`, `postgresqlConfigModule`, `mysqlConfigModule`, or `jdbcGenericConfigModule`), and a pool module (`hikariPoolModule`), plus `flywayMigrationModule` and `exposedDatabaseModule` as needed; register tables with `inBindSet { add { singleton { MyTable } } }`.
With Metro, the `-metro` driver, pool, Flyway, and Exposed modules contribute everything; for the generic config, add `JdbcGenericConfigBindings` to your graph's `bindingContainers`; register tables with `@Provides @IntoSet fun myTable(): Table = MyTable`.
A graph without a driver or a pool fails to compile with a missing binding.
@@ -176,7 +178,7 @@ See these three wiring options:
* [ ] Create a boot-server-http-ktor-cohort module using https://github.com/sksamuel/cohort[Cohort]
* [ ] Update the ktor example to integrate https://github.com/sksamuel/hoplite[Kompendium] — this is outside the scope of Bootable, but it would be a good example of how to integrate Bootable with other libraries
* [ ] Make multiplatform — some modules are backend only e.g. Hoplite, Log4j2, but others could easily be adapted for multiplatform usage
-* [ ] Integrate more useful modules e.g. postgresql, and so on
+* [ ] Integrate more useful modules
== Author
diff --git a/boot-db-jdbc-mysql/src/kodein/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlModule.kt b/boot-db-jdbc-mysql/src/kodein/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlModule.kt
new file mode 100644
index 0000000..86266a1
--- /dev/null
+++ b/boot-db-jdbc-mysql/src/kodein/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlModule.kt
@@ -0,0 +1,16 @@
+package com.github.rocketraman.bootable.db.jdbc
+
+import com.github.rocketraman.bootable.config.ConfigBinder
+import com.github.rocketraman.bootable.config.config
+import org.kodein.di.DI
+import org.kodein.di.bindSingleton
+import org.kodein.di.instance
+
+/**
+ * A Kodein module that decodes a [MysqlConfig] from the `db.jdbc` config section as the [JdbcConfig].
+ * Import it alongside `jdbcDataSourceModule`, which builds the pool from it, and a pool module. Requires a `ConfigBinder` binding
+ * (e.g. from boot-config-hoplite's `hopliteConfigModule`).
+ */
+val mysqlConfigModule by DI.Module {
+ bindSingleton { instance().config("db.jdbc") }
+}
diff --git a/boot-db-jdbc-mysql/src/main/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlConfig.kt b/boot-db-jdbc-mysql/src/main/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlConfig.kt
new file mode 100644
index 0000000..4334807
--- /dev/null
+++ b/boot-db-jdbc-mysql/src/main/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlConfig.kt
@@ -0,0 +1,98 @@
+package com.github.rocketraman.bootable.db.jdbc
+
+import kotlin.time.Duration
+
+/**
+ * A strongly typed [JdbcConfig] for a MySQL database, connected to with
+ * [MySQL Connector/J](https://dev.mysql.com/doc/connector-j/en/), which this module brings in. It brings Flyway's
+ * MySQL database module too, for `boot-db-flyway`.
+ *
+ * Its defaults turn on Connector/J's client-side prepared statement cache, sized as
+ * [HikariCP recommends](https://github.com/brettwooldridge/HikariCP/wiki/MySQL-Configuration): the driver
+ * otherwise parses every statement again each time it is prepared.
+ *
+ * Decoded from the `db.jdbc` config section:
+ *
+ * ```
+ * db {
+ * jdbc {
+ * host = db.example.com
+ * database = app
+ * username = app
+ * password = ${?DB_PASSWORD}
+ * sslMode = VERIFY_IDENTITY
+ * }
+ * }
+ * ```
+ *
+ * The settings other than the address and credentials are passed to the driver as connection properties. Those
+ * left `null` keep the driver's default. For a server address this can't express, such as several hosts for
+ * failover, use [JdbcGenericConfig] with a JDBC URL instead.
+ *
+ * @property host The server's host name or IP address.
+ * @property port The server's port.
+ * @property database The database to connect to.
+ * @property sslMode Whether to use SSL, and how much of the server's certificate to verify.
+ * @property allowPublicKeyRetrieval Whether the driver may ask the server for its RSA public key, which the
+ * `caching_sha2_password` authentication needs on a connection without SSL. Off by default in the driver, since a
+ * server impersonating the real one could then read the password.
+ * @property connectTimeout How long to wait to open a connection to the server; the driver waits forever by
+ * default.
+ * @property socketTimeout How long to wait for a read from the server, which bounds every query; the driver waits
+ * forever by default.
+ * @property rewriteBatchedStatements Whether to rewrite a batch of statements into fewer, multi-row statements,
+ * which is much faster.
+ * @property cachePrepStmts Whether to cache prepared statements' parsed form on each connection.
+ * @property prepStmtCacheSize How many prepared statements to cache on each connection.
+ * @property prepStmtCacheSqlLimit The longest SQL statement, in characters, that is cached.
+ */
+data class MysqlConfig(
+ val host: String = "localhost",
+ val port: Int = 3306,
+ val database: String,
+ override val username: String? = null,
+ override val password: String? = null,
+ val sslMode: MysqlSslMode? = null,
+ val allowPublicKeyRetrieval: Boolean? = null,
+ val connectTimeout: Duration? = null,
+ val socketTimeout: Duration? = null,
+ val rewriteBatchedStatements: Boolean? = null,
+ val cachePrepStmts: Boolean = true,
+ val prepStmtCacheSize: Int = 250,
+ val prepStmtCacheSqlLimit: Int = 2048,
+) : JdbcConfig {
+ override val jdbcUrl get() = "jdbc:mysql://${urlHost(host)}:$port/$database"
+ override val driverClassName get() = "com.mysql.cj.jdbc.Driver"
+
+ override val dataSourceProperties: Map
+ get() = buildMap {
+ sslMode?.let { put("sslMode", it.name) }
+ allowPublicKeyRetrieval?.let { put("allowPublicKeyRetrieval", it.toString()) }
+ connectTimeout?.let { put("connectTimeout", it.inWholeMilliseconds.toString()) }
+ socketTimeout?.let { put("socketTimeout", it.inWholeMilliseconds.toString()) }
+ rewriteBatchedStatements?.let { put("rewriteBatchedStatements", it.toString()) }
+ put("cachePrepStmts", cachePrepStmts.toString())
+ if (cachePrepStmts) {
+ put("prepStmtCacheSize", prepStmtCacheSize.toString())
+ put("prepStmtCacheSqlLimit", prepStmtCacheSqlLimit.toString())
+ }
+ }
+
+ // keep the password out of logs and exception messages
+ override fun toString() =
+ "MysqlConfig(host=$host, port=$port, database=$database, username=$username, " +
+ "password=${password?.let { "****" }}, sslMode=$sslMode, allowPublicKeyRetrieval=$allowPublicKeyRetrieval, " +
+ "connectTimeout=$connectTimeout, socketTimeout=$socketTimeout, " +
+ "rewriteBatchedStatements=$rewriteBatchedStatements, cachePrepStmts=$cachePrepStmts, " +
+ "prepStmtCacheSize=$prepStmtCacheSize, prepStmtCacheSqlLimit=$prepStmtCacheSqlLimit)"
+}
+
+/**
+ * Connector/J's [SSL modes](https://dev.mysql.com/doc/connector-j/en/connector-j-connp-props-security.html#cj-conn-prop_sslMode).
+ * Only [VERIFY_CA] and [VERIFY_IDENTITY] protect against a server impersonating the real one; [VERIFY_IDENTITY]
+ * also checks the host name.
+ */
+enum class MysqlSslMode { DISABLED, PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY }
+
+/** A host for a JDBC URL: an IPv6 address must be bracketed, so its colons aren't read as the port. */
+private fun urlHost(host: String) = if (':' in host && !host.startsWith("[")) "[$host]" else host
diff --git a/boot-db-jdbc-mysql/src/metro/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlConfigBindings.kt b/boot-db-jdbc-mysql/src/metro/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlConfigBindings.kt
new file mode 100644
index 0000000..6480500
--- /dev/null
+++ b/boot-db-jdbc-mysql/src/metro/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlConfigBindings.kt
@@ -0,0 +1,20 @@
+package com.github.rocketraman.bootable.db.jdbc
+
+import com.github.rocketraman.bootable.boot.BootScope
+import com.github.rocketraman.bootable.config.ConfigBinder
+import com.github.rocketraman.bootable.config.config
+import dev.zacsweers.metro.BindingContainer
+import dev.zacsweers.metro.ContributesTo
+import dev.zacsweers.metro.Provides
+import dev.zacsweers.metro.SingleIn
+
+/**
+ * Provides a [MysqlConfig], decoded from the `db.jdbc` config section, as the [JdbcConfig] that
+ * `JdbcDataSourceBindings` (from boot-db-jdbc-metro) builds the pool from.
+ */
+@ContributesTo(BootScope::class)
+@BindingContainer
+object MysqlConfigBindings {
+ @Provides @SingleIn(BootScope::class)
+ fun mysqlConfig(binder: ConfigBinder): JdbcConfig = binder.config("db.jdbc")
+}
diff --git a/boot-db-jdbc-mysql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlConfigTest.kt b/boot-db-jdbc-mysql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlConfigTest.kt
new file mode 100644
index 0000000..16cb0b3
--- /dev/null
+++ b/boot-db-jdbc-mysql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlConfigTest.kt
@@ -0,0 +1,55 @@
+package com.github.rocketraman.bootable.db.jdbc
+
+import de.infix.testBalloon.framework.core.testSuite
+import kotlin.time.Duration.Companion.seconds
+
+private val prepStmtCache = mapOf(
+ "cachePrepStmts" to "true",
+ "prepStmtCacheSize" to "250",
+ "prepStmtCacheSqlLimit" to "2048",
+)
+
+val MysqlConfigTest by testSuite {
+ test("the URL addresses the database") {
+ assert(MysqlConfig(database = "app").jdbcUrl == "jdbc:mysql://localhost:3306/app")
+ assert(MysqlConfig(host = "db", port = 3307, database = "app").jdbcUrl == "jdbc:mysql://db:3307/app")
+ }
+
+ test("an IPv6 host is bracketed") {
+ assert(MysqlConfig(host = "::1", database = "app").jdbcUrl == "jdbc:mysql://[::1]:3306/app")
+ assert(MysqlConfig(host = "[::1]", database = "app").jdbcUrl == "jdbc:mysql://[::1]:3306/app")
+ }
+
+ test("by default, only the prepared statement cache is set") {
+ assert(MysqlConfig(database = "app").dataSourceProperties == prepStmtCache)
+ }
+
+ test("turning off the prepared statement cache drops its sizes") {
+ assert(MysqlConfig(database = "app", cachePrepStmts = false).dataSourceProperties == mapOf("cachePrepStmts" to "false"))
+ }
+
+ test("settings become the driver's connection properties") {
+ val config = MysqlConfig(
+ database = "app",
+ sslMode = MysqlSslMode.VERIFY_IDENTITY,
+ allowPublicKeyRetrieval = true,
+ connectTimeout = 3.seconds,
+ socketTimeout = 60.seconds,
+ rewriteBatchedStatements = true,
+ )
+ assert(
+ config.dataSourceProperties == prepStmtCache + mapOf(
+ "sslMode" to "VERIFY_IDENTITY",
+ "allowPublicKeyRetrieval" to "true",
+ "connectTimeout" to "3000",
+ "socketTimeout" to "60000",
+ "rewriteBatchedStatements" to "true",
+ )
+ )
+ }
+
+ test("toString masks the password") {
+ val string = MysqlConfig(database = "app", username = "app", password = "secret").toString()
+ assert("secret" !in string && "password=****" in string && "username=app" in string)
+ }
+}
diff --git a/boot-db-jdbc-mysql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlDatabaseTest.kt b/boot-db-jdbc-mysql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlDatabaseTest.kt
new file mode 100644
index 0000000..ead04ee
--- /dev/null
+++ b/boot-db-jdbc-mysql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlDatabaseTest.kt
@@ -0,0 +1,64 @@
+package com.github.rocketraman.bootable.db.jdbc
+
+import com.github.rocketraman.bootable.db.flyway.FlywayMigrationService
+import de.infix.testBalloon.framework.core.TestConfig
+import de.infix.testBalloon.framework.core.disable
+import de.infix.testBalloon.framework.core.testSuite
+import org.testcontainers.DockerClientFactory
+import org.testcontainers.mysql.MySQLContainer
+import javax.sql.DataSource
+
+/**
+ * Against a real MySQL server, in a container. Skipped where Docker is unavailable, except on CI, where it must
+ * run. With Podman, point `DOCKER_HOST` at its socket, e.g. `unix:///run/user/1000/podman/podman.sock`.
+ */
+val MysqlDatabaseTest by testSuite(
+ testConfig = if (System.getenv("CI") != null || DockerClientFactory.instance().isDockerAvailable) TestConfig
+ else TestConfig.disable()
+) {
+ val server = testFixture {
+ MySQLContainer("mysql:8.4").apply { start() }
+ } closeWith { stop() }
+
+ suspend fun config() = server().let {
+ MysqlConfig(
+ host = it.host,
+ port = it.firstMappedPort,
+ database = it.databaseName,
+ username = it.username,
+ password = it.password,
+ )
+ }
+
+ test("connections carry the configured settings") {
+ JdbcDataSourceService(config().copy(sslMode = MysqlSslMode.REQUIRED), HikariConnectionPoolFactory()).use {
+ // the second column of SHOW STATUS is the value: a cipher only on an SSL connection
+ assert(it.queryString("SHOW SESSION STATUS LIKE 'Ssl_cipher'", column = 2).isNotEmpty())
+ }
+ }
+
+ test("flyway migrates the database") {
+ JdbcDataSourceService(config(), HikariConnectionPoolFactory()).use {
+ FlywayMigrationService(it).start()
+ assert(it.queryString("SELECT message FROM greeting WHERE id = 1") == "hello")
+ }
+ }
+}
+
+private fun JdbcDataSourceService.use(block: (DataSource) -> Unit) {
+ start()
+ try {
+ block(dataSource)
+ } finally {
+ shutdown()
+ }
+}
+
+private fun DataSource.queryString(sql: String, column: Int = 1): String = connection.use { connection ->
+ connection.createStatement().use { statement ->
+ statement.executeQuery(sql).use { rs ->
+ rs.next()
+ rs.getString(column)
+ }
+ }
+}
diff --git a/boot-db-jdbc-mysql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlWiringTest.kt b/boot-db-jdbc-mysql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlWiringTest.kt
new file mode 100644
index 0000000..212eec4
--- /dev/null
+++ b/boot-db-jdbc-mysql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlWiringTest.kt
@@ -0,0 +1,88 @@
+package com.github.rocketraman.bootable.db.jdbc
+
+import com.github.rocketraman.bootable.boot.AppService
+import com.github.rocketraman.bootable.boot.BootScope
+import com.github.rocketraman.bootable.boot.BootableGraph
+import com.github.rocketraman.bootable.boot.bootableModule
+import com.github.rocketraman.bootable.config.config
+import com.github.rocketraman.bootable.config.hopliteConfigBinder
+import com.github.rocketraman.bootable.config.hopliteConfigModule
+import com.zaxxer.hikari.HikariDataSource
+import de.infix.testBalloon.framework.core.testSuite
+import dev.zacsweers.metro.DependencyGraph
+import dev.zacsweers.metro.createGraph
+import org.kodein.di.DI
+import org.kodein.di.direct
+import org.kodein.di.instance
+import javax.sql.DataSource
+import kotlin.time.Duration.Companion.seconds
+
+sealed interface TestScope
+
+@DependencyGraph(TestScope::class, additionalScopes = [BootScope::class])
+interface TestGraph : BootableGraph {
+ val dataSource: DataSource
+ val jdbcConfig: JdbcConfig
+}
+
+private val expectedConfig = MysqlConfig(
+ host = "db.test",
+ port = 3307,
+ database = "app",
+ username = "app",
+ password = "secret",
+ sslMode = MysqlSslMode.VERIFY_IDENTITY,
+ connectTimeout = 3.seconds,
+)
+
+/**
+ * The pool built from the decoded config. The server in the config doesn't exist, so nothing is started: the pool
+ * opens no connection until then.
+ */
+private fun HikariDataSource.assertBuiltFromConfig() {
+ assert(jdbcUrl == "jdbc:mysql://db.test:3307/app")
+ assert(username == "app" && password == "secret")
+ assert(dataSourceProperties.getProperty("sslMode") == "VERIFY_IDENTITY")
+ assert(poolName == "wiring-test")
+}
+
+/** Config comes from src/test/resources/application.conf, as it would in an application. */
+val MysqlWiringTest by testSuite {
+ testSuite("typed config decoded by") {
+ test("hoplite") {
+ assert(hopliteConfigBinder(report = false).config("db.jdbc") == expectedConfig)
+ }
+
+ test("hoplite in strict mode, alongside the pool config") {
+ val binder = hopliteConfigBinder(report = false, strict = true)
+ binder.config("db.jdbc")
+ binder.config("db.pool")
+ }
+
+ test("kodein") {
+ val di = DI {
+ import(bootableModule)
+ import(hopliteConfigModule)
+ import(jdbcDataSourceModule)
+ import(mysqlConfigModule)
+ import(hikariPoolModule)
+ }.direct
+
+ assert(di.instance() == expectedConfig)
+ val hikari = di.instance() as HikariDataSource
+ hikari.assertBuiltFromConfig()
+ assert(di.instance>().filterIsInstance().single().dataSource === hikari)
+ hikari.close()
+ }
+
+ test("metro") {
+ val graph = createGraph()
+
+ assert(graph.jdbcConfig == expectedConfig)
+ val hikari = graph.dataSource as HikariDataSource
+ hikari.assertBuiltFromConfig()
+ assert(graph.appServices.filterIsInstance().single().dataSource === hikari)
+ hikari.close()
+ }
+ }
+}
diff --git a/boot-db-jdbc-mysql/src/test/resources/application.conf b/boot-db-jdbc-mysql/src/test/resources/application.conf
new file mode 100644
index 0000000..71db162
--- /dev/null
+++ b/boot-db-jdbc-mysql/src/test/resources/application.conf
@@ -0,0 +1,15 @@
+db {
+ jdbc {
+ host = db.test
+ port = 3307
+ database = app
+ username = app
+ password = secret
+ sslMode = VERIFY_IDENTITY
+ connectTimeout = 3s
+ }
+ pool {
+ poolName = wiring-test
+ connectionTimeout = 3s
+ }
+}
diff --git a/boot-db-jdbc-mysql/src/test/resources/db/migration/V1__create_greeting.sql b/boot-db-jdbc-mysql/src/test/resources/db/migration/V1__create_greeting.sql
new file mode 100644
index 0000000..9d0dbc2
--- /dev/null
+++ b/boot-db-jdbc-mysql/src/test/resources/db/migration/V1__create_greeting.sql
@@ -0,0 +1,2 @@
+CREATE TABLE greeting (id INT PRIMARY KEY, message VARCHAR(100) NOT NULL);
+INSERT INTO greeting (id, message) VALUES (1, 'hello');
diff --git a/boot-db-jdbc-postgresql/src/kodein/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlModule.kt b/boot-db-jdbc-postgresql/src/kodein/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlModule.kt
new file mode 100644
index 0000000..505f50f
--- /dev/null
+++ b/boot-db-jdbc-postgresql/src/kodein/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlModule.kt
@@ -0,0 +1,16 @@
+package com.github.rocketraman.bootable.db.jdbc
+
+import com.github.rocketraman.bootable.config.ConfigBinder
+import com.github.rocketraman.bootable.config.config
+import org.kodein.di.DI
+import org.kodein.di.bindSingleton
+import org.kodein.di.instance
+
+/**
+ * A Kodein module that decodes a [PostgresqlConfig] from the `db.jdbc` config section as the [JdbcConfig].
+ * Import it alongside `jdbcDataSourceModule`, which builds the pool from it, and a pool module. Requires a `ConfigBinder` binding
+ * (e.g. from boot-config-hoplite's `hopliteConfigModule`).
+ */
+val postgresqlConfigModule by DI.Module {
+ bindSingleton { instance().config("db.jdbc") }
+}
diff --git a/boot-db-jdbc-postgresql/src/main/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlConfig.kt b/boot-db-jdbc-postgresql/src/main/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlConfig.kt
new file mode 100644
index 0000000..3e69488
--- /dev/null
+++ b/boot-db-jdbc-postgresql/src/main/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlConfig.kt
@@ -0,0 +1,89 @@
+package com.github.rocketraman.bootable.db.jdbc
+
+import kotlin.time.Duration
+
+/**
+ * A strongly typed [JdbcConfig] for a PostgreSQL database, connected to with the
+ * [PostgreSQL JDBC driver](https://jdbc.postgresql.org/), which this module brings in. It brings Flyway's PostgreSQL
+ * database module too, for `boot-db-flyway`.
+ *
+ * Decoded from the `db.jdbc` config section:
+ *
+ * ```
+ * db {
+ * jdbc {
+ * host = db.example.com
+ * database = app
+ * username = app
+ * password = ${?DB_PASSWORD}
+ * sslMode = VERIFY_FULL
+ * }
+ * }
+ * ```
+ *
+ * The settings other than the address and credentials are passed to the driver as connection properties. Those
+ * left `null` keep the driver's default. For a server address this can't express, such as several hosts for
+ * failover, use [JdbcGenericConfig] with a JDBC URL instead.
+ *
+ * @property host The server's host name or IP address.
+ * @property port The server's port.
+ * @property database The database to connect to.
+ * @property currentSchema The schema search path for the connection, e.g. `app` or `app,public`.
+ * @property applicationName The name the connection reports to the server, shown in `pg_stat_activity`.
+ * @property sslMode Whether to use SSL, and how much of the server's certificate to verify.
+ * @property connectTimeout How long to wait to open a connection to the server. Whole seconds; the driver's
+ * default is 10 seconds.
+ * @property socketTimeout How long to wait for a read from the server, which bounds every query; the driver waits
+ * forever by default. Whole seconds.
+ * @property reWriteBatchedInserts Whether to rewrite a batch of `INSERT`s into multi-row `INSERT`s, which is much
+ * faster. The driver then reports the batch's update counts as `SUCCESS_NO_INFO`.
+ */
+data class PostgresqlConfig(
+ val host: String = "localhost",
+ val port: Int = 5432,
+ val database: String,
+ override val username: String? = null,
+ override val password: String? = null,
+ val currentSchema: String? = null,
+ val applicationName: String? = null,
+ val sslMode: PostgresqlSslMode? = null,
+ val connectTimeout: Duration? = null,
+ val socketTimeout: Duration? = null,
+ val reWriteBatchedInserts: Boolean? = null,
+) : JdbcConfig {
+ override val jdbcUrl get() = "jdbc:postgresql://${urlHost(host)}:$port/$database"
+ override val driverClassName get() = "org.postgresql.Driver"
+
+ override val dataSourceProperties: Map
+ get() = buildMap {
+ currentSchema?.let { put("currentSchema", it) }
+ applicationName?.let { put("ApplicationName", it) }
+ sslMode?.let { put("sslmode", it.value) }
+ connectTimeout?.let { put("connectTimeout", it.inWholeSeconds.toString()) }
+ socketTimeout?.let { put("socketTimeout", it.inWholeSeconds.toString()) }
+ reWriteBatchedInserts?.let { put("reWriteBatchedInserts", it.toString()) }
+ }
+
+ // keep the password out of logs and exception messages
+ override fun toString() =
+ "PostgresqlConfig(host=$host, port=$port, database=$database, username=$username, " +
+ "password=${password?.let { "****" }}, currentSchema=$currentSchema, applicationName=$applicationName, " +
+ "sslMode=$sslMode, connectTimeout=$connectTimeout, socketTimeout=$socketTimeout, " +
+ "reWriteBatchedInserts=$reWriteBatchedInserts)"
+}
+
+/**
+ * PostgreSQL's [SSL modes](https://jdbc.postgresql.org/documentation/ssl/). Only [VERIFY_CA] and [VERIFY_FULL]
+ * protect against a server impersonating the real one; [VERIFY_FULL] also checks the host name.
+ */
+enum class PostgresqlSslMode(val value: String) {
+ DISABLE("disable"),
+ ALLOW("allow"),
+ PREFER("prefer"),
+ REQUIRE("require"),
+ VERIFY_CA("verify-ca"),
+ VERIFY_FULL("verify-full"),
+}
+
+/** A host for a JDBC URL: an IPv6 address must be bracketed, so its colons aren't read as the port. */
+private fun urlHost(host: String) = if (':' in host && !host.startsWith("[")) "[$host]" else host
diff --git a/boot-db-jdbc-postgresql/src/metro/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlConfigBindings.kt b/boot-db-jdbc-postgresql/src/metro/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlConfigBindings.kt
new file mode 100644
index 0000000..59fefb7
--- /dev/null
+++ b/boot-db-jdbc-postgresql/src/metro/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlConfigBindings.kt
@@ -0,0 +1,20 @@
+package com.github.rocketraman.bootable.db.jdbc
+
+import com.github.rocketraman.bootable.boot.BootScope
+import com.github.rocketraman.bootable.config.ConfigBinder
+import com.github.rocketraman.bootable.config.config
+import dev.zacsweers.metro.BindingContainer
+import dev.zacsweers.metro.ContributesTo
+import dev.zacsweers.metro.Provides
+import dev.zacsweers.metro.SingleIn
+
+/**
+ * Provides a [PostgresqlConfig], decoded from the `db.jdbc` config section, as the [JdbcConfig] that
+ * `JdbcDataSourceBindings` (from boot-db-jdbc-metro) builds the pool from.
+ */
+@ContributesTo(BootScope::class)
+@BindingContainer
+object PostgresqlConfigBindings {
+ @Provides @SingleIn(BootScope::class)
+ fun postgresqlConfig(binder: ConfigBinder): JdbcConfig = binder.config("db.jdbc")
+}
diff --git a/boot-db-jdbc-postgresql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlConfigTest.kt b/boot-db-jdbc-postgresql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlConfigTest.kt
new file mode 100644
index 0000000..5d69e62
--- /dev/null
+++ b/boot-db-jdbc-postgresql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlConfigTest.kt
@@ -0,0 +1,47 @@
+package com.github.rocketraman.bootable.db.jdbc
+
+import de.infix.testBalloon.framework.core.testSuite
+import kotlin.time.Duration.Companion.seconds
+
+val PostgresqlConfigTest by testSuite {
+ test("the URL addresses the database") {
+ assert(PostgresqlConfig(database = "app").jdbcUrl == "jdbc:postgresql://localhost:5432/app")
+ assert(PostgresqlConfig(host = "db", port = 6543, database = "app").jdbcUrl == "jdbc:postgresql://db:6543/app")
+ }
+
+ test("an IPv6 host is bracketed") {
+ assert(PostgresqlConfig(host = "::1", database = "app").jdbcUrl == "jdbc:postgresql://[::1]:5432/app")
+ assert(PostgresqlConfig(host = "[::1]", database = "app").jdbcUrl == "jdbc:postgresql://[::1]:5432/app")
+ }
+
+ test("unset settings keep the driver's defaults") {
+ assert(PostgresqlConfig(database = "app").dataSourceProperties.isEmpty())
+ }
+
+ test("settings become the driver's connection properties") {
+ val config = PostgresqlConfig(
+ database = "app",
+ currentSchema = "app,public",
+ applicationName = "my-app",
+ sslMode = PostgresqlSslMode.VERIFY_FULL,
+ connectTimeout = 3.seconds,
+ socketTimeout = 60.seconds,
+ reWriteBatchedInserts = true,
+ )
+ assert(
+ config.dataSourceProperties == mapOf(
+ "currentSchema" to "app,public",
+ "ApplicationName" to "my-app",
+ "sslmode" to "verify-full",
+ "connectTimeout" to "3",
+ "socketTimeout" to "60",
+ "reWriteBatchedInserts" to "true",
+ )
+ )
+ }
+
+ test("toString masks the password") {
+ val string = PostgresqlConfig(database = "app", username = "app", password = "secret").toString()
+ assert("secret" !in string && "password=****" in string && "username=app" in string)
+ }
+}
diff --git a/boot-db-jdbc-postgresql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlDatabaseTest.kt b/boot-db-jdbc-postgresql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlDatabaseTest.kt
new file mode 100644
index 0000000..0b5497e
--- /dev/null
+++ b/boot-db-jdbc-postgresql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlDatabaseTest.kt
@@ -0,0 +1,65 @@
+package com.github.rocketraman.bootable.db.jdbc
+
+import com.github.rocketraman.bootable.db.flyway.FlywayMigrationService
+import de.infix.testBalloon.framework.core.TestConfig
+import de.infix.testBalloon.framework.core.disable
+import de.infix.testBalloon.framework.core.testSuite
+import org.testcontainers.DockerClientFactory
+import org.testcontainers.postgresql.PostgreSQLContainer
+import javax.sql.DataSource
+
+/**
+ * Against a real PostgreSQL server, in a container. Skipped where Docker is unavailable, except on CI, where it must
+ * run. With Podman, point `DOCKER_HOST` at its socket, e.g. `unix:///run/user/1000/podman/podman.sock`.
+ */
+val PostgresqlDatabaseTest by testSuite(
+ testConfig = if (System.getenv("CI") != null || DockerClientFactory.instance().isDockerAvailable) TestConfig
+ else TestConfig.disable()
+) {
+ val server = testFixture {
+ PostgreSQLContainer("postgres:18-alpine").apply { start() }
+ } closeWith { stop() }
+
+ suspend fun config() = server().let {
+ PostgresqlConfig(
+ host = it.host,
+ port = it.firstMappedPort,
+ database = it.databaseName,
+ username = it.username,
+ password = it.password,
+ applicationName = "bootable-test",
+ )
+ }
+
+ test("connections carry the configured settings") {
+ JdbcDataSourceService(config().copy(currentSchema = "pg_catalog"), HikariConnectionPoolFactory()).use {
+ assert(it.queryString("SELECT current_setting('application_name')") == "bootable-test")
+ assert(it.queryString("SELECT current_schema()") == "pg_catalog")
+ }
+ }
+
+ test("flyway migrates the database") {
+ JdbcDataSourceService(config(), HikariConnectionPoolFactory()).use {
+ FlywayMigrationService(it).start()
+ assert(it.queryString("SELECT message FROM greeting WHERE id = 1") == "hello")
+ }
+ }
+}
+
+private fun JdbcDataSourceService.use(block: (DataSource) -> Unit) {
+ start()
+ try {
+ block(dataSource)
+ } finally {
+ shutdown()
+ }
+}
+
+private fun DataSource.queryString(sql: String): String = connection.use { connection ->
+ connection.createStatement().use { statement ->
+ statement.executeQuery(sql).use { rs ->
+ rs.next()
+ rs.getString(1)
+ }
+ }
+}
diff --git a/boot-db-jdbc-postgresql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlWiringTest.kt b/boot-db-jdbc-postgresql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlWiringTest.kt
new file mode 100644
index 0000000..f2829d3
--- /dev/null
+++ b/boot-db-jdbc-postgresql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlWiringTest.kt
@@ -0,0 +1,89 @@
+package com.github.rocketraman.bootable.db.jdbc
+
+import com.github.rocketraman.bootable.boot.AppService
+import com.github.rocketraman.bootable.boot.BootScope
+import com.github.rocketraman.bootable.boot.BootableGraph
+import com.github.rocketraman.bootable.boot.bootableModule
+import com.github.rocketraman.bootable.config.config
+import com.github.rocketraman.bootable.config.hopliteConfigBinder
+import com.github.rocketraman.bootable.config.hopliteConfigModule
+import com.zaxxer.hikari.HikariDataSource
+import de.infix.testBalloon.framework.core.testSuite
+import dev.zacsweers.metro.DependencyGraph
+import dev.zacsweers.metro.createGraph
+import org.kodein.di.DI
+import org.kodein.di.direct
+import org.kodein.di.instance
+import javax.sql.DataSource
+import kotlin.time.Duration.Companion.seconds
+
+sealed interface TestScope
+
+@DependencyGraph(TestScope::class, additionalScopes = [BootScope::class])
+interface TestGraph : BootableGraph {
+ val dataSource: DataSource
+ val jdbcConfig: JdbcConfig
+}
+
+private val expectedConfig = PostgresqlConfig(
+ host = "db.test",
+ port = 5433,
+ database = "app",
+ username = "app",
+ password = "secret",
+ applicationName = "wiring-test",
+ sslMode = PostgresqlSslMode.VERIFY_FULL,
+ connectTimeout = 3.seconds,
+)
+
+/**
+ * The pool built from the decoded config. The server in the config doesn't exist, so nothing is started: the pool
+ * opens no connection until then.
+ */
+private fun HikariDataSource.assertBuiltFromConfig() {
+ assert(jdbcUrl == "jdbc:postgresql://db.test:5433/app")
+ assert(username == "app" && password == "secret")
+ assert(dataSourceProperties.getProperty("sslmode") == "verify-full")
+ assert(poolName == "wiring-test")
+}
+
+/** Config comes from src/test/resources/application.conf, as it would in an application. */
+val PostgresqlWiringTest by testSuite {
+ testSuite("typed config decoded by") {
+ test("hoplite") {
+ assert(hopliteConfigBinder(report = false).config("db.jdbc") == expectedConfig)
+ }
+
+ test("hoplite in strict mode, alongside the pool config") {
+ val binder = hopliteConfigBinder(report = false, strict = true)
+ binder.config("db.jdbc")
+ binder.config("db.pool")
+ }
+
+ test("kodein") {
+ val di = DI {
+ import(bootableModule)
+ import(hopliteConfigModule)
+ import(jdbcDataSourceModule)
+ import(postgresqlConfigModule)
+ import(hikariPoolModule)
+ }.direct
+
+ assert(di.instance() == expectedConfig)
+ val hikari = di.instance() as HikariDataSource
+ hikari.assertBuiltFromConfig()
+ assert(di.instance>().filterIsInstance().single().dataSource === hikari)
+ hikari.close()
+ }
+
+ test("metro") {
+ val graph = createGraph()
+
+ assert(graph.jdbcConfig == expectedConfig)
+ val hikari = graph.dataSource as HikariDataSource
+ hikari.assertBuiltFromConfig()
+ assert(graph.appServices.filterIsInstance().single().dataSource === hikari)
+ hikari.close()
+ }
+ }
+}
diff --git a/boot-db-jdbc-postgresql/src/test/resources/application.conf b/boot-db-jdbc-postgresql/src/test/resources/application.conf
new file mode 100644
index 0000000..feb31e8
--- /dev/null
+++ b/boot-db-jdbc-postgresql/src/test/resources/application.conf
@@ -0,0 +1,16 @@
+db {
+ jdbc {
+ host = db.test
+ port = 5433
+ database = app
+ username = app
+ password = secret
+ applicationName = wiring-test
+ sslMode = VERIFY_FULL
+ connectTimeout = 3s
+ }
+ pool {
+ poolName = wiring-test
+ connectionTimeout = 3s
+ }
+}
diff --git a/boot-db-jdbc-postgresql/src/test/resources/db/migration/V1__create_greeting.sql b/boot-db-jdbc-postgresql/src/test/resources/db/migration/V1__create_greeting.sql
new file mode 100644
index 0000000..9d0dbc2
--- /dev/null
+++ b/boot-db-jdbc-postgresql/src/test/resources/db/migration/V1__create_greeting.sql
@@ -0,0 +1,2 @@
+CREATE TABLE greeting (id INT PRIMARY KEY, message VARCHAR(100) NOT NULL);
+INSERT INTO greeting (id, message) VALUES (1, 'hello');
diff --git a/build.gradle.kts b/build.gradle.kts
index 434761e..937ae68 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -35,6 +35,8 @@ dependencies {
dokka(project(":boot-db-exposed"))
dokka(project(":boot-db-flyway"))
dokka(project(":boot-db-jdbc"))
+ dokka(project(":boot-db-jdbc-mysql"))
+ dokka(project(":boot-db-jdbc-postgresql"))
dokka(project(":boot-db-jdbc-sqlite"))
dokka(project(":boot-db-pool-hikari"))
dokka(project(":boot-env"))
@@ -285,6 +287,74 @@ project("boot-db-jdbc-sqlite") {
}
}
+project("boot-db-jdbc-mysql") {
+ configureDiVariants()
+ dependencies {
+ api(project(":boot-db-jdbc"))
+ // the driver is the point of this module: depending on it brings the right JDBC driver transitively
+ api(rootProject.libs.mysql.connector.j)
+ // Flyway needs its MySQL database module for MySQL migrations; harmless without boot-db-flyway
+ runtimeOnly(rootProject.libs.flyway.mysql)
+ "metroApi"(project(path = ":boot", configuration = "metroElements"))
+ "metroApi"(project(path = ":boot-db-jdbc", configuration = "metroElements"))
+ "kodeinApi"(project(path = ":boot-db-jdbc", configuration = "kodeinElements"))
+
+ // the tests exercise this module's kodein and metro flavors too, wired up as a consumer would
+ testImplementation(project(path = ":boot", configuration = "kodeinElements"))
+ testImplementation(project(path = ":boot", configuration = "metroElements"))
+ testImplementation(project(path = ":boot-config-hoplite", configuration = "kodeinElements"))
+ testImplementation(project(path = ":boot-config-hoplite", configuration = "metroElements"))
+ testImplementation(project(path = ":boot-db-jdbc", configuration = "kodeinElements"))
+ testImplementation(project(path = ":boot-db-jdbc", configuration = "metroElements"))
+ // the driver settings only matter through a real pool, and Flyway proves its database module is there
+ testImplementation(project(path = ":boot-db-pool-hikari", configuration = "kodeinElements"))
+ testImplementation(project(path = ":boot-db-pool-hikari", configuration = "metroElements"))
+ testImplementation(project(":boot-db-flyway"))
+ testImplementation(project(":boot-logging-log4j2"))
+ // a real MySQL in a container; those tests are skipped where Docker is unavailable, outside CI
+ testImplementation(rootProject.libs.testcontainers.mysql)
+ }
+
+ sourceSets.named("test") {
+ compileClasspath += sourceSets["kodein"].output + sourceSets["metro"].output
+ runtimeClasspath += sourceSets["kodein"].output + sourceSets["metro"].output
+ }
+}
+
+project("boot-db-jdbc-postgresql") {
+ configureDiVariants()
+ dependencies {
+ api(project(":boot-db-jdbc"))
+ // the driver is the point of this module: depending on it brings the right JDBC driver transitively
+ api(rootProject.libs.postgresql)
+ // Flyway needs its PostgreSQL database module for PostgreSQL migrations; harmless without boot-db-flyway
+ runtimeOnly(rootProject.libs.flyway.database.postgresql)
+ "metroApi"(project(path = ":boot", configuration = "metroElements"))
+ "metroApi"(project(path = ":boot-db-jdbc", configuration = "metroElements"))
+ "kodeinApi"(project(path = ":boot-db-jdbc", configuration = "kodeinElements"))
+
+ // the tests exercise this module's kodein and metro flavors too, wired up as a consumer would
+ testImplementation(project(path = ":boot", configuration = "kodeinElements"))
+ testImplementation(project(path = ":boot", configuration = "metroElements"))
+ testImplementation(project(path = ":boot-config-hoplite", configuration = "kodeinElements"))
+ testImplementation(project(path = ":boot-config-hoplite", configuration = "metroElements"))
+ testImplementation(project(path = ":boot-db-jdbc", configuration = "kodeinElements"))
+ testImplementation(project(path = ":boot-db-jdbc", configuration = "metroElements"))
+ // the driver settings only matter through a real pool, and Flyway proves its database module is there
+ testImplementation(project(path = ":boot-db-pool-hikari", configuration = "kodeinElements"))
+ testImplementation(project(path = ":boot-db-pool-hikari", configuration = "metroElements"))
+ testImplementation(project(":boot-db-flyway"))
+ testImplementation(project(":boot-logging-log4j2"))
+ // a real PostgreSQL in a container; those tests are skipped where Docker is unavailable, outside CI
+ testImplementation(rootProject.libs.testcontainers.postgresql)
+ }
+
+ sourceSets.named("test") {
+ compileClasspath += sourceSets["kodein"].output + sourceSets["metro"].output
+ runtimeClasspath += sourceSets["kodein"].output + sourceSets["metro"].output
+ }
+}
+
project("boot-db-exposed") {
configureDiVariants()
dependencies {
@@ -325,6 +395,7 @@ project("boot-db-flyway") {
dependencies {
api(project(":boot-db-jdbc"))
// flyway-core supports SQLite itself; other databases' Flyway modules come with their driver modules
+ // (boot-db-jdbc-postgresql, boot-db-jdbc-mysql)
api(rootProject.libs.flyway.core)
// the logging API only: the application chooses the logging implementation (e.g. boot-logging-log4j2)
implementation(rootProject.libs.bundles.log4j.api)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ca2627f..6e3a856 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -12,7 +12,10 @@ ktor = "3.1.1"
log4j = "2.24.3"
log4j-kotlin-api = "1.5.0"
metro = "1.3.2"
+mysql-connector-j = "26.7.0"
+postgresql = "42.7.14"
sqlite-jdbc = "3.53.4.0"
+testcontainers = "2.0.5"
# 1.1.0+ trips a power-assert bug on TestBalloon's generated entry point ("Unable to find source for IrFile",
# KT-83931, fixed in Kotlin 2.4.0); stay on the 1.0.x line until Kotlin is bumped
testBalloon = "1.0.0-K2.3.0"
@@ -34,6 +37,8 @@ exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "e
exposed-migration-jdbc = { module = "org.jetbrains.exposed:exposed-migration-jdbc", version.ref = "exposed" }
flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" }
+flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql", version.ref = "flyway" }
+flyway-mysql = { module = "org.flywaydb:flyway-mysql", version.ref = "flyway" }
hikari = { module = "com.zaxxer:HikariCP", version.ref = "hikari" }
@@ -61,6 +66,8 @@ log4j-jul = { module = "org.apache.logging.log4j:log4j-jul", version.ref = "log4
log4j-layout-template-json = { module = "org.apache.logging.log4j:log4j-layout-template-json", version.ref = "log4j" }
log4j-slf4j-impl = { module = "org.apache.logging.log4j:log4j-slf4j2-impl", version.ref = "log4j" }
+mysql-connector-j = { module = "com.mysql:mysql-connector-j", version.ref = "mysql-connector-j" }
+postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }
sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite-jdbc" }
# below here, only used for the server-ktor sample
@@ -71,6 +78,9 @@ ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref
testBalloon-framework-core = { module = "de.infix.testBalloon:testBalloon-framework-core", version.ref = "testBalloon" }
+testcontainers-mysql = { module = "org.testcontainers:testcontainers-mysql", version.ref = "testcontainers" }
+testcontainers-postgresql = { module = "org.testcontainers:testcontainers-postgresql", version.ref = "testcontainers" }
+
[bundles]
exposed = [
"exposed-core",
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 4e90a15..bf5c89b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -29,6 +29,8 @@ include("boot-config-hoplite")
include("boot-db-exposed")
include("boot-db-flyway")
include("boot-db-jdbc")
+include("boot-db-jdbc-mysql")
+include("boot-db-jdbc-postgresql")
include("boot-db-jdbc-sqlite")
include("boot-db-pool-hikari")
include("boot-env")