From dc5818d701fe341f56371601276e37eb22268557 Mon Sep 17 00:00:00 2001 From: Raman Gupta Date: Mon, 19 Oct 2026 14:22:04 -0400 Subject: [PATCH] feat: boot-db-jdbc-postgresql and boot-db-jdbc-mysql driver modules Typed JdbcConfig implementations for PostgreSQL (PostgresqlConfig) and MySQL (MysqlConfig), decoded from db.jdbc, with Kodein and Metro bindings like boot-db-jdbc-sqlite. Each takes host, port, database and credentials, and passes typed driver settings (sslMode, timeouts, and so on) as connection properties. MysqlConfig turns on Connector/J's prepared statement cache, as HikariCP recommends. Each module brings its JDBC driver as api, and its Flyway database module as runtimeOnly, so boot-db-flyway works with it. Tests run against real servers with Testcontainers; they are skipped where Docker is unavailable, except on CI. --- AGENTS.md | 6 ++ README.adoc | 8 +- .../bootable/db/jdbc/MysqlModule.kt | 16 +++ .../bootable/db/jdbc/MysqlConfig.kt | 98 +++++++++++++++++++ .../bootable/db/jdbc/MysqlConfigBindings.kt | 20 ++++ .../bootable/db/jdbc/MysqlConfigTest.kt | 55 +++++++++++ .../bootable/db/jdbc/MysqlDatabaseTest.kt | 64 ++++++++++++ .../bootable/db/jdbc/MysqlWiringTest.kt | 88 +++++++++++++++++ .../src/test/resources/application.conf | 15 +++ .../db/migration/V1__create_greeting.sql | 2 + .../bootable/db/jdbc/PostgresqlModule.kt | 16 +++ .../bootable/db/jdbc/PostgresqlConfig.kt | 89 +++++++++++++++++ .../db/jdbc/PostgresqlConfigBindings.kt | 20 ++++ .../bootable/db/jdbc/PostgresqlConfigTest.kt | 47 +++++++++ .../db/jdbc/PostgresqlDatabaseTest.kt | 65 ++++++++++++ .../bootable/db/jdbc/PostgresqlWiringTest.kt | 89 +++++++++++++++++ .../src/test/resources/application.conf | 16 +++ .../db/migration/V1__create_greeting.sql | 2 + build.gradle.kts | 71 ++++++++++++++ gradle/libs.versions.toml | 10 ++ settings.gradle.kts | 2 + 21 files changed, 796 insertions(+), 3 deletions(-) create mode 100644 boot-db-jdbc-mysql/src/kodein/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlModule.kt create mode 100644 boot-db-jdbc-mysql/src/main/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlConfig.kt create mode 100644 boot-db-jdbc-mysql/src/metro/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlConfigBindings.kt create mode 100644 boot-db-jdbc-mysql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlConfigTest.kt create mode 100644 boot-db-jdbc-mysql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlDatabaseTest.kt create mode 100644 boot-db-jdbc-mysql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/MysqlWiringTest.kt create mode 100644 boot-db-jdbc-mysql/src/test/resources/application.conf create mode 100644 boot-db-jdbc-mysql/src/test/resources/db/migration/V1__create_greeting.sql create mode 100644 boot-db-jdbc-postgresql/src/kodein/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlModule.kt create mode 100644 boot-db-jdbc-postgresql/src/main/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlConfig.kt create mode 100644 boot-db-jdbc-postgresql/src/metro/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlConfigBindings.kt create mode 100644 boot-db-jdbc-postgresql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlConfigTest.kt create mode 100644 boot-db-jdbc-postgresql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlDatabaseTest.kt create mode 100644 boot-db-jdbc-postgresql/src/test/kotlin/com/github/rocketraman/bootable/db/jdbc/PostgresqlWiringTest.kt create mode 100644 boot-db-jdbc-postgresql/src/test/resources/application.conf create mode 100644 boot-db-jdbc-postgresql/src/test/resources/db/migration/V1__create_greeting.sql 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")