feat: initial db persistence

This commit is contained in:
Finn Linck Ryan 2025-12-13 17:09:08 +01:00
parent e8abfb18eb
commit 2c4995f9d7
9 changed files with 342 additions and 46 deletions

4
.env.example Normal file
View file

@ -0,0 +1,4 @@
DB_URL=jdbc:postgresql://localhost:5432/homepage
DB_MIGRATION=src/main/resources/db/migration
DB_USERNAME=postgres
DB_PASSWORD=postgres

View file

@ -1,14 +1,42 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import io.github.klahap.dotenv.DotEnvBuilder
import org.flywaydb.gradle.task.FlywayMigrateTask
import org.gradle.api.JavaVersion.VERSION_21
import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
import kotlin.io.path.Path
plugins {
kotlin("jvm") version "2.2.20"
val env = DotEnvBuilder.dotEnv {
addFile("$rootDir/.env")
addSystemEnv()
}
id("gg.jte.gradle") version "3.2.1"
id("com.gradleup.shadow") version "9.3.0"
val envDbUrl: String = env["DB_URL"] ?: ""
val envDbMigration: String = env["DB_MIGRATIONS"] ?: "src/main/resources/db/migration"
val envDbUsername: String = env["DB_USERNAME"] ?: ""
val envDbPassword: String = env["DB_PASSWORD"] ?: ""
val generatedResourcesDirectory = "${layout.buildDirectory.get()}/generated-resources"
val generatedSourcesDirectory = "${layout.buildDirectory.get()}/generated-src"
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.shadow)
alias(libs.plugins.dotenv.plugin)
alias(libs.plugins.jte)
alias(libs.plugins.flyway)
alias(libs.plugins.jooq.codegen.gradle)
alias(libs.plugins.taskinfo)
}
kotlin {
jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
repositories {
mavenCentral()
}
buildscript {
@ -18,32 +46,20 @@ buildscript {
}
dependencies {
classpath(libs.postgresql)
classpath(libs.flyway.database.postgresql)
}
}
kotlin {
jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
jte {
sourceDirectory.set(Path("src/main/kte"))
targetDirectory.set(Path("${layout.buildDirectory.get()}/classes/jte"))
precompile()
}
sourceSets.main {
resources.srcDir("${layout.buildDirectory.get()}/classes/jte")
}
repositories {
mavenCentral()
resources.srcDir("$generatedResourcesDirectory/jte")
kotlin.srcDir("$generatedSourcesDirectory/jooq")
}
tasks {
withType<KotlinJvmCompile>().configureEach {
dependsOn("jooqCodegen")
compilerOptions {
allWarningsAsErrors = false
jvmTarget.set(JVM_21)
@ -55,6 +71,14 @@ tasks {
useJUnitPlatform()
}
withType<FlywayMigrateTask> {
dependsOn("initDb")
}
named("precompileJte") {
dependsOn("compileKotlin")
}
named<ShadowJar>("shadowJar") {
manifest {
attributes("Main-Class" to "at.dokkae.homepage.HomepageKt")
@ -62,13 +86,19 @@ tasks {
dependsOn("precompileJte")
from("${layout.buildDirectory.get()}/classes/jte")
mustRunAfter("flywayMigrate", "jooqCodegen")
from("$generatedResourcesDirectory/jte")
archiveFileName.set("app.jar")
exclude("META-INF/*.RSA", "META-INF/*.SF", "META-INF/*.DSA")
}
register("buildDocker") {
}
java {
sourceCompatibility = VERSION_21
targetCompatibility = VERSION_21
@ -76,17 +106,101 @@ tasks {
}
dependencies {
implementation(platform("org.http4k:http4k-bom:6.23.1.0"))
implementation("org.http4k:http4k-client-okhttp")
implementation("org.http4k:http4k-core")
implementation("org.http4k:http4k-server-jetty")
implementation("org.http4k:http4k-template-jte")
implementation("org.http4k:http4k-web-htmx")
implementation("gg.jte:jte-kotlin:3.2.1")
testImplementation("org.http4k:http4k-testing-approval")
testImplementation("org.http4k:http4k-testing-hamkrest")
testImplementation("org.junit.jupiter:junit-jupiter-api:6.0.0")
testImplementation("org.junit.jupiter:junit-jupiter-engine:6.0.0")
testImplementation("org.junit.platform:junit-platform-launcher:6.0.0")
implementation(platform(libs.http4k.bom))
implementation(libs.dotenv)
implementation(libs.bundles.http4k)
implementation(libs.jte.kotlin)
implementation(libs.bundles.database)
testImplementation(libs.bundles.testing)
jooqCodegen(libs.jooq.meta)
jooqCodegen(libs.jooq.postgres)
}
// ========== JTE Templating ==========
jte {
sourceDirectory.set(Path("src/main/kte"))
targetDirectory.set(Path("$generatedResourcesDirectory/jte"))
precompile()
}
// ========== FlyWay ==========
flyway {
url = envDbUrl
user = envDbUsername
password = envDbPassword
locations = arrayOf("filesystem:$envDbMigration")
baselineOnMigrate = true
validateMigrationNaming = true
}
tasks.register("initDb") {
doFirst {
println("Database Configuration:")
println(" Raw URL from env: $envDbUrl")
println(" Resolved URL: $envDbUrl")
println(" Migrations: $envDbMigration")
println(" Credentials:")
println(" Username: $envDbUsername")
println(" Password: ${"*".repeat(envDbPassword.length)}")
}
}
tasks.named("flywayMigrate") {
finalizedBy("jooqCodegen")
}
// ========== Jooq ==========
jooq {
configuration {
logging = org.jooq.meta.jaxb.Logging.WARN
jdbc {
driver = "org.postgresql.Driver"
url = envDbUrl
user = envDbUsername
password = envDbPassword
}
generator {
name = "org.jooq.codegen.KotlinGenerator"
database {
name = "org.jooq.meta.postgres.PostgresDatabase"
inputSchema = "public"
// SQLite specific configuration
includes = ".*"
excludes = """
flyway_.*|
pg_.*|
information_schema.*
""".trimMargin().replace("\n", "")
}
generate {
// Recommended settings for Kotlin
isDeprecated = false
isRecords = true
isImmutablePojos = true
isFluentSetters = true
isKotlinNotNullRecordAttributes = true
isKotlinNotNullPojoAttributes = true
isKotlinNotNullInterfaceAttributes = true
isPojosAsKotlinDataClasses = true
}
target {
packageName = "at.dokkae.homepage.generated.jooq"
directory = "$generatedSourcesDirectory/jooq"
}
strategy {
name = "org.jooq.codegen.DefaultGeneratorStrategy"
}
}
}
}

View file

@ -1,2 +1,2 @@
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.configuration-cache=false

83
gradle/libs.versions.toml Normal file
View file

@ -0,0 +1,83 @@
[versions]
kotlin = "2.2.20"
shadow = "9.3.0"
dotenv-plugin = "1.1.3"
dotenv = "6.5.1"
http4k = "6.23.1.0"
jte = "3.2.1"
flyway = "11.19.0"
jooq = "3.20.10"
junit = "6.0.0"
postgresql = "42.7.7"
taskinfo = "3.0.0"
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
dotenv-plugin = { id = "io.github.klahap.dotenv", version.ref = "dotenv-plugin" }
jte = { id = "gg.jte.gradle", version.ref = "jte" }
flyway = { id = "org.flywaydb.flyway", version.ref = "flyway" }
jooq-codegen-gradle = { id = "org.jooq.jooq-codegen-gradle", version.ref = "jooq" }
taskinfo = { id = "org.barfuin.gradle.taskinfo", version.ref = "taskinfo" }
[bundles]
http4k = [
"http4k-client-okhttp",
"http4k-core",
"http4k-server-jetty",
"http4k-template-jte",
"http4k-web-htmx"
]
testing = [
"http4k-testing-approval",
"http4k-testing-hamkrest",
"junit-jupiter-api",
"junit-jupiter-engine",
"junit-platform-launcher"
]
database = [
"flyway-core",
"jooq",
"jooq-meta",
"jooq-codegen",
"jooq-postgres"
]
[libraries]
# Environment Management
dotenv = { module = "io.github.cdimascio:dotenv-kotlin", version.ref = "dotenv" }
# HTTP4K Platform (BOM)
http4k-bom = { module = "org.http4k:http4k-bom", version.ref = "http4k" }
# HTTP4K Dependencies
http4k-client-okhttp = { module = "org.http4k:http4k-client-okhttp" }
http4k-core = { module = "org.http4k:http4k-core" }
http4k-server-jetty = { module = "org.http4k:http4k-server-jetty" }
http4k-template-jte = { module = "org.http4k:http4k-template-jte" }
http4k-web-htmx = { module = "org.http4k:http4k-web-htmx" }
# JTE Templating
jte-kotlin = { module = "gg.jte:jte-kotlin", version.ref = "jte" }
# Database Driver
postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }
# Flyway
flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" }
flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql", version.ref = "flyway"}
# Jooq
jooq = { module = "org.jooq:jooq", version.ref = "jooq" }
jooq-meta = { module = "org.jooq:jooq-meta", version.ref = "jooq" }
jooq-codegen = { module = "org.jooq:jooq-codegen", version.ref = "jooq" }
jooq-postgres = { module = "org.jooq:jooq-postgres-extensions", version.ref = "jooq" }
# Testing
http4k-testing-approval = { module = "org.http4k:http4k-testing-approval" }
http4k-testing-hamkrest = { module = "org.http4k:http4k-testing-hamkrest" }
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" }
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit" }

View file

@ -1,7 +1,10 @@
package at.dokkae.homepage
import at.dokkae.homepage.extensions.Precompiled
import at.dokkae.homepage.repository.MessageRepository
import at.dokkae.homepage.repository.impls.JooqMessageRepository
import at.dokkae.homepage.templates.Index
import io.github.cdimascio.dotenv.dotenv
import org.http4k.core.HttpHandler
import org.http4k.core.Method.*
import org.http4k.core.Response
@ -22,30 +25,43 @@ import org.http4k.sse.SseResponse
import org.http4k.template.JTETemplates
import org.http4k.template.ViewModel
import org.jetbrains.kotlin.backend.common.push
import org.jooq.SQLDialect
import org.jooq.impl.DSL
import java.sql.DriverManager
import java.time.Instant
import java.util.UUID
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.concurrent.thread
data class Message (
data class Message(
val author: String,
val content: String,
val createdAt: Instant = Instant.now(),
val id: UUID = UUID.randomUUID(),
val createdAt: Instant = Instant.now(),
val updatedAt: Instant? = null
) : ViewModel {
init {
require(author.length <= 31) { "Author must be 31 characters or less" }
require(content.length <= 255) { "Content must be 255 characters or less" }
}
override fun template(): String = "partials/Message"
}
fun main() {
val messages = CopyOnWriteArrayList<Message>()
val subscribers = CopyOnWriteArrayList<Sse>()
val renderer = JTETemplates().Precompiled("build/classes/jte")
val env = dotenv()
messages.push(Message("Kurisu", "Hello World!"))
messages.push(Message("Violet", "Haii Kurisu!"))
val connection = DriverManager.getConnection(env["DB_URL"], env["DB_USERNAME"], env["DB_PASSWORD"])
val dslContext = DSL.using(connection, SQLDialect.POSTGRES)
val messageRepository: MessageRepository = JooqMessageRepository(dslContext)
val subscribers = CopyOnWriteArrayList<Sse>()
val renderer = JTETemplates().Precompiled("build/generated-resources/jte")
val indexHandler: HttpHandler = {
Response(Status.OK).body(renderer(Index(messages)))
Response(Status.OK).body(renderer(Index(messageRepository.findAll())))
}
val sse = sse(
@ -72,12 +88,12 @@ fun main() {
val msg = Message(author, message)
val sseMsg = SseMessage.Data(renderer(msg))
messages.push(msg)
messageRepository.save(msg)
subscribers.forEach {
thread { it.send(sseMsg) }
}
Response(Status.OK)
Response(Status.CREATED)
}
}
)

View file

@ -0,0 +1,8 @@
package at.dokkae.homepage.repository
import at.dokkae.homepage.Message
interface MessageRepository {
fun save(message: Message): Message
fun findAll(): List<Message>
}

View file

@ -0,0 +1,40 @@
package at.dokkae.homepage.repository.impls
import at.dokkae.homepage.Message
import at.dokkae.homepage.generated.jooq.tables.records.MessageRecord
import at.dokkae.homepage.generated.jooq.tables.references.MESSAGE
import at.dokkae.homepage.repository.MessageRepository
import org.jooq.DSLContext
import org.jooq.impl.DSL
class JooqMessageRepository(
private val dslContext: DSLContext
) : MessageRepository {
override fun save(message: Message): Message = dslContext.transactionResult { config ->
val ctx = DSL.using(config)
ctx.insertInto(MESSAGE)
.set(MESSAGE.ID, message.id)
.set(MESSAGE.AUTHOR, message.author)
.set(MESSAGE.CONTENT, message.content)
.onDuplicateKeyUpdate()
.set(MESSAGE.AUTHOR, message.author)
.set(MESSAGE.CONTENT, message.content)
.returning()
.fetchOne()!!
.toMessage()
}
override fun findAll(): List<Message> = dslContext.selectFrom(MESSAGE)
.orderBy(MESSAGE.CREATED_AT.desc())
.fetch { it.toMessage() }
private fun MessageRecord.toMessage(): Message = Message(
id = this.id,
author = this.author,
content = this.content,
createdAt = this.createdAt!!.toInstant(),
updatedAt = this.updatedAt?.toInstant(),
)
}

View file

@ -0,0 +1,18 @@
create or replace function handle_timestamps()
returns trigger as
$$
begin
if lower(tg_op) = 'insert' then
new.created_at = coalesce(new.created_at, current_timestamp);
new.updated_at = coalesce(new.updated_at, current_timestamp);
elsif lower(tg_op) = 'update' then
if new.created_at is distinct from old.created_at then
raise exception 'Direct modification of created_at is not allowed.';
end if;
new.updated_at = current_timestamp;
end if;
return new;
end;
$$ language plpgsql;

View file

@ -0,0 +1,13 @@
create table message (
id uuid not null primary key,
author varchar(31) not null,
content varchar(255) not null,
created_at timestamptz not null default current_timestamp,
updated_at timestamptz
);
create trigger handle_message_timestamps
before insert or update on message
for each row
execute function handle_timestamps();