Add database migrations and user table / migration

This commit is contained in:
Maxime Duchene-Savard 2026-06-23 23:57:25 -04:00
parent da8362e88c
commit 452e11aa7b
9 changed files with 292 additions and 3 deletions

View File

@ -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)

View File

@ -97,6 +97,12 @@
<artifactId>javalin</artifactId>
<version>7.2.2</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.11</version>
</dependency>
</dependencies>
</project>

View File

@ -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)
}

View File

@ -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<String, String> = 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<String, String>.firstPresent(
vararg keys: String,
defaultValue: String,
): String =
keys.firstNotNullOfOrNull { key -> this[key]?.takeIf(String::isNotBlank) } ?: defaultValue

View File

@ -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)
}

View File

@ -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<DatabaseMigration> =
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<String> =
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<String> {
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<String> {
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<String> {
val connection = resource.openConnection()
return when (connection) {
is JarURLConnection -> classNamesFromJar(connection.jarFile, packagePath)
else -> emptyList()
}
}
private fun classNamesFromJar(
jarFile: JarFile,
packagePath: String,
): List<String> =
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 <T> java.util.Enumeration<T>.toList(): List<T> {
val values = mutableListOf<T>()
while (hasMoreElements()) {
values += nextElement()
}
return values
}

View File

@ -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(),
)
}
}
}

View File

@ -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,
)
}
}

View File

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