Integrating database access and management with Flyway and jOOQ into a Kotlin Ktor app
One of my goals when I started a learning project a couple of years ago was to get more hands-on experience with Kotlin co-routines, so I explored app frameworks that natively supported co-routines. I landed on Ktor, an open-source framework from JetBrains (creators of Kotlin) that supports co-routines throughout the framework. I've really enjoyed working with Ktor, and the lightweight flexibility of the framework allowed me to easily integrate libraries like jOOQ and Flyway.
This post aims to show how I incorporated several libraries and tools to manage schemas and interact with the database from Ktor:
We'll use a simplified bank account as an example throughout this post.
Managing database schema with Flyway
First off, we need a way to manage the database schema - creating tables, etc. I've used Flyway for the past few years as a simple and flexible way to manage database schemas alongside source code, and I wanted to incorporate it into my Ktor projects as well.
I like to split out the database schema and generated jOOQ code into their own Gradle subproject to cleanly separate the mechanics of managing them from the Gradle subproject with the app code.
First, we'll define the initial database tables in a SQL file under src/main/resources/db/migration
in the database
subproject. Flyway uses a version file naming convention to know which order
to apply migrations in and keep track of which migrations have been applied. In our case
we'll name the file starting with V1__
to indicate this is the first migration file.
src/main/resources/db/migration/V1__InitialAccountAndEntryTables.sql
1CREATE TABLE account_holder(
2 id BIGSERIAL PRIMARY KEY,
3 user_name TEXT UNIQUE NOT NULL,
4 first_name TEXT,
5 last_name TEXT
6);
7CREATE INDEX account_holder_user_name_idx on account_holder(user_name);
8
9CREATE TYPE bank_account_type AS ENUM ('CHECKING', 'SAVINGS');
10CREATE TABLE bank_account(
11 id BIGSERIAL PRIMARY KEY,
12 account_holder_id BIGINT REFERENCES account_holder ON DELETE CASCADE,
13 account_type bank_account_type NOT NULL
14);
15CREATE INDEX bank_account_account_holder_id_idx on bank_account(account_holder_id);
16
17CREATE TYPE transaction_type AS ENUM ('DEPOSIT', 'WITHDRAWAL');
18CREATE TABLE account_transaction(
19 id BIGSERIAL PRIMARY KEY,
20 bank_account_id BIGINT REFERENCES bank_account ON DELETE CASCADE,
21 amount NUMERIC(12, 2) NOT NULL,
22 transaction_type transaction_type NOT NULL
23);
24CREATE INDEX account_transaction_bank_account_id_idx on account_transaction(bank_account_id);
Now we'll add the Flyway dependency to our database
subproject:
build.gradle
1dependencies {
2 api "org.flywaydb:flyway-core:7.5.1"
3}
jOOQ code generator
Next we'll configure the jOOQ code generator to generate type-safe database access code to work with our database schema.
The configuration for the jOOQ generator is a bit lengthy - one of the reasons
why I like to separate it out into the database
subproject to avoid the main app's Gradle
file growing too large.
We'll need a running database with the schema applied to point the jOOQ generator at, so I stitch together a couple Gradle plugins to automate this process:
- Use the Docker Compose and a Gradle Docker Compose plugin to spin up a fresh Postgres database
- Next use the Flyway Gradle plugin to run the migrations against the database to initialize the schema
- Run the jOOQ code generator using the jOOQ Gradle plugin
- Finally shut down the Postgres instance using the Gradle Docker Compose plugin
build.gradle
1plugins {
2 id "com.avast.gradle.docker-compose" version "0.14.0"
3 id 'nu.studer.jooq' version "5.2.1"
4 id "org.flywaydb.flyway" version "7.5.1"
5}
6
7dependencies {
8 api "org.flywaydb:flyway-core:7.5.1"
9 api "com.zaxxer:HikariCP:3.4.5"
10 api "org.jooq:jooq:$jooq_version"
11
12 api "org.postgresql:postgresql:$postgres_driver_version"
13
14 jooqGenerator "org.postgresql:postgresql:$postgres_driver_version"
15}
16
17flyway {
18 url = 'jdbc:postgresql://localhost:5433/bankaccountdb'
19 user = 'testuser'
20 password = 'testpass'
21}
22
23jooq {
24 version = jooq_version
25
26 configurations {
27 main {
28 generateSchemaSourceOnCompilation = false
29 generationTool {
30 jdbc {
31 driver = 'org.postgresql.Driver'
32 url = 'jdbc:postgresql://localhost:5433/bankaccountdb'
33 user = 'testuser'
34 password = 'testpass'
35 }
36 generator {
37 name = 'org.jooq.codegen.KotlinGenerator'
38 strategy {
39 name = 'org.jooq.codegen.DefaultGeneratorStrategy'
40 }
41 database {
42 name = 'org.jooq.meta.postgres.PostgresDatabase'
43 excludes = "flyway_schema_history"
44 inputSchema = "public"
45 }
46 generate {
47 relations = true
48 deprecated = false
49 records = true
50 pojos = true
51 pojosEqualsAndHashCode = true
52 daos = true
53 }
54 target {
55 packageName = 'example.bank.database.generated'
56 directory = 'src/main/kotlin'
57 }
58 }
59 }
60 }
61 }
62}
63
64generateJooq.inputs.dir("${projectDir}/src/main/resources/db/migration")
65generateJooq.outputs.cacheIf { true }
66
67flywayMigrate.dependsOn composeUp
68generateJooq.dependsOn flywayMigrate
69generateJooq.finalizedBy composeDownForced
A couple of key call-outs from the jOOQ Gradle config:
- I prefer to check in the generated jOOQ code as the schema changes much less frequently than I modify app code
so that way I don't have to re-generate the jOOQ code every time I build the project.
By default, the jOOQ Gradle plugin will run the generator as part of source compilation, but I'm disabling that by setting
generateSchemaSourceOnCompilation = false
in the jOOQ Gradle plugin config. - jOOQ recently added support for generating Kotlin code, and that is configured with
generator { name = 'org.jooq.codegen.KotlinGenerator' }
above. - This sequence at the end of the
build.gradle
file controls the Postgres instance startup and shutdown:
1flywayMigrate.dependsOn composeUp
2generateJooq.dependsOn flywayMigrate
3generateJooq.finalizedBy composeDownForced
With this setup, running the Gradle generateJooq
task will automatically run all those
operations and we'll end up with a powerful set of generated type-safe database access code.
Using Flyway and jOOQ with Ktor
Next we'll configure and use Flyway and jOOQ in our Ktor application.
First, we need to create Ktor config parameters for the datasource properties (URL, credentials, etc.).
One option for Ktor configuration is using an HOCON file application.conf
:
src/main/resources/application.conf
1ktor {
2 deployment {
3 port = 8080
4 port = ${?PORT}
5 }
6
7 application {
8 modules = [example.bank.ApplicationKt.module]
9 }
10
11 datasource {
12 username = "testuser"
13 username = ${?DB_USERNAME}
14 password = "testpass"
15 password = ${?DB_PASSWORD}
16 jdbcUrl = "jdbc:postgresql://localhost:5433/bankaccountdb"
17 jdbcUrl = ${?DB_URL}
18 schema = "public"
19 schema = ${?DB_SCHEMA}
20 }
21}
The first definition of each parameter is the default value while the second definition allows overriding the default by setting the specified environment variable - convenient for per-environment settings like these datasource properties.
Next, we'll set up the following on application startup in the main Ktor application file:
- Read the datasource config parameters in the Ktor app code
- Initialize the datasource - Hakari for this app
- Run the Flyway migrations
- Create the jOOQ DSLContext - the key jOOQ object
1fun Application.module(): ModuleContext {
2 val applicationConfig = environment.config
3
4 val dataSourceConfig = DataSourceConfig.createDataSourceConfig(applicationConfig)
5 val dataSource = DataSourceConfig.createDataSource(dataSourceConfig)
6 DataSourceConfig.flywayMigrate(dataSource, dataSourceConfig)
7 val dslContext = DataSourceConfig.createDSLContext(dataSource, dataSourceConfig)
8
9 val bankAccountRepository = BankAccountRepository(dslContext)
10 ...
11}
And the code that is performing those operations:
1package example.bank.database
2
3import com.zaxxer.hikari.HikariConfig
4import com.zaxxer.hikari.HikariDataSource
5import io.ktor.config.ApplicationConfig
6import io.ktor.util.KtorExperimentalAPI
7import org.flywaydb.core.Flyway
8import org.jooq.DSLContext
9import org.jooq.SQLDialect
10import org.jooq.conf.MappedSchema
11import org.jooq.conf.RenderMapping
12import org.jooq.conf.Settings
13import org.jooq.impl.DSL
14import javax.sql.DataSource
15
16@KtorExperimentalAPI
17data class DataSourceConfig(val jdbcUrl: String, val username: String, val password: String, val schema: String) {
18 companion object {
19 fun createDataSourceConfig(applicationConfig: ApplicationConfig) = DataSourceConfig(
20 applicationConfig.property("ktor.datasource.jdbcUrl").getString(),
21 applicationConfig.property("ktor.datasource.username").getString(),
22 applicationConfig.property("ktor.datasource.password").getString(),
23 applicationConfig.property("ktor.datasource.schema").getString()
24 )
25
26 fun createDataSource(dataSourceConfig: DataSourceConfig): HikariDataSource {
27 val hikariConfig = HikariConfig()
28 hikariConfig.username = dataSourceConfig.username
29 hikariConfig.password = dataSourceConfig.password
30 hikariConfig.jdbcUrl = dataSourceConfig.jdbcUrl
31 hikariConfig.schema = dataSourceConfig.schema
32 hikariConfig.maximumPoolSize = 10
33
34 val dataSource = HikariDataSource(hikariConfig)
35
36 return dataSource
37 }
38
39 fun flywayMigrate(dataSource: DataSource, dataSourceConfig: DataSourceConfig) {
40 val flyway = Flyway.configure()
41 .dataSource(dataSource)
42 .schemas(dataSourceConfig.schema)
43 .load()
44
45 flyway.migrate()
46 }
47
48 fun createDSLContext(dataSource: DataSource, dataSourceConfig: DataSourceConfig): DSLContext {
49 val settings = Settings()
50 .withRenderMapping(
51 RenderMapping()
52 .withSchemata(
53 MappedSchema().withInput("public")
54 .withOutput(dataSourceConfig.schema)
55 )
56 )
57
58 return DSL.using(dataSource, SQLDialect.POSTGRES, settings)
59 }
60 }
61}
Now we have a database with the migrations applied and the jOOQ root object DSLContext
that
we can use in our database repository code.
For example, fetching bank accounts by account holder username:
1package example.bank.account
2
3import example.bank.database.generated.enums.BankAccountType
4import example.bank.database.generated.tables.BankAccount.Companion.BANK_ACCOUNT
5import example.bank.database.generated.tables.pojos.AccountHolder
6import example.bank.database.generated.tables.pojos.BankAccount
7import example.bank.database.generated.tables.references.ACCOUNT_HOLDER
8import kotlinx.coroutines.Dispatchers
9import kotlinx.coroutines.withContext
10import org.jooq.DSLContext
11import java.math.BigDecimal
12
13class BankAccountRepository(private val dslContext: DSLContext) {
14
15 suspend fun fetchBankAccounts(accountHolderUserName: String): List<BankAccount> =
16 withContext(Dispatchers.IO) {
17 dslContext.select(BANK_ACCOUNT.fields().toList())
18 .from(BANK_ACCOUNT)
19 .innerJoin(ACCOUNT_HOLDER).on(BANK_ACCOUNT.ACCOUNT_HOLDER_ID.eq(ACCOUNT_HOLDER.ID))
20 .where(ACCOUNT_HOLDER.USER_NAME.eq(accountHolderUserName))
21 .fetchInto(BankAccount::class.java)
22 }
23}
Testing
We'll walk through setting up a Ktor application test case that sets up data in the database, accesses an endpoint to fetch bank accounts for a user, then verifies the returned response.
First, let's set up helper code that will create our application under test and configure the database properties (JDBC URL, credentials, etc.):
1package example.bank
2
3import com.fasterxml.jackson.databind.DeserializationFeature
4import com.fasterxml.jackson.databind.ObjectMapper
5import com.fasterxml.jackson.databind.PropertyNamingStrategy
6import com.fasterxml.jackson.databind.SerializationFeature
7import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
8import com.fasterxml.jackson.module.kotlin.registerKotlinModule
9import com.zaxxer.hikari.HikariDataSource
10import io.ktor.application.*
11import io.ktor.config.*
12import io.ktor.util.*
13import org.jooq.DSLContext
14import org.junit.jupiter.api.AfterEach
15
16@KtorExperimentalAPI
17open class ApplicationTestCase {
18 val objectMapper: ObjectMapper = ObjectMapper()
19 .registerKotlinModule()
20 .registerModule(JavaTimeModule())
21 .setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE)
22 .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
23 .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
24
25 lateinit var dataSource: HikariDataSource
26 lateinit var dslContext: DSLContext
27
28 lateinit var bankAccountTestData: BankAccountTestData
29
30 fun createTestApplication(application: Application) {
31
32 (application.environment.config as MapApplicationConfig).apply {
33 put("ktor.datasource.username", System.getenv("DB_USERNAME") ?: "testuser")
34 put("ktor.datasource.password", System.getenv("DB_PASSWORD") ?: "testpass")
35 put("ktor.datasource.jdbcUrl", System.getenv("DB_URL") ?: "jdbc:postgresql://localhost:5433/bankaccountdb")
36 put("ktor.datasource.schema", "public")
37 }
38
39 val appContext = application.module()
40 dataSource = appContext.dataSource
41 dslContext = appContext.dslContext
42
43 bankAccountTestData = BankAccountTestData(dslContext)
44 }
45
46 @AfterEach
47 fun closeDataSource() {
48 dataSource.close()
49 }
50}
First fetching the database values from environment variables allows overriding them in different environments, such as CI with GitHub Actions - where the JDBC URL may be different.
Now we can write up a test case that fetches a bank account by account holder username and verifies the response data:
1package example.bank.account
2
3import com.fasterxml.jackson.module.kotlin.readValue
4import example.bank.ApplicationTestCase
5import example.bank.database.generated.enums.BankAccountType
6import example.bank.database.generated.tables.pojos.BankAccount
7import io.ktor.http.*
8import io.ktor.server.testing.*
9import io.ktor.util.*
10import org.apache.commons.lang3.RandomStringUtils
11import org.junit.jupiter.api.Test
12import strikt.api.expectThat
13import strikt.assertions.all
14import strikt.assertions.hasSize
15import strikt.assertions.isEqualTo
16import kotlin.test.assertNotNull
17
18@KtorExperimentalAPI
19class BankAccountApplicationTest : ApplicationTestCase() {
20
21 @Test
22 fun `should list user bank accounts`() {
23 val userName = RandomStringUtils.randomAlphanumeric(16)
24
25 withTestApplication(::createTestApplication) {
26 handleRequest(HttpMethod.Get, "/accounts/$userName") {
27 val accountHolder = bankAccountTestData.createAccountHolder(userName)
28 bankAccountTestData.createBankAccount(BankAccountType.CHECKING, accountHolder)
29 }.apply {
30 expectThat(response.status()).isEqualTo(HttpStatusCode.OK)
31
32 val responseBody = response.content
33 assertNotNull(responseBody)
34
35 val bankAccounts: List<BankAccount> = objectMapper.readValue(responseBody)
36 expectThat(bankAccounts).hasSize(1).all {
37 get { accountType }.isEqualTo(BankAccountType.CHECKING)
38 }
39 }
40 }
41 }
42}
Conclusion
Thanks to the lightweight nature of Ktor and the modularity of database libraries such as Flyway and jOOQ, it is straightforward to integrate these tools together for controlled database migrations with Flyway and powerful, type-safe database access code with jOOQ.
Example code
The full code for this example project is at https://github.com/craigatk/bank-account-example