From 3f6d1daa16e7ed5a3b6c4d9c176bc186df22a79b Mon Sep 17 00:00:00 2001 From: William Nolin Date: Thu, 4 Mar 2021 00:48:54 -0500 Subject: [PATCH] =?UTF-8?q?Ajout=20des=20fonctionnalit=C3=A9es=20de=20base?= =?UTF-8?q?s=20de=20l'utilitaire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + build.gradle.kts | 30 ++- settings.gradle.kts | 2 +- .../databasemanager/Database.kt | 158 +++++++++++ .../databasemanager/DatabaseUpdater.kt | 116 ++++++++ .../databasemanager/Properties.kt | 117 ++++++++ .../databaseupdater/DatabaseUpdater.kt | 4 - src/main/resources/changelogs/changelog.1.xml | 250 ++++++++++++++++++ src/main/resources/changelogs/changelog.2.xml | 182 +++++++++++++ .../changelogs/changelog.master.1.xml | 8 + .../changelogs/changelog.master.2.xml | 9 + .../resources/changelogs/liquibase.properties | 5 + src/main/resources/logback.xml | 34 +++ .../databasemanager/DatabaseTest.kt | 186 +++++++++++++ .../databasemanager/PropertiesTest.kt | 154 +++++++++++ .../databasemanager/TestConfig.kt | 9 + 16 files changed, 1253 insertions(+), 12 deletions(-) create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/Database.kt create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/DatabaseUpdater.kt create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/Properties.kt delete mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/databaseupdater/DatabaseUpdater.kt create mode 100644 src/main/resources/changelogs/changelog.1.xml create mode 100644 src/main/resources/changelogs/changelog.2.xml create mode 100644 src/main/resources/changelogs/changelog.master.1.xml create mode 100644 src/main/resources/changelogs/changelog.master.2.xml create mode 100644 src/main/resources/changelogs/liquibase.properties create mode 100644 src/main/resources/logback.xml create mode 100644 src/test/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/DatabaseTest.kt create mode 100644 src/test/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/PropertiesTest.kt create mode 100644 src/test/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/TestConfig.kt diff --git a/.gitignore b/.gitignore index 74ecf93..ae6b357 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ gradlew* build/ logs/ dokka/ +workdir/* diff --git a/build.gradle.kts b/build.gradle.kts index f970c46..6ed2868 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,12 +1,13 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -plugins { - kotlin("jvm") version "1.4.30" -} - group = "dev.fyloz.colorrecipesexplorer" version = "1.0" +plugins { + kotlin("jvm") version "1.4.30" + id("com.github.johnrengelman.shadow") version "6.1.0" +} + repositories { mavenCentral() } @@ -14,9 +15,17 @@ repositories { dependencies { implementation("org.liquibase:liquibase-core:4.3.1") - testImplementation(kotlin("test-junit5")) - testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.6.0") + // Logging + implementation("io.github.microutils:kotlin-logging:1.12.0") + implementation("org.slf4j:slf4j-api:1.7.30") + implementation("ch.qos.logback:logback-classic:1.0.13") + + // Database drivers + implementation("mysql:mysql-connector-java:8.0.22") + + testImplementation("io.mockk:mockk:1.10.6") + testImplementation("io.kotest:kotest-runner-junit5:4.4.1") + implementation(kotlin("stdlib-jdk8")) } tasks.test { @@ -25,4 +34,11 @@ tasks.test { tasks.withType() { kotlinOptions.jvmTarget = "11" + kotlinOptions.useIR = true +} + +tasks.withType { + manifest { + attributes["Main-Class"] = "dev.fyloz.colorrecipesexplorer.databasemanager.DatabaseUpdaterKt" + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 474319a..10ee092 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,3 @@ -rootProject.name = "DatabaseUpdater" +rootProject.name = "DatabaseManager" diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/Database.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/Database.kt new file mode 100644 index 0000000..2f18b02 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/Database.kt @@ -0,0 +1,158 @@ +package dev.fyloz.colorrecipesexplorer.databasemanager + +import liquibase.Contexts +import liquibase.Liquibase +import liquibase.database.DatabaseFactory +import liquibase.database.jvm.JdbcConnection +import liquibase.resource.ClassLoaderResourceAccessor +import mu.KotlinLogging +import org.slf4j.Logger +import java.sql.Connection +import java.sql.DriverManager + +internal const val DATABASE_VERSION_NOT_INITIALIZED = 0 + +internal const val METADATA_TABLE_NAME = "updater_metadata" +internal const val COLUMN_KEY_NAME = "metadata_key" +internal const val COLUMN_VALUE_NAME = "metadata_value" + +internal const val ROW_VERSION_NAME = "version" + +internal const val QUERY_SELECT_VERSION = + "SELECT $COLUMN_VALUE_NAME FROM $METADATA_TABLE_NAME WHERE $COLUMN_KEY_NAME='$ROW_VERSION_NAME'" + + +/** [CreDatabase]'s context. Contains data needed by the utility like the [properties] and the [logger]. */ +data class DatabaseContext( + val properties: DatabaseUpdaterProperties, + val logger: Logger +) + +/** DSL for [DatabaseContext]. */ +fun databaseContext( + properties: DatabaseUpdaterProperties, + logger: Logger = KotlinLogging.logger { }, + op: DatabaseContext.() -> Unit = {} +) = DatabaseContext(properties, logger).apply(op) + + +class CreDatabase( + context: DatabaseContext +) { + private val properties = context.properties + private val logger = context.logger + + /** Connection to the database server */ + val serverConnection = tryConnect(properties.url) + + private var databaseConnectionOpen = false + + /** Connection to the database */ + val databaseConnection by lazy { + tryConnect(properties.url + properties.dbName) + .apply { databaseConnectionOpen = true } + } + + private var databaseOpen = false + val database by lazy { + DatabaseFactory.getInstance().findCorrectDatabaseImplementation(JdbcConnection(databaseConnection)) + .apply { databaseOpen = true } + } + + /** If the database exists */ + val exists: Boolean by lazy { + with(serverConnection.metaData.catalogs) { + while (next()) { + if (properties.dbName == getString(1)) { + return@with true + } + } + // If no catalog has the name of the targeted database, it does not exist + false + } + } + + /** If the updater's metadata table exists in the database */ + val metadataTableExists by lazy { + with( + serverConnection.metaData.getTables(properties.dbName, null, METADATA_TABLE_NAME, arrayOf("TABLE")) + ) { + // If there is no result, the table does not exist + this.next() + } + } + + /** The version of the database */ + val version by lazy { fetchVersion() } + + /** Updates the database to the target version. */ + fun update() { + val targetVersion = properties.targetVersion + + if (version >= targetVersion) { + logger.warn("Database version is superior or equals to the target version. No changes will be made.") + return + } + + logger.info("Updating database from version $version to $targetVersion...") + + val liquibase = + Liquibase("/changelogs/changelog.master.$targetVersion.xml", ClassLoaderResourceAccessor(), database) + liquibase.update(Contexts()) + logger.debug("Update to version $targetVersion completed!") + liquibase.close() + + logger.info("Update completed!") + } + + /** Gets the database version. */ + private fun fetchVersion(): Int { + fun getVersionFromDatabase(): Int { + val statement = databaseConnection.createStatement() + val results = statement.executeQuery(QUERY_SELECT_VERSION) + + if (!results.next()) { + logger.debug("The version row in the metadata table was not found, assuming version $DATABASE_VERSION_NOT_INITIALIZED") + return DATABASE_VERSION_NOT_INITIALIZED + } + + return results.getInt(COLUMN_VALUE_NAME) + } + + // Check if the database exists + if (!exists) { + throw CreDatabaseException.UnknownDatabase(properties.dbName) + } + // Check if the metadata table exists + if (!metadataTableExists) { + logger.debug("The metadata table was not found, assuming version $DATABASE_VERSION_NOT_INITIALIZED") + return DATABASE_VERSION_NOT_INITIALIZED + } + + return getVersionFromDatabase() + } + + /** Try to create a [Connection] with the given [url]. */ + private fun tryConnect(url: String): Connection = + try { + DriverManager.getConnection(url, properties.username, properties.password) + } catch (ex: Exception) { + throw CreDatabaseException.Connection(url, ex) + } + + /** Closes all connections to the database. */ + fun close() { + serverConnection.close() + if (databaseConnectionOpen) databaseConnection.close() + } +} + +sealed class CreDatabaseException(message: String, exception: Exception? = null) : Exception(message, exception) { + /** Thrown when an error occurs while creating a connection to the database at the given [url]. */ + class Connection(val url: String, exception: Exception) : + CreDatabaseException("Could not create a connection to the database at '$url'", exception) + + /** Thrown when the database with the given [name] cannot be found. */ + class UnknownDatabase(val name: String) : + CreDatabaseException("Unknown database '$name'") +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/DatabaseUpdater.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/DatabaseUpdater.kt new file mode 100644 index 0000000..8a540bb --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/DatabaseUpdater.kt @@ -0,0 +1,116 @@ +package dev.fyloz.colorrecipesexplorer.databasemanager + +import mu.KotlinLogging +import java.io.File +import kotlin.system.exitProcess + +private const val ACTION_ARG_INDEX = 0 +private const val PROPERTIES_PATH_ARG_INDEX = 1 + +private const val ACTION_VERSION_CHECK = "versionCheck" +private const val ACTION_UPDATE = "update" + +private var propertiesPath: String? = null +private var database: CreDatabase? = null +private val logger = KotlinLogging.logger { } + +// Lazy because propertiesPath is needed to load the properties file +private val properties by lazy { loadProperties(propertiesPath) } + +internal fun main(args: Array) { + logger.info("Starting Color Recipes Explorer database updater...") + + if (args.isEmpty() || args.size < 2) { + logger.error("No enough parameters were specified. ") + exitLogUsage() + } + + val action = args[ACTION_ARG_INDEX] + propertiesPath = args[PROPERTIES_PATH_ARG_INDEX] + + try { + database = CreDatabase(DatabaseContext(properties, logger)) + + // Run the specified action + when (action) { + ACTION_VERSION_CHECK -> executeVersionCheck(database!!) + ACTION_UPDATE -> executeUpdate(database!!) + else -> { + logger.error("Invalid action '$action'") + exitLogUsage() + } + } + } catch (ex: CreDatabaseException) { + if (ex is CreDatabaseException.Connection) { + logger.error(ex.message, ex) + } else { + logger.error(ex.message) + } + exitError() + } + + exit() +} + +private fun executeVersionCheck(database: CreDatabase) { + logger.info("Performing a version check...") + logger.info("Target version: ${properties.targetVersion}") + + val databaseVersion = database.version + logger.info("Database version: $databaseVersion") + + when (databaseVersion.compareTo(properties.targetVersion)) { + 1 -> logger.info("Database version is higher than target version!") + 0 -> logger.info("Database version matches the target version!") + -1 -> logger.info("Database version is lower than target version!") + } +} + +private fun executeUpdate(database: CreDatabase) { + logger.info("Performing an update...") + + database.update() +} + +/** Load properties from the given [path]. */ +private fun loadProperties(path: String?): DatabaseUpdaterProperties { + // Stop the utility if propertiesPath is null + if (path == null) { + logger.error("No path to a properties file was specified") + exitError() + } + + val file = File(path) + logger.debug("Loading properties from ${file.absolutePath}") + try { + val properties = databaseUpdaterProperties(file) + logger.debug("Loaded properties: $properties") + return properties + } catch (exception: InvalidDatabaseUpdaterPropertiesException) { + logger.error(exception.message) + exitError() + } +} + +/** Exits the utility. */ +private fun exit(): Nothing { + database?.close() + logger.info("Exiting Color Recipes Explorer database updater without error") + exitProcess(0) +} + +/** Exits the utility with an error code and a warning. */ +private fun exitError(): Nothing { + database?.close() + logger.warn("Exiting Color Recipes Explorer database updater with an error") + exitProcess(1) +} + +/** Exits the utility and logs how to use it. */ +private fun exitLogUsage(): Nothing { + logger.error("Usage: java -jar DatabaseUpdater.jar ") + logger.error("Actions: ") + logger.error("\t- versionCheck: Compare the database's version to the target version specified in the properties") + logger.error("\t- update: Update the database to the target version specified in the properties") + exitError() +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/Properties.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/Properties.kt new file mode 100644 index 0000000..93a1b22 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/Properties.kt @@ -0,0 +1,117 @@ +package dev.fyloz.colorrecipesexplorer.databasemanager + +import java.io.File +import java.io.FileInputStream +import java.util.* + +/** The latest version of the database supported by the utility. The [DatabaseUpdaterProperties] target version cannot be higher than this. */ +internal const val LATEST_DATABASE_VERSION = 2 + +/** The key of the target version property */ +internal const val PROPERTY_TARGET_VERSION = "database.target-version" + +/** The key of the database server's url property */ +internal const val PROPERTY_URL = "database.url" + +/** The key of the database's name property */ +internal const val PROPERTY_DB_NAME = "database.name" + +/** The key of the database's username property */ +internal const val PROPERTY_USERNAME = "database.username" + +/** The key of the database's password property */ +internal const val PROPERTY_PASSWORD = "database.password" + +/** The regular expression to validate a database url */ +internal val DATABASE_URL_REGEX = + Regex("^jdbc:\\w+://(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])(:\\d+)?/\$") + +/** The properties used by the utility */ +data class DatabaseUpdaterProperties( + /** The target version of the database */ + val targetVersion: Int, + /** The url of the database server */ + val url: String, + /** The name of the database */ + val dbName: String, + /** The username of the database's user */ + val username: String, + /** The password of the database's user */ + val password: String +) { + init { + if (targetVersion < 0 || targetVersion > LATEST_DATABASE_VERSION) + throw InvalidDatabaseUpdaterPropertiesException.InvalidTargetVersion(targetVersion) + + if (!url.matches(DATABASE_URL_REGEX)) + throw InvalidDatabaseUpdaterPropertiesException.InvalidUrl(url) + } +} + +/** DSL for creating an instance of [DatabaseUpdaterProperties]. */ +fun databaseUpdaterProperties( + targetVersion: Int = 0, + url: String = "jdbc:driver://host:1234/", + dbName: String = "database", + username: String = "user", + password: String = "pass", + op: DatabaseUpdaterProperties.() -> Unit = {} +) = DatabaseUpdaterProperties(targetVersion, url, dbName, username, password).apply(op) + +/** DSL for creating an instance of [DatabaseUpdaterProperties] from the given [properties]. */ +internal fun databaseUpdaterProperties( + properties: Properties, + op: DatabaseUpdaterProperties.() -> Unit = {} +) = with(properties) { + /** Gets a [property] from the given [properties]. Calls exitMissingProperty() when the value is null. */ + fun property(property: String): String = + getProperty(property) ?: throw InvalidDatabaseUpdaterPropertiesException.MissingProperty(property) + + databaseUpdaterProperties( + targetVersion = with(property(PROPERTY_TARGET_VERSION)) { + toIntOrNull() ?: throw InvalidDatabaseUpdaterPropertiesException.TargetVersionFormat(this) + }, + url = property(PROPERTY_URL), + dbName = property(PROPERTY_DB_NAME), + username = property(PROPERTY_USERNAME), + password = property(PROPERTY_PASSWORD), + op + ) +} + +/** DSL for creating an instance of [DatabaseUpdaterProperties] from the given [file]. */ +internal fun databaseUpdaterProperties( + file: File, + op: DatabaseUpdaterProperties.() -> Unit = {} +) = databaseUpdaterProperties( + properties = Properties().apply { + if (!file.exists() || !file.isFile) + throw InvalidDatabaseUpdaterPropertiesException.PropertiesNotFound(file.absolutePath) + + load(FileInputStream(file)) + }, + op +) + +/** An exception representing an invalid value in a [DatabaseUpdaterProperties] instance. */ +internal sealed class InvalidDatabaseUpdaterPropertiesException(message: String) : Exception(message) { + /** Thrown when the properties file does not exists. */ + class PropertiesNotFound(val path: String) : + InvalidDatabaseUpdaterPropertiesException("Could not find properties file at '$path'") + + /** Thrown when the target version property cannot be represented as an [Int]. */ + class TargetVersionFormat(val value: String) : + InvalidDatabaseUpdaterPropertiesException("Invalid database target version format for value '$value'") + + /** Thrown when the target version property is not in the range {0, [LATEST_DATABASE_VERSION]}. */ + class InvalidTargetVersion(val targetVersion: Int) : + InvalidDatabaseUpdaterPropertiesException("Invalid database target version '$targetVersion'. The database version must be between 0 and $LATEST_DATABASE_VERSION") + + /** Thrown when the url property does not matches the pattern 'jdbc:{driver}://{host}{:port?}/'. */ + class InvalidUrl(val url: String) : + InvalidDatabaseUpdaterPropertiesException("Invalid database url '$url'. The proper format is: jdbc:{driver}://{host}{:port?}/") + + /** Thrown when the given [property] is not defined. */ + class MissingProperty(val property: String) : + InvalidDatabaseUpdaterPropertiesException("The property $property is not defined") +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/databaseupdater/DatabaseUpdater.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/databaseupdater/DatabaseUpdater.kt deleted file mode 100644 index 2ee7c4f..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/databaseupdater/DatabaseUpdater.kt +++ /dev/null @@ -1,4 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.databaseupdater - - - diff --git a/src/main/resources/changelogs/changelog.1.xml b/src/main/resources/changelogs/changelog.1.xml new file mode 100644 index 0000000..d568351 --- /dev/null +++ b/src/main/resources/changelogs/changelog.1.xml @@ -0,0 +1,250 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + metadata_key='version' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/changelogs/changelog.2.xml b/src/main/resources/changelogs/changelog.2.xml new file mode 100644 index 0000000..8b7570c --- /dev/null +++ b/src/main/resources/changelogs/changelog.2.xml @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + metadata_key='version' + + + + + metadata_key='version' + + + + diff --git a/src/main/resources/changelogs/changelog.master.1.xml b/src/main/resources/changelogs/changelog.master.1.xml new file mode 100644 index 0000000..329b0e1 --- /dev/null +++ b/src/main/resources/changelogs/changelog.master.1.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/main/resources/changelogs/changelog.master.2.xml b/src/main/resources/changelogs/changelog.master.2.xml new file mode 100644 index 0000000..64ffa95 --- /dev/null +++ b/src/main/resources/changelogs/changelog.master.2.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/src/main/resources/changelogs/liquibase.properties b/src/main/resources/changelogs/liquibase.properties new file mode 100644 index 0000000..3a6fd7d --- /dev/null +++ b/src/main/resources/changelogs/liquibase.properties @@ -0,0 +1,5 @@ +url: jdbc:mysql://172.66.1.1/updater +username: root +password: pass +changeLogFile: changelog.master.xml +classpath: /home/william/.m2/repository/mysql/mysql-connector-java/8.0.22/mysql-connector-java-8.0.22.jar diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..db5d62c --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,34 @@ + + + + + + DEBUG + DENY + ACCEPT + + + %black(%d{ISO8601}) %highlight(%-5level) [%blue(%t)] %yellow(%C{36}): %msg%n%throwable + + + + + logs/latest.log + true + + + logs/du-%d{dd-MM-yyyy}.log.zip + 10 + 100MB + + + + %d{dd-MM-yyyy HH:mm:ss.SSS} [%thread] %-5level %logger.%M - %msg%n + + + + + + + + diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/DatabaseTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/DatabaseTest.kt new file mode 100644 index 0000000..6a15af4 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/DatabaseTest.kt @@ -0,0 +1,186 @@ +package dev.fyloz.colorrecipesexplorer.databasemanager + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.datatest.forAll +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.spyk +import org.slf4j.Logger +import java.sql.Connection +import java.sql.DriverManager +import java.sql.SQLException + +class DatabaseTest : ShouldSpec({ + val logger = mockk { + every { debug(any()) } answers {} + } + val properties = databaseUpdaterProperties() + val context = databaseContext(properties, logger) + + fun connectionMock(url: String) = every { + DriverManager.getConnection(url, properties.username, properties.password) + } + + fun serverConnectionMock() = connectionMock(properties.url) + fun databaseConnectionMock() = connectionMock(properties.url + properties.dbName) + + context("CreDatabase") { + mockkStatic(DriverManager::class.qualifiedName!!) { + fun withDatabase(test: CreDatabase.() -> Unit) { + serverConnectionMock() returns mockk() + CreDatabase(context).test() + } + + context("::new") { + should("throws Connection exception when the url is invalid") { + serverConnectionMock() throws SQLException() + + val exception = shouldThrow { CreDatabase(context) } + exception.url shouldBe properties.url + } + + should("initialize a connection to the database's server") { + val connection = mockk() + serverConnectionMock() returns connection + + val database = CreDatabase(context) + database.serverConnection shouldBe connection + } + } + + context("::databaseConnection") { + val databaseConnectionUrl = properties.url + properties.dbName + + should("throws Connection exception when the url is invalid") { + withDatabase { + databaseConnectionMock() throws SQLException() + + val exception = shouldThrow { databaseConnection } + exception.url shouldBe databaseConnectionUrl + } + } + + should("initialize a connection to the database") { + withDatabase { + val connection = mockk() + databaseConnectionMock() returns connection + + databaseConnection shouldBe connection + } + } + } + + context("::exists should be") { + fun withDatabaseCatalog(catalog: String?, test: CreDatabase.() -> Unit) { + withDatabase { + every { serverConnection.metaData } returns mockk { + every { catalogs } returns mockk { + every { next() } answers { catalog != null } andThen false + every { getString(1) } answers { catalog } andThen null + } + } + test() + } + } + + should("be true when database's catalogs contains database's name") { + withDatabaseCatalog(properties.dbName) { + exists shouldBe true + } + } + + should("be false when database's catalogs does not contain given database's name") { + withDatabaseCatalog("anotherName") { + exists shouldBe false + } + } + + should("be false when database's catalogs are empty") { + withDatabaseCatalog(null) { + exists shouldBe false + } + } + } + + context("::metadataTableExists should be") { + forAll( + "true when query returns a table" to true, + "false when query returns nothing" to false + ) { exists -> + withDatabase { + every { serverConnection.metaData } returns mockk { + every { + getTables(properties.dbName, null, METADATA_TABLE_NAME, arrayOf("TABLE")) + } returns mockk { + every { next() } answers { exists } andThen false + } + } + + metadataTableExists shouldBe exists + } + } + } + + context("::version") { + fun withMockedDatabase( + databaseExists: Boolean, + metadataTableExists: Boolean, + versionRowExists: Boolean, + test: CreDatabase.() -> Unit + ) { + withDatabase { + // Mocking lazy properties does not seems to work, so we need to mock everything + every { serverConnection.metaData } returns mockk { + every { catalogs } returns mockk { + every { next() } answers { databaseExists } andThen false + every { getString(1) } answers { properties.dbName } + } + every { + getTables(properties.dbName, null, METADATA_TABLE_NAME, arrayOf("TABLE")) + } returns mockk { + every { next() } returns metadataTableExists + } + } + databaseConnectionMock() returns mockk { + every { createStatement() } returns mockk { + every { executeQuery(QUERY_SELECT_VERSION) } returns mockk { + every { next() } answers { versionRowExists } andThen false + every { getInt(COLUMN_VALUE_NAME) } returns properties.targetVersion + } + } + } + test() + } + } + + should("equals database's version") { + withMockedDatabase(databaseExists = true, metadataTableExists = true, versionRowExists = true) { + version shouldBe properties.targetVersion + } + } + + should("throw UnknownDatabase exception when database doesn't exists") { + withMockedDatabase(databaseExists = false, metadataTableExists = true, versionRowExists = true) { + val exception = shouldThrow { version } + exception.name shouldBe properties.dbName + } + } + + should("be 0 when the metadata table does not exist") { + withMockedDatabase(databaseExists = true, metadataTableExists = false, versionRowExists = true) { + version shouldBe 0 + } + } + + should("be 0 when the version row does not exist") { + withMockedDatabase(databaseExists = true, metadataTableExists = true, versionRowExists = false) { + version shouldBe 0 + } + } + } + } + } +}) diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/PropertiesTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/PropertiesTest.kt new file mode 100644 index 0000000..2966a9f --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/PropertiesTest.kt @@ -0,0 +1,154 @@ +package dev.fyloz.colorrecipesexplorer.databasemanager + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.datatest.forAll +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.core.test.TestContext +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import java.io.File +import java.util.* + +class PropertiesTest : ShouldSpec({ + val targetVersion = 0 + val url = "jdbc:driver://url:3306/" + val dbName = "database" + val username = "user" + val password = "pass" + + context("database's url regular expression") { + context("matches valid urls") { + forAll( + "jdbc:mysql://host:9999/", + "jdbc:h2://host:9999/", + "jdbc:mysql://127.0.0.1:9999/", + "jdbc:mysql://host:9999/", + "jdbc:mysql://host/" + ) { url -> + url.matches(DATABASE_URL_REGEX) shouldBe true + } + } + + context("does not match invalid urls") { + forAll( + "mysql://host:9999/", + "jdbc://host:9999/", + "jdbc:mysql:host:9999/", + "jdbc:mysql/", + "host:9999", + "jdbc:mysql://host:9999/database", + "jdbc:mysql://host" + ) { url -> + url.matches(DATABASE_URL_REGEX) shouldBe false + } + } + + should("not matches empty url") { + "".matches(DATABASE_URL_REGEX) shouldBe false + } + } + + context("DatabaseUpdaterProperties::new throws") { + context("InvalidTargetVersion exception with invalid database target versions") { + forAll( + "negative" to -1, + "too large" to LATEST_DATABASE_VERSION + 1 + ) { targetVersion -> + val exception = + shouldThrow { + DatabaseUpdaterProperties(targetVersion, url, dbName, username, password) + } + exception.targetVersion shouldBe targetVersion + } + } + + context("InvalidUrl exception with invalid database urls") { + forAll( + "mysql://host:9999/", + "jdbc:mysql://:9999" + ) { url -> + val exception = + shouldThrow { + DatabaseUpdaterProperties(targetVersion, url, dbName, username, password) + } + exception.url shouldBe url + } + } + } + + context("DatabaseUpdaterProperties DSL from properties") { + val properties = mockk() + every { properties.getProperty(PROPERTY_URL) } returns url + every { properties.getProperty(PROPERTY_DB_NAME) } returns dbName + every { properties.getProperty(PROPERTY_USERNAME) } returns username + every { properties.getProperty(PROPERTY_PASSWORD) } returns password + + fun withTargetVersion(version: String?, test: () -> Unit) { + every { properties.getProperty(PROPERTY_TARGET_VERSION) } returns version + test() + } + + fun withTargetVersion(version: Int, test: () -> Unit) = withTargetVersion(version.toString(), test) + + should("throw TargetVersionFormat with non-numeric target version") { + val invalidTargetVersion = "zero" + withTargetVersion(invalidTargetVersion) { + val exception = shouldThrow { + databaseUpdaterProperties(properties) + } + exception.value shouldBe invalidTargetVersion + } + } + + should("return instance with correct values") { + withTargetVersion(targetVersion) { + with(databaseUpdaterProperties(properties)) { + this.targetVersion shouldBe targetVersion + this.url shouldBe url + this.dbName shouldBe dbName + this.username shouldBe username + this.password shouldBe password + } + } + } + + should("throw MissingProperty when a property is not defined") { + withTargetVersion(null) { + val exception = shouldThrow { + databaseUpdaterProperties(properties) + } + exception.property shouldBe PROPERTY_TARGET_VERSION + } + } + + afterEach { + clearMocks(properties) + } + } + + context("DatabaseUpdaterProperties DSL from file") { + val file = mockk() + val fileAbsolutePath = "/config/du.properties" + every { file.absolutePath } returns fileAbsolutePath + + should("throw PropertiesNotFound when the file does not exists") { + every { file.exists() } returns false + every { file.isFile } returns true + val exception = shouldThrow { + databaseUpdaterProperties(file) + } + exception.path shouldBe fileAbsolutePath + } + + should("throw PropertiesNotFound when the file is not a file") { + every { file.exists() } returns true + every { file.isFile } returns false + val exception = shouldThrow { + databaseUpdaterProperties(file) + } + exception.path shouldBe fileAbsolutePath + } + } +}) diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/TestConfig.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/TestConfig.kt new file mode 100644 index 0000000..3c5dd12 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/databasemanager/TestConfig.kt @@ -0,0 +1,9 @@ +package dev.fyloz.colorrecipesexplorer.databasemanager + +import io.kotest.core.config.AbstractProjectConfig +import io.kotest.core.spec.IsolationMode +import io.kotest.core.test.AssertionMode + +object TestConfig : AbstractProjectConfig() { + override val assertionMode = AssertionMode.Warn +}