From 452e11aa7bb585bf40faf793266d909639c3bc39 Mon Sep 17 00:00:00 2001 From: Maxime Duchene-Savard Date: Tue, 23 Jun 2026 23:57:25 -0400 Subject: [PATCH] Add database migrations and user table / migration --- README.md | 22 +++ pom.xml | 8 +- src/main/kotlin/Main.kt | 10 +- .../dev/mduchene/database/DatabaseConfig.kt | 42 +++++ .../database/migration/DatabaseMigration.kt | 10 ++ .../database/migration/DatabaseMigrator.kt | 151 ++++++++++++++++++ .../migrations/V001CreateUsersTable.kt | 22 +++ .../migration/DatabaseMigratorTest.kt | 18 +++ .../migration/fixture/TestMigrations.kt | 12 ++ 9 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/dev/mduchene/database/DatabaseConfig.kt create mode 100644 src/main/kotlin/dev/mduchene/database/migration/DatabaseMigration.kt create mode 100644 src/main/kotlin/dev/mduchene/database/migration/DatabaseMigrator.kt create mode 100644 src/main/kotlin/dev/mduchene/database/migrations/V001CreateUsersTable.kt create mode 100644 src/test/kotlin/dev/mduchene/database/migration/DatabaseMigratorTest.kt create mode 100644 src/test/kotlin/dev/mduchene/database/migration/fixture/TestMigrations.kt diff --git a/README.md b/README.md index 3ed0ba4..0cc82e9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,27 @@ # Boruto +## Database + +The server runs database migrations before Javalin starts. + +By default, it connects to: + +```text +jdbc:postgresql://localhost:5432/boruto +username: boruto +password: boruto +``` + +Override those values with: + +- `DATABASE_URL` or `JDBC_DATABASE_URL` +- `DATABASE_USERNAME`, `POSTGRES_USER`, or `DB_USER` +- `DATABASE_PASSWORD`, `POSTGRES_PASSWORD`, or `DB_PASSWORD` + +Migrations are classes in `dev.mduchene.database.migrations`. The migration +runner discovers classes in that package and runs unapplied migrations ordered by +class name. + ## Documentation - [SQL Builder](docs/sql-builder.md) diff --git a/pom.xml b/pom.xml index 5ac7a6b..edb1def 100644 --- a/pom.xml +++ b/pom.xml @@ -97,6 +97,12 @@ javalin 7.2.2 + + + org.postgresql + postgresql + 42.7.11 + - \ No newline at end of file + diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 100b065..28c7d60 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -1,9 +1,15 @@ package dev.mduchene +import dev.mduchene.database.DatabaseConfig +import dev.mduchene.database.migration.DatabaseMigrator import io.javalin.Javalin fun main() { - val app = Javalin.create { config -> + DatabaseConfig.fromEnvironment().openConnection().use { connection -> + DatabaseMigrator("dev.mduchene.database.migrations").migrate(connection) + } + + Javalin.create { config -> config.routes.get("/") { ctx -> ctx.result("Hello World") } }.start(7070) -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/mduchene/database/DatabaseConfig.kt b/src/main/kotlin/dev/mduchene/database/DatabaseConfig.kt new file mode 100644 index 0000000..d4b28cb --- /dev/null +++ b/src/main/kotlin/dev/mduchene/database/DatabaseConfig.kt @@ -0,0 +1,42 @@ +package dev.mduchene.database + +import java.sql.Connection +import java.sql.DriverManager + +data class DatabaseConfig( + val jdbcUrl: String, + val username: String, + val password: String, +) { + fun openConnection(): Connection = + DriverManager.getConnection(jdbcUrl, username, password) + + companion object { + fun fromEnvironment(environment: Map = System.getenv()): DatabaseConfig = + DatabaseConfig( + jdbcUrl = environment.firstPresent( + "DATABASE_URL", + "JDBC_DATABASE_URL", + defaultValue = "jdbc:postgresql://localhost:5432/boruto", + ), + username = environment.firstPresent( + "DATABASE_USERNAME", + "POSTGRES_USER", + "DB_USER", + defaultValue = "root", + ), + password = environment.firstPresent( + "DATABASE_PASSWORD", + "POSTGRES_PASSWORD", + "DB_PASSWORD", + defaultValue = "root", + ), + ) + } +} + +private fun Map.firstPresent( + vararg keys: String, + defaultValue: String, +): String = + keys.firstNotNullOfOrNull { key -> this[key]?.takeIf(String::isNotBlank) } ?: defaultValue diff --git a/src/main/kotlin/dev/mduchene/database/migration/DatabaseMigration.kt b/src/main/kotlin/dev/mduchene/database/migration/DatabaseMigration.kt new file mode 100644 index 0000000..f5d43b1 --- /dev/null +++ b/src/main/kotlin/dev/mduchene/database/migration/DatabaseMigration.kt @@ -0,0 +1,10 @@ +package dev.mduchene.database.migration + +import java.sql.Connection + +interface DatabaseMigration { + val name: String + get() = javaClass.simpleName + + fun migrate(connection: Connection) +} diff --git a/src/main/kotlin/dev/mduchene/database/migration/DatabaseMigrator.kt b/src/main/kotlin/dev/mduchene/database/migration/DatabaseMigrator.kt new file mode 100644 index 0000000..2752437 --- /dev/null +++ b/src/main/kotlin/dev/mduchene/database/migration/DatabaseMigrator.kt @@ -0,0 +1,151 @@ +package dev.mduchene.database.migration + +import java.io.File +import java.lang.reflect.Modifier +import java.net.JarURLConnection +import java.net.URL +import java.sql.Connection +import java.util.jar.JarFile + +class DatabaseMigrator( + private val migrationsPackage: String, + private val classLoader: ClassLoader = Thread.currentThread().contextClassLoader, +) { + fun migrate(connection: Connection) { + val originalAutoCommit = connection.autoCommit + connection.autoCommit = false + + try { + ensureMigrationTable(connection) + val appliedMigrations = appliedMigrationNames(connection) + + discoverMigrations() + .filterNot { migration -> migration.name in appliedMigrations } + .forEach { migration -> + migration.migrate(connection) + recordMigration(connection, migration.name) + connection.commit() + } + + connection.commit() + } catch (exception: Exception) { + connection.rollback() + throw exception + } finally { + connection.autoCommit = originalAutoCommit + } + } + + fun discoverMigrations(): List = + classNamesInPackage() + .map { className -> Class.forName(className, true, classLoader) } + .filter { migrationClass -> + DatabaseMigration::class.java.isAssignableFrom(migrationClass) && + !migrationClass.isInterface && + !Modifier.isAbstract(migrationClass.modifiers) + } + .map { migrationClass -> + migrationClass.getDeclaredConstructor().newInstance() as DatabaseMigration + } + .sortedBy(DatabaseMigration::name) + + private fun ensureMigrationTable(connection: Connection) { + connection.createStatement().use { statement -> + statement.executeUpdate( + """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + name VARCHAR(255) PRIMARY KEY, + applied_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """.trimIndent(), + ) + } + } + + private fun appliedMigrationNames(connection: Connection): Set = + connection.createStatement().use { statement -> + statement.executeQuery("SELECT name FROM schema_migrations").use { results -> + buildSet { + while (results.next()) { + add(results.getString("name")) + } + } + } + } + + private fun recordMigration( + connection: Connection, + migrationName: String, + ) { + connection.prepareStatement("INSERT INTO schema_migrations (name) VALUES (?)").use { statement -> + statement.setString(1, migrationName) + statement.executeUpdate() + } + } + + private fun classNamesInPackage(): List { + val packagePath = migrationsPackage.replace('.', '/') + val resources = classLoader.getResources(packagePath).toList() + + return resources.flatMap { resource -> + when (resource.protocol) { + "file" -> classNamesFromDirectory(resource) + "jar" -> classNamesFromJar(resource, packagePath) + else -> emptyList() + } + }.distinct() + } + + private fun classNamesFromDirectory(resource: URL): List { + val root = File(resource.toURI()) + + return root + .walkTopDown() + .filter { file -> file.isFile && file.extension == "class" && '$' !in file.name } + .map { file -> + val relativeClassName = root + .toPath() + .relativize(file.toPath()) + .toString() + .removeSuffix(".class") + .replace(File.separatorChar, '.') + + "$migrationsPackage.$relativeClassName" + } + .toList() + } + + private fun classNamesFromJar( + resource: URL, + packagePath: String, + ): List { + val connection = resource.openConnection() + + return when (connection) { + is JarURLConnection -> classNamesFromJar(connection.jarFile, packagePath) + else -> emptyList() + } + } + + private fun classNamesFromJar( + jarFile: JarFile, + packagePath: String, + ): List = + jarFile + .entries() + .asSequence() + .map { entry -> entry.name } + .filter { name -> name.startsWith(packagePath) && name.endsWith(".class") && '$' !in name } + .map { name -> name.removeSuffix(".class").replace('/', '.') } + .toList() +} + +private fun java.util.Enumeration.toList(): List { + val values = mutableListOf() + + while (hasMoreElements()) { + values += nextElement() + } + + return values +} diff --git a/src/main/kotlin/dev/mduchene/database/migrations/V001CreateUsersTable.kt b/src/main/kotlin/dev/mduchene/database/migrations/V001CreateUsersTable.kt new file mode 100644 index 0000000..113b953 --- /dev/null +++ b/src/main/kotlin/dev/mduchene/database/migrations/V001CreateUsersTable.kt @@ -0,0 +1,22 @@ +package dev.mduchene.database.migrations + +import dev.mduchene.database.migration.DatabaseMigration +import java.sql.Connection + +class V001CreateUsersTable : DatabaseMigration { + override fun migrate(connection: Connection) { + connection.createStatement().use { statement -> + statement.executeUpdate( + """ + CREATE TABLE IF NOT EXISTS users ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + username VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """.trimIndent(), + ) + } + } +} diff --git a/src/test/kotlin/dev/mduchene/database/migration/DatabaseMigratorTest.kt b/src/test/kotlin/dev/mduchene/database/migration/DatabaseMigratorTest.kt new file mode 100644 index 0000000..5f8d5fe --- /dev/null +++ b/src/test/kotlin/dev/mduchene/database/migration/DatabaseMigratorTest.kt @@ -0,0 +1,18 @@ +package dev.mduchene.database.migration + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DatabaseMigratorTest { + @Test + fun `discovers migrations from a package ordered by migration name`() { + val migrations = DatabaseMigrator("dev.mduchene.database.migration.fixture") + .discoverMigrations() + .map(DatabaseMigration::name) + + assertEquals( + listOf("V001FirstMigration", "V002SecondMigration"), + migrations, + ) + } +} diff --git a/src/test/kotlin/dev/mduchene/database/migration/fixture/TestMigrations.kt b/src/test/kotlin/dev/mduchene/database/migration/fixture/TestMigrations.kt new file mode 100644 index 0000000..3fad5eb --- /dev/null +++ b/src/test/kotlin/dev/mduchene/database/migration/fixture/TestMigrations.kt @@ -0,0 +1,12 @@ +package dev.mduchene.database.migration.fixture + +import dev.mduchene.database.migration.DatabaseMigration +import java.sql.Connection + +class V002SecondMigration : DatabaseMigration { + override fun migrate(connection: Connection) = Unit +} + +class V001FirstMigration : DatabaseMigration { + override fun migrate(connection: Connection) = Unit +}