Add database migrations and user table / migration
This commit is contained in:
parent
da8362e88c
commit
452e11aa7b
22
README.md
22
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)
|
||||
|
||||
6
pom.xml
6
pom.xml
@ -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>
|
||||
@ -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)
|
||||
}
|
||||
42
src/main/kotlin/dev/mduchene/database/DatabaseConfig.kt
Normal file
42
src/main/kotlin/dev/mduchene/database/DatabaseConfig.kt
Normal 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
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user