From: David ‘Bombe’ Roden Date: Sun, 6 Apr 2025 07:24:45 +0000 (+0200) Subject: 🗃️ Add JDBC-based preferences implementation X-Git-Url: https://git.pterodactylus.net/?a=commitdiff_plain;h=730c69a02e0fc5649dcf76c578415de703e026c4;p=Sone.git 🗃️ Add JDBC-based preferences implementation --- diff --git a/build.gradle b/build.gradle index 27a35a0..4e452a9 100644 --- a/build.gradle +++ b/build.gradle @@ -57,6 +57,7 @@ dependencies { implementation group: 'org.jsoup', name: 'jsoup', version: '1.18.1' implementation group: 'io.dropwizard.metrics', name: 'metrics-core', version: '4.2.27' implementation group: 'jakarta.activation', name: 'jakarta.activation-api', version: '2.1.3' + implementation group: 'com.h2database', name: 'h2', version: '1.4.200' implementation group: 'org.jooq', name: 'jooq', version: '3.14.16' testImplementation group: 'org.jetbrains.kotlin', name: 'kotlin-test-junit' diff --git a/src/main/kotlin/net/pterodactylus/sone/database/h2/JdbcPreferences.kt b/src/main/kotlin/net/pterodactylus/sone/database/h2/JdbcPreferences.kt new file mode 100644 index 0000000..ca5823a --- /dev/null +++ b/src/main/kotlin/net/pterodactylus/sone/database/h2/JdbcPreferences.kt @@ -0,0 +1,73 @@ +package net.pterodactylus.sone.database.h2 + +import jakarta.inject.Inject +import java.util.Locale +import javax.sql.DataSource +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty +import net.pterodactylus.sone.core.Preferences +import net.pterodactylus.sone.database.h2.jooq.tables.references.OPTIONS +import net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired +import org.jooq.SQLDialect +import org.jooq.impl.DSL + +class JdbcPreferences @Inject constructor(dataSource: DataSource, sqlDialect: SQLDialect) : Preferences { + + override var insertionDelay: Int? by intOption(dataSource, sqlDialect) + override var postsPerPage: Int? by intOption(dataSource, sqlDialect) + override var imagesPerPage: Int? by intOption(dataSource, sqlDialect) + override var charactersPerPost: Int? by intOption(dataSource, sqlDialect) + override var postCutOffLength: Int? by intOption(dataSource, sqlDialect) + override var requireFullAccess: Boolean? by booleanOption(dataSource, sqlDialect) + override var fcpInterfaceActive: Boolean? by booleanOption(dataSource, sqlDialect) + override var fcpFullAccessRequired: FullAccessRequired? by enumOption(dataSource, sqlDialect, FullAccessRequired.entries::get) + override var strictFiltering: Boolean? by booleanOption(dataSource, sqlDialect) + +} + +private val KProperty<*>.asOptionName: String get() = name.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + +private fun intOption(dataSource: DataSource, sqlDialect: SQLDialect) = object : ReadWriteProperty { + + override operator fun getValue(thisRef: Preferences, property: KProperty<*>): Int? = DSL.using(dataSource, sqlDialect) + .select(OPTIONS.VALUE).from(OPTIONS) + .where(OPTIONS.KEY.eq(property.asOptionName)) + .fetch().getValue(0, OPTIONS.VALUE) + + override fun setValue(thisRef: Preferences, property: KProperty<*>, value: Int?) = DSL.using(dataSource, sqlDialect) + .insertInto(OPTIONS, OPTIONS.KEY, OPTIONS.VALUE).values(property.asOptionName, value) + .onDuplicateKeyUpdate().set(OPTIONS.VALUE, value) + .execute().let { } + +} + +private fun > enumOption(dataSource: DataSource, sqlDialect: SQLDialect, toEnum: (Int) -> E) = object : ReadWriteProperty { + + override operator fun getValue(thisRef: Preferences, property: KProperty<*>): E? = DSL.using(dataSource, sqlDialect) + .select(OPTIONS.VALUE).from(OPTIONS) + .where(OPTIONS.KEY.eq(property.asOptionName)) + .fetch().getValue(0, OPTIONS.VALUE) + ?.let(toEnum) + + override fun setValue(thisRef: Preferences, property: KProperty<*>, value: E?) = DSL.using(dataSource, sqlDialect) + .insertInto(OPTIONS, OPTIONS.KEY, OPTIONS.VALUE).values(property.asOptionName, value?.ordinal) + .onDuplicateKeyUpdate().set(OPTIONS.VALUE, value?.ordinal) + .execute().let { } + +} + +private fun booleanOption(dataSource: DataSource, sqlDialect: SQLDialect) = object : ReadWriteProperty { + + override operator fun getValue(thisRef: Preferences, property: KProperty<*>): Boolean? = DSL.using(dataSource, sqlDialect) + .select(OPTIONS.VALUE).from(OPTIONS) + .where(OPTIONS.KEY.eq(property.asOptionName)) + .fetch().getValue(0, OPTIONS.VALUE)?.let { it != 0 } + + override fun setValue(thisRef: Preferences, property: KProperty<*>, value: Boolean?) = DSL.using(dataSource, sqlDialect) + .insertInto(OPTIONS, OPTIONS.KEY, OPTIONS.VALUE).values(property.asOptionName, value?.toInt()) + .onDuplicateKeyUpdate().set(OPTIONS.VALUE, value?.toInt()) + .execute().let { } + +} + +private fun Boolean.toInt() = if (this) 1 else 0 diff --git a/src/main/resources/net/pterodactylus/sone/database/migration/V202501202137__create_sone_schema.sql b/src/main/resources/net/pterodactylus/sone/database/migration/V202501202137__create_sone_schema.sql new file mode 100644 index 0000000..cde12da --- /dev/null +++ b/src/main/resources/net/pterodactylus/sone/database/migration/V202501202137__create_sone_schema.sql @@ -0,0 +1 @@ +CREATE SCHEMA sone; diff --git a/src/main/resources/net/pterodactylus/sone/database/migration/V202501221927__create_options_table.sql b/src/main/resources/net/pterodactylus/sone/database/migration/V202501221927__create_options_table.sql new file mode 100644 index 0000000..c9ad89a --- /dev/null +++ b/src/main/resources/net/pterodactylus/sone/database/migration/V202501221927__create_options_table.sql @@ -0,0 +1,19 @@ +SET SCHEMA sone; + +CREATE TABLE options ( + "key" VARCHAR(21) PRIMARY KEY, + "value" INTEGER NULL DEFAULT NULL +); + +INSERT INTO options ("key", "value") +VALUES + ('InsertionDelay', NULL), + ('PostsPerPage', NULL), + ('ImagesPerPage', NULL), + ('CharactersPerPost', NULL), + ('PostCutOffLength', NULL), + ('RequireFullAccess', NULL), + ('FcpInterfaceActive', NULL), + ('FcpFullAccessRequired', NULL), + ('StrictFiltering', NULL) +; diff --git a/src/test/kotlin/net/pterodactylus/sone/database/h2/JdbcPreferencesTest.kt b/src/test/kotlin/net/pterodactylus/sone/database/h2/JdbcPreferencesTest.kt new file mode 100644 index 0000000..3855ac0 --- /dev/null +++ b/src/test/kotlin/net/pterodactylus/sone/database/h2/JdbcPreferencesTest.kt @@ -0,0 +1,311 @@ +package net.pterodactylus.sone.database.h2 + +import com.google.inject.Guice.createInjector +import javax.sql.DataSource +import kotlin.test.BeforeTest +import kotlin.test.Test +import net.pterodactylus.sone.database.h2.jooq.tables.references.OPTIONS +import net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired.ALWAYS +import net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired.NO +import net.pterodactylus.sone.fcp.FcpInterface.FullAccessRequired.WRITING +import net.pterodactylus.sone.test.getInstance +import net.pterodactylus.sone.test.getMigrationSql +import net.pterodactylus.sone.test.isProvidedBy +import net.pterodactylus.sone.test.jOOQ +import net.pterodactylus.sone.test.optionsTableMigrations +import net.pterodactylus.sone.test.randomInMemoryDataSource +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.jooq.SQLDialect + +class JdbcPreferencesTest { + + @Test + fun `jdbc preferences can be created by guice`() { + val injector = createInjector( + DataSource::class.isProvidedBy(dataSource), + SQLDialect::class.isProvidedBy(SQLDialect.H2) + ) + injector.getInstance() + } + + @Test + fun `jdbc preferences can read current insertion delay`() { + verifyIntValueIsReadCorrectly("InsertionDelay", 58, jdbcPreferences::insertionDelay::get) + } + + @Test + fun `jdbc preferences returns null if insertion delay is null`() { + verifyIntValueIsReadCorrectly("InsertionDelay", null, jdbcPreferences::insertionDelay::get) + } + + @Test + fun `jdbc preferences can write insertion delay`() { + verifyIntValueIsWrittenCorrectly("InsertionDelay", jdbcPreferences::insertionDelay::set, 47) + } + + @Test + fun `jdbc preferences can write null for insertion delay`() { + verifyIntValueIsWrittenCorrectly("InsertionDelay", jdbcPreferences::insertionDelay::set, null) + } + + @Test + fun `jdbc preferences can read current posts per page`() { + verifyIntValueIsReadCorrectly("PostsPerPage", 17, jdbcPreferences::postsPerPage::get) + } + + @Test + fun `jdbc preferences returns null if posts per page is null`() { + verifyIntValueIsReadCorrectly("PostsPerPage", null, jdbcPreferences::postsPerPage::get) + } + + @Test + fun `jdbc preferences can write posts per page`() { + verifyIntValueIsWrittenCorrectly("PostsPerPage", jdbcPreferences::postsPerPage::set, 19) + } + + @Test + fun `jdbc preferences can write null for posts per page`() { + verifyIntValueIsWrittenCorrectly("PostsPerPage", jdbcPreferences::postsPerPage::set, null) + } + + @Test + fun `jdbc preferences can read current images per page`() { + verifyIntValueIsReadCorrectly("ImagesPerPage", 23, jdbcPreferences::imagesPerPage::get) + } + + @Test + fun `jdbc preferences returns null if images per page is null`() { + verifyIntValueIsReadCorrectly("ImagesPerPage", null, jdbcPreferences::imagesPerPage::get) + } + + @Test + fun `jdbc preferences can write images per page`() { + verifyIntValueIsWrittenCorrectly("ImagesPerPage", jdbcPreferences::imagesPerPage::set, 27) + } + + @Test + fun `jdbc preferences can write null for images per page`() { + verifyIntValueIsWrittenCorrectly("ImagesPerPage", jdbcPreferences::imagesPerPage::set, null) + } + + @Test + fun `jdbc preferences can read current characters per post`() { + verifyIntValueIsReadCorrectly("CharactersPerPost", 31, jdbcPreferences::charactersPerPost::get) + } + + @Test + fun `jdbc preferences returns null if characters per post is null`() { + verifyIntValueIsReadCorrectly("CharactersPerPost", null, jdbcPreferences::charactersPerPost::get) + } + + @Test + fun `jdbc preferences can write characters per post`() { + verifyIntValueIsWrittenCorrectly("CharactersPerPost", jdbcPreferences::charactersPerPost::set, 37) + } + + @Test + fun `jdbc preferences can write null for characters per post`() { + verifyIntValueIsWrittenCorrectly("CharactersPerPost", jdbcPreferences::charactersPerPost::set, null) + } + + @Test + fun `jdbc preferences can read current post cut-off length`() { + verifyIntValueIsReadCorrectly("PostCutOffLength", 41, jdbcPreferences::postCutOffLength::get) + } + + @Test + fun `jdbc preferences returns null if post cut-off length is null`() { + verifyIntValueIsReadCorrectly("PostCutOffLength", null, jdbcPreferences::postCutOffLength::get) + } + + @Test + fun `jdbc preferences can write post cut-off length`() { + verifyIntValueIsWrittenCorrectly("PostCutOffLength", jdbcPreferences::postCutOffLength::set, 47) + } + + @Test + fun `jdbc preferences can write null for post cut-off length`() { + verifyIntValueIsWrittenCorrectly("PostCutOffLength", jdbcPreferences::postCutOffLength::set, null) + } + + @Test + fun `jdbc preferences reads 0 for require full access flag as false`() { + verifyBooleanValueIsReadCorrectly("RequireFullAccess", 0, jdbcPreferences::requireFullAccess::get, false) + } + + @Test + fun `jdbc preferences reads 1 for require full access flag as true`() { + verifyBooleanValueIsReadCorrectly("RequireFullAccess", 1, jdbcPreferences::requireFullAccess::get, true) + } + + @Test + fun `jdbc preferences returns null if require full access is null`() { + verifyBooleanValueIsReadCorrectly("RequireFullAccess", null, jdbcPreferences::requireFullAccess::get, null) + } + + @Test + fun `jdbc preferences can write require full access flag when false`() { + verifyBooleanValueIsWrittenCorrectly("RequireFullAccess", false, jdbcPreferences::requireFullAccess::set, 0) + } + + @Test + fun `jdbc preferences can write require full access flag when true`() { + verifyBooleanValueIsWrittenCorrectly("RequireFullAccess", true, jdbcPreferences::requireFullAccess::set, 1) + } + + @Test + fun `jdbc preferences can write null for require full access`() { + verifyBooleanValueIsWrittenCorrectly("RequireFullAccess", null, jdbcPreferences::requireFullAccess::set, null) + } + + @Test + fun `jdbc preferences reads 0 for fcp interface active flag as false`() { + verifyBooleanValueIsReadCorrectly("FcpInterfaceActive", 0, jdbcPreferences::fcpInterfaceActive::get, false) + } + + @Test + fun `jdbc preferences reads 1 for fcp interface active flag as true`() { + verifyBooleanValueIsReadCorrectly("FcpInterfaceActive", 1, jdbcPreferences::fcpInterfaceActive::get, true) + } + + @Test + fun `jdbc preferences returns null if fcp interface active is null`() { + verifyBooleanValueIsReadCorrectly("FcpInterfaceActive", null, jdbcPreferences::fcpInterfaceActive::get, null) + } + + @Test + fun `jdbc preferences can write fcp interface active flag when false`() { + verifyBooleanValueIsWrittenCorrectly("FcpInterfaceActive", false, jdbcPreferences::fcpInterfaceActive::set, 0) + } + + @Test + fun `jdbc preferences can write fcp interface active flag when true`() { + verifyBooleanValueIsWrittenCorrectly("FcpInterfaceActive", true, jdbcPreferences::fcpInterfaceActive::set, 1) + } + + @Test + fun `jdbc preferences can write null for fcp interface active`() { + verifyBooleanValueIsWrittenCorrectly("FcpInterfaceActive", null, jdbcPreferences::fcpInterfaceActive::set, null) + } + + @Test + fun `jdbc preferences reads 0 for fcp full-access required as no`() { + verifyEnumValueIsReadCorrectly("FcpFullAccessRequired", 0, jdbcPreferences::fcpFullAccessRequired::get, NO) + } + + @Test + fun `jdbc preferences reads 1 for fcp full-access required as writing`() { + verifyEnumValueIsReadCorrectly("FcpFullAccessRequired", 1, jdbcPreferences::fcpFullAccessRequired::get, WRITING) + } + + @Test + fun `jdbc preferences reads 2 for fcp full-access required as always`() { + verifyEnumValueIsReadCorrectly("FcpFullAccessRequired", 2, jdbcPreferences::fcpFullAccessRequired::get, ALWAYS) + } + + @Test + fun `jdbc preferences returns null if fcp full-access required is null`() { + verifyEnumValueIsReadCorrectly("FcpFullAccessRequired", null, jdbcPreferences::fcpFullAccessRequired::get, null) + } + + @Test + fun `jdbc preferences writes no for fcp full-access requires as 0`() { + verifyEnumValueIsWrittenCorrectly("FcpFullAccessRequired", NO, jdbcPreferences::fcpFullAccessRequired::set, 0) + } + + @Test + fun `jdbc preferences writes writing for fcp full-access requires as 1`() { + verifyEnumValueIsWrittenCorrectly("FcpFullAccessRequired", WRITING, jdbcPreferences::fcpFullAccessRequired::set, 1) + } + + @Test + fun `jdbc preferences writes always for fcp full-access requires as 2`() { + verifyEnumValueIsWrittenCorrectly("FcpFullAccessRequired", ALWAYS, jdbcPreferences::fcpFullAccessRequired::set, 2) + } + + @Test + fun `jdbc preferences can write null for fcp full-access required`() { + verifyEnumValueIsWrittenCorrectly("FcpFullAccessRequired", null, jdbcPreferences::fcpFullAccessRequired::set, null) + } + + @Test + fun `jdbc preferences reads 0 for strict filtering flag as false`() { + verifyBooleanValueIsReadCorrectly("StrictFiltering", 0, jdbcPreferences::strictFiltering::get, false) + } + + @Test + fun `jdbc preferences reads 1 for strict filtering flag as true`() { + verifyBooleanValueIsReadCorrectly("StrictFiltering", 1, jdbcPreferences::strictFiltering::get, true) + } + + @Test + fun `jdbc preferences returns null if strict filtering is null`() { + verifyBooleanValueIsReadCorrectly("StrictFiltering", null, jdbcPreferences::strictFiltering::get, null) + } + + @Test + fun `jdbc preferences can write strict filtering flag when false`() { + verifyBooleanValueIsWrittenCorrectly("StrictFiltering", false, jdbcPreferences::strictFiltering::set, 0) + } + + @Test + fun `jdbc preferences can write strict filtering flag when true`() { + verifyBooleanValueIsWrittenCorrectly("StrictFiltering", true, jdbcPreferences::strictFiltering::set, 1) + } + + @Test + fun `jdbc preferences can write null for strict filtering`() { + verifyBooleanValueIsWrittenCorrectly("StrictFiltering", null, jdbcPreferences::strictFiltering::set, null) + } + + @BeforeTest + fun createOptionsTable() { + jOOQ.transaction { configuration -> + getMigrationSql(*optionsTableMigrations).forEach(configuration.dsl()::execute) + } + } + + private fun readConfigOption(key: String) = + jOOQ.select(OPTIONS.VALUE).from(OPTIONS).where(OPTIONS.KEY.eq(key)).fetchOne(OPTIONS.VALUE) + + private fun insertConfigOption(key: String, value: Int?) = + jOOQ.update(OPTIONS).set(OPTIONS.VALUE, value).where(OPTIONS.KEY.eq(key)).execute() + + private fun verifyIntValueIsReadCorrectly(propertyKey: String, intValue: Int?, getter: () -> Int?) { + insertConfigOption(propertyKey, intValue) + assertThat(getter(), equalTo(intValue)) + } + + private fun verifyIntValueIsWrittenCorrectly(propertyKey: String, setter: (Int?) -> Unit, intValue: Int?) { + setter(intValue) + assertThat(readConfigOption(propertyKey), equalTo(intValue)) + } + + private fun verifyBooleanValueIsReadCorrectly(propertyKey: String, databaseIntValue: Int?, getter: () -> Boolean?, expectedValue: Boolean?) { + insertConfigOption(propertyKey, databaseIntValue) + assertThat(getter(), equalTo(expectedValue)) + } + + private fun verifyBooleanValueIsWrittenCorrectly(propertyKey: String, booleanValue: Boolean?, setter: (Boolean?) -> Unit, databaseIntValue: Int?) { + setter(booleanValue) + assertThat(readConfigOption(propertyKey), equalTo(databaseIntValue)) + } + + @Suppress("SameParameterValue") + private fun verifyEnumValueIsReadCorrectly(propertyKey: String, databaseIntValue: Int?, getter: () -> E?, expectedValue: E?) { + insertConfigOption(propertyKey, databaseIntValue) + assertThat(getter(), equalTo(expectedValue)) + } + + @Suppress("SameParameterValue") + private fun verifyEnumValueIsWrittenCorrectly(propertyKey: String, enumValue: E?, setter: (E?) -> Unit, databaseIntValue: Int?) { + setter(enumValue) + assertThat(readConfigOption(propertyKey), equalTo(databaseIntValue)) + } + + private val dataSource = randomInMemoryDataSource + private val jOOQ = jOOQ(dataSource) + private val jdbcPreferences = JdbcPreferences(dataSource, SQLDialect.H2) + +} diff --git a/src/test/kotlin/net/pterodactylus/sone/test/Database.kt b/src/test/kotlin/net/pterodactylus/sone/test/Database.kt new file mode 100644 index 0000000..0b78234 --- /dev/null +++ b/src/test/kotlin/net/pterodactylus/sone/test/Database.kt @@ -0,0 +1,24 @@ +package net.pterodactylus.sone.test + +import javax.sql.DataSource +import org.h2.jdbcx.JdbcDataSource +import org.jooq.SQLDialect +import org.jooq.impl.DSL + +fun testDataSource(jdbcUrl: String): DataSource = + JdbcDataSource().apply { + setUrl(jdbcUrl) + } + +val inMemoryDataSource = testDataSource("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1") +val randomInMemoryDataSource get() = testDataSource("jdbc:h2:mem:${System.nanoTime()};DB_CLOSE_DELAY=-1") +fun jOOQ(dataSource: DataSource = inMemoryDataSource) = DSL.using(dataSource, SQLDialect.H2) + +fun getMigrationSql(vararg migrationNames: String) = + migrationNames + .map { "/net/pterodactylus/sone/database/migration/$it.sql" } + .mapNotNull { resource(it, object {}.javaClass) } + .flatMap { it.split(";") } + .filterNot(String::isBlank) + +val optionsTableMigrations = arrayOf("V202501202137__create_sone_schema", "V202501221927__create_options_table") diff --git a/src/test/kotlin/net/pterodactylus/sone/test/TestUtils.kt b/src/test/kotlin/net/pterodactylus/sone/test/TestUtils.kt index e49663f..2b9854d 100644 --- a/src/test/kotlin/net/pterodactylus/sone/test/TestUtils.kt +++ b/src/test/kotlin/net/pterodactylus/sone/test/TestUtils.kt @@ -1,6 +1,9 @@ package net.pterodactylus.sone.test import org.junit.rules.ExpectedException +import java.io.InputStreamReader +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets.UTF_8 import sun.misc.Unsafe inline fun setField(instance: O, name: String, value: Any?) { @@ -13,3 +16,9 @@ inline fun setField(instance: O, name: String, value: Any?) { } inline fun ExpectedException.expect() = expect(T::class.java) + +fun resource(name: String, resourceClass: Class<*>, encoding: Charset = UTF_8): String? = resourceClass.getResourceAsStream(name)?.use { inputStream -> + InputStreamReader(inputStream, encoding).use { inputStreamReader -> + inputStreamReader.readText() + } +}