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
+}