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
|
# 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
|
## Documentation
|
||||||
|
|
||||||
- [SQL Builder](docs/sql-builder.md)
|
- [SQL Builder](docs/sql-builder.md)
|
||||||
|
|||||||
8
pom.xml
8
pom.xml
@ -97,6 +97,12 @@
|
|||||||
<artifactId>javalin</artifactId>
|
<artifactId>javalin</artifactId>
|
||||||
<version>7.2.2</version>
|
<version>7.2.2</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<version>42.7.11</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
package dev.mduchene
|
package dev.mduchene
|
||||||
|
|
||||||
|
import dev.mduchene.database.DatabaseConfig
|
||||||
|
import dev.mduchene.database.migration.DatabaseMigrator
|
||||||
import io.javalin.Javalin
|
import io.javalin.Javalin
|
||||||
|
|
||||||
fun main() {
|
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") }
|
config.routes.get("/") { ctx -> ctx.result("Hello World") }
|
||||||
}.start(7070)
|
}.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