From 2c4995f9d7c07b7695df6a51dcda30b10a8e4a1f Mon Sep 17 00:00:00 2001 From: Dokkae6949 Date: Sat, 13 Dec 2025 17:09:08 +0100 Subject: [PATCH] feat: initial db persistence --- .env.example | 4 + build.gradle.kts | 184 ++++++++++++++---- gradle.properties | 2 +- gradle/libs.versions.toml | 83 ++++++++ .../kotlin/at/dokkae/homepage/Homepage.kt | 36 +++- .../homepage/repository/MessageRepository.kt | 8 + .../repository/impls/JooqMessageRepository.kt | 40 ++++ ...d_update_and_create_timestamp_triggers.sql | 18 ++ .../db/migration/V002__add_message_table.sql | 13 ++ 9 files changed, 342 insertions(+), 46 deletions(-) create mode 100644 .env.example create mode 100644 gradle/libs.versions.toml create mode 100644 src/main/kotlin/at/dokkae/homepage/repository/MessageRepository.kt create mode 100644 src/main/kotlin/at/dokkae/homepage/repository/impls/JooqMessageRepository.kt create mode 100644 src/main/resources/db/migration/V001__add_update_and_create_timestamp_triggers.sql create mode 100644 src/main/resources/db/migration/V002__add_message_table.sql diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..60c183c --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +DB_URL=jdbc:postgresql://localhost:5432/homepage +DB_MIGRATION=src/main/resources/db/migration +DB_USERNAME=postgres +DB_PASSWORD=postgres \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 75d553e..ce86718 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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().configureEach { + dependsOn("jooqCodegen") + compilerOptions { allWarningsAsErrors = false jvmTarget.set(JVM_21) @@ -55,6 +71,14 @@ tasks { useJUnitPlatform() } + withType { + dependsOn("initDb") + } + + named("precompileJte") { + dependsOn("compileKotlin") + } + named("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" + } + } + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 216f58e..d3c86a1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,2 @@ org.gradle.caching=true -org.gradle.configuration-cache=true \ No newline at end of file +org.gradle.configuration-cache=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..c284c06 --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } \ No newline at end of file diff --git a/src/main/kotlin/at/dokkae/homepage/Homepage.kt b/src/main/kotlin/at/dokkae/homepage/Homepage.kt index 55a89ca..f2dd08d 100644 --- a/src/main/kotlin/at/dokkae/homepage/Homepage.kt +++ b/src/main/kotlin/at/dokkae/homepage/Homepage.kt @@ -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() - val subscribers = CopyOnWriteArrayList() - 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() + 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) } } ) diff --git a/src/main/kotlin/at/dokkae/homepage/repository/MessageRepository.kt b/src/main/kotlin/at/dokkae/homepage/repository/MessageRepository.kt new file mode 100644 index 0000000..4f99b94 --- /dev/null +++ b/src/main/kotlin/at/dokkae/homepage/repository/MessageRepository.kt @@ -0,0 +1,8 @@ +package at.dokkae.homepage.repository + +import at.dokkae.homepage.Message + +interface MessageRepository { + fun save(message: Message): Message + fun findAll(): List +} \ No newline at end of file diff --git a/src/main/kotlin/at/dokkae/homepage/repository/impls/JooqMessageRepository.kt b/src/main/kotlin/at/dokkae/homepage/repository/impls/JooqMessageRepository.kt new file mode 100644 index 0000000..a049291 --- /dev/null +++ b/src/main/kotlin/at/dokkae/homepage/repository/impls/JooqMessageRepository.kt @@ -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 = 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(), + ) +} \ No newline at end of file diff --git a/src/main/resources/db/migration/V001__add_update_and_create_timestamp_triggers.sql b/src/main/resources/db/migration/V001__add_update_and_create_timestamp_triggers.sql new file mode 100644 index 0000000..c59f21d --- /dev/null +++ b/src/main/resources/db/migration/V001__add_update_and_create_timestamp_triggers.sql @@ -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; \ No newline at end of file diff --git a/src/main/resources/db/migration/V002__add_message_table.sql b/src/main/resources/db/migration/V002__add_message_table.sql new file mode 100644 index 0000000..950c6d6 --- /dev/null +++ b/src/main/resources/db/migration/V002__add_message_table.sql @@ -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(); \ No newline at end of file