From 8fc167c772d6acdfce7eff7e52be65b5d4639b7b Mon Sep 17 00:00:00 2001 From: StellaLupus Date: Tue, 19 May 2026 15:50:32 +0300 Subject: [PATCH 1/7] Fix SLF4J NOP logger when hive-jdbc standalone shadows slf4j-api 2.x. Prioritize slf4j-api and logback on JavaExec/Test classpaths and reorder jpackage app.classpath so Logback binds correctly at runtime. --- build.gradle.kts | 81 +++++++++++++++++++++++++++++++++++++++++ shared/build.gradle.kts | 10 +++-- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index ef712f07..b3d2d563 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,6 @@ +import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.testing.Test + plugins { alias(libs.plugins.kotlin.multiplatform).apply(false) alias(libs.plugins.kotlin.serialization).apply(false) @@ -8,6 +11,60 @@ plugins { alias(libs.plugins.conveyor).apply(false) } +/** + * hive-jdbc standalone embeds SLF4J 1.7.x; if its jar precedes slf4j-api 2.x on the classpath, + * LoggerFactory loads the shaded 1.x API and falls back to NOP (no StaticLoggerBinder). + */ +fun prioritizeSlf4jBindingsOnClasspath(files: Collection): List { + val (bindings, rest) = files.partition { file -> + val name = file.name + name.startsWith("slf4j-api-") || + name.startsWith("logback-classic-") || + name.startsWith("logback-core-") + } + val orderedBindings = bindings.sortedBy { file -> + when { + file.name.startsWith("slf4j-api-") -> 0 + file.name.startsWith("logback-classic-") -> 1 + file.name.startsWith("logback-core-") -> 2 + else -> 3 + } + } + return orderedBindings + rest +} + +fun slf4jBindingPriority(classpathEntry: String): Int? = when { + "slf4j-api-" in classpathEntry -> 0 + "logback-classic-" in classpathEntry -> 1 + "logback-core-" in classpathEntry -> 2 + else -> null +} + +/** Reorders app.classpath lines in jpackage-generated .cfg (alphabetical order puts hive before slf4j-api). */ +fun reorderJpackageAppClasspath(cfgFile: File) { + val lines = cfgFile.readLines() + if (lines.none { it.startsWith("app.classpath=") }) return + + val sortedClasspath = lines + .filter { it.startsWith("app.classpath=") } + .sortedWith(compareBy({ slf4jBindingPriority(it) ?: 3 }, { it })) + + val result = buildList(lines.size) { + var classpathWritten = false + for (line in lines) { + if (line.startsWith("app.classpath=")) { + if (!classpathWritten) { + addAll(sortedClasspath) + classpathWritten = true + } + } else { + add(line) + } + } + } + cfgFile.writeText(result.joinToString("\n", postfix = "\n")) +} + val dummyAttribute = Attribute.of("org.angryscan", String::class.java) group = "org.angryscan" @@ -17,7 +74,31 @@ subprojects { group = rootProject.group version = rootProject.version + tasks.withType().configureEach { + doFirst { + classpath = files(prioritizeSlf4jBindingsOnClasspath(classpath.files)) + } + } + + tasks.withType().configureEach { + doFirst { + classpath = files(prioritizeSlf4jBindingsOnClasspath(classpath.files)) + } + } + if (name == "desktop") { + tasks.matching { + it.name == "createDistributable" || it.name == "createReleaseDistributable" + }.configureEach { + doLast { + val appRoot = layout.buildDirectory.dir("compose/binaries/main/app").get().asFile + if (!appRoot.exists()) return@doLast + appRoot.walkTopDown().filter { it.isFile && it.extension == "cfg" }.forEach { cfg -> + reorderJpackageAppClasspath(cfg) + } + } + } + } } dependencies { diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 6e0b4ae3..cd0a3da5 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -37,10 +37,6 @@ kotlin { implementation(libs.sql.clickhouse) implementation(libs.sql.redshift) implementation(libs.sql.mssql) - // Uber-jar: thin hive-jdbc omits RPC/Thrift. Woodstox on classpath fixes - // META-INF/services XMLOutputFactory entries that reference com.ctc.wstx.*. - implementation("org.apache.hive:hive-jdbc:4.2.0:standalone@jar") - implementation("com.fasterxml.woodstox:woodstox-core:6.6.2") implementation(libs.sql.flyway) api(libs.exposed.core) @@ -76,6 +72,12 @@ kotlin { implementation(libs.logging.oshai) implementation(libs.logging.logback) + // Uber-jar: thin hive-jdbc omits RPC/Thrift. Woodstox on classpath fixes + // META-INF/services XMLOutputFactory entries that reference com.ctc.wstx.*. + // Declared after logging so slf4j-api/logback precede hive's embedded SLF4J 1.7 on the classpath. + implementation("com.fasterxml.woodstox:woodstox-core:6.6.2") + implementation("org.apache.hive:hive-jdbc:4.2.0:standalone@jar") + api(libs.koin.core) api(libs.koin.compose) api(libs.koin.compose.viewmodel) From b06d0b713c309b765976e883d0ce3c6996369769 Mon Sep 17 00:00:00 2001 From: StellaLupus Date: Tue, 19 May 2026 17:44:29 +0300 Subject: [PATCH 2/7] Add function to reorder jar paths in Conveyor config files Implement `reorderConveyorJarInputBlocks` to sort jar paths alphabetically within Conveyor `app.inputs` blocks. Update the `fixConveyorConfig` task to utilize this new function, ensuring proper ordering of dependencies in generated configuration files. --- build.gradle.kts | 50 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index b3d2d563..54fc36aa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -40,6 +40,47 @@ fun slf4jBindingPriority(classpathEntry: String): Int? = when { else -> null } +/** Reorders jar path lines inside Conveyor `app.inputs = [...]` blocks (plugin lists deps alphabetically). */ +fun reorderConveyorJarInputBlocks(cfgFile: File) { + val lines = cfgFile.readLines() + val result = ArrayList(lines.size) + var i = 0 + while (i < lines.size) { + val line = lines[i] + val trimmed = line.trim() + if (trimmed.endsWith("[") && trimmed.contains(".inputs")) { + result.add(line) + i++ + val jarLines = ArrayList() + while (i < lines.size && !lines[i].trim().startsWith("]")) { + val current = lines[i] + if (current.trim().endsWith(".jar")) { + jarLines.add(current) + } else { + result.add(current) + } + i++ + } + if (jarLines.isNotEmpty()) { + val indent = jarLines.first().takeWhile(Char::isWhitespace) + val ordered = jarLines + .map { it.trim() } + .sortedWith(compareBy({ slf4jBindingPriority(it) ?: 3 }, { it })) + .map { "$indent$it" } + result.addAll(ordered) + } + if (i < lines.size) { + result.add(lines[i]) + } + i++ + } else { + result.add(line) + i++ + } + } + cfgFile.writeText(result.joinToString("\n", postfix = "\n")) +} + /** Reorders app.classpath lines in jpackage-generated .cfg (alphabetical order puts hive before slf4j-api). */ fun reorderJpackageAppClasspath(cfgFile: File) { val lines = cfgFile.readLines() @@ -98,6 +139,15 @@ subprojects { } } } + + tasks.named("fixConveyorConfig").configure { + doLast { + val configFile = layout.projectDirectory.file("generated.conveyor.conf").asFile + if (configFile.exists()) { + reorderConveyorJarInputBlocks(configFile) + } + } + } } } From 0816e85c00cd2ba8d00f5616d65c8e694b2afbe7 Mon Sep 17 00:00:00 2001 From: StellaLupus Date: Tue, 19 May 2026 19:07:17 +0300 Subject: [PATCH 3/7] Refactor fixConveyorConfig task to execute after project evaluation Update the `fixConveyorConfig` task to run after project evaluation, ensuring that the configuration file is processed correctly when it exists. This change improves the task's reliability in handling generated Conveyor configuration files. --- build.gradle.kts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 54fc36aa..64655e2a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -140,11 +140,13 @@ subprojects { } } - tasks.named("fixConveyorConfig").configure { - doLast { - val configFile = layout.projectDirectory.file("generated.conveyor.conf").asFile - if (configFile.exists()) { - reorderConveyorJarInputBlocks(configFile) + afterEvaluate { + tasks.named("fixConveyorConfig").configure { + doLast { + val configFile = layout.projectDirectory.file("generated.conveyor.conf").asFile + if (configFile.exists()) { + reorderConveyorJarInputBlocks(configFile) + } } } } From 0af96821f2d4870a259c9921568eb25253f03761 Mon Sep 17 00:00:00 2001 From: StellaLupus Date: Tue, 19 May 2026 20:01:22 +0300 Subject: [PATCH 4/7] Update Conveyor version to 22.0 and implement retry logic for build process Replace the installation of Conveyor from version 18.0 to 22.0 in both nightly and release workflows. Enhance the build process by adding a retry mechanism for the `conveyUnixCI` command to handle potential timeouts, improving reliability. Additionally, update the Conveyor configuration to prioritize local mirrors for Debian distributions to mitigate timeout issues during GitHub Actions execution. --- .github/workflows/nightly.yml | 29 ++++++++++++++++++----------- .github/workflows/release.yml | 29 ++++++++++++++++++----------- desktop/conveyor.unix.conf | 7 +++++++ 3 files changed, 43 insertions(+), 22 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9f709d16..c33f42a9 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -73,15 +73,22 @@ jobs: - name: Install conveyor run: | - wget https://downloads.hydraulic.dev/conveyor/conveyor-18.0-linux-amd64.tar.gz - tar -xzf conveyor-18.0-linux-amd64.tar.gz - chmod +x conveyor-18.0/bin/conveyor - echo "$(pwd)/conveyor-18.0/bin" >> $GITHUB_PATH + wget https://downloads.hydraulic.dev/conveyor/conveyor-22.0-linux-amd64.tar.gz + tar -xzf conveyor-22.0-linux-amd64.tar.gz + chmod +x conveyor-22.0/bin/conveyor + echo "$(pwd)/conveyor-22.0/bin" >> $GITHUB_PATH - name: Build and push with conveyor - uses: coactions/setup-xvfb@v1 - with: - run: ./gradlew conveyUnixCI + run: | + for attempt in 1 2 3; do + echo "conveyUnixCI attempt ${attempt}/3" + ./gradlew conveyUnixCI && exit 0 + if [ "${attempt}" -lt 3 ]; then + echo "Conveyor failed (often Debian mirror timeout), retrying in 45s..." + sleep 45 + fi + done + exit 1 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} @@ -141,10 +148,10 @@ jobs: - name: Install conveyor run: | - wget https://downloads.hydraulic.dev/conveyor/conveyor-18.0-linux-amd64.tar.gz - tar -xzf conveyor-18.0-linux-amd64.tar.gz - chmod +x conveyor-18.0/bin/conveyor - echo "$(pwd)/conveyor-18.0/bin" >> $GITHUB_PATH + wget https://downloads.hydraulic.dev/conveyor/conveyor-22.0-linux-amd64.tar.gz + tar -xzf conveyor-22.0-linux-amd64.tar.gz + chmod +x conveyor-22.0/bin/conveyor + echo "$(pwd)/conveyor-22.0/bin" >> $GITHUB_PATH - name: Build and push with conveyor uses: coactions/setup-xvfb@v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30ae94ca..e484cb7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,15 +81,22 @@ jobs: - name: Install conveyor run: | - wget https://downloads.hydraulic.dev/conveyor/conveyor-18.0-linux-amd64.tar.gz - tar -xzf conveyor-18.0-linux-amd64.tar.gz - chmod +x conveyor-18.0/bin/conveyor - echo "$(pwd)/conveyor-18.0/bin" >> $GITHUB_PATH + wget https://downloads.hydraulic.dev/conveyor/conveyor-22.0-linux-amd64.tar.gz + tar -xzf conveyor-22.0-linux-amd64.tar.gz + chmod +x conveyor-22.0/bin/conveyor + echo "$(pwd)/conveyor-22.0/bin" >> $GITHUB_PATH - name: Build and push with conveyor - uses: coactions/setup-xvfb@v1 - with: - run: ./gradlew conveyUnixCI + run: | + for attempt in 1 2 3; do + echo "conveyUnixCI attempt ${attempt}/3" + ./gradlew conveyUnixCI && exit 0 + if [ "${attempt}" -lt 3 ]; then + echo "Conveyor failed (often Debian mirror timeout), retrying in 45s..." + sleep 45 + fi + done + exit 1 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} @@ -135,10 +142,10 @@ jobs: - name: Install conveyor run: | - wget https://downloads.hydraulic.dev/conveyor/conveyor-18.0-linux-amd64.tar.gz - tar -xzf conveyor-18.0-linux-amd64.tar.gz - chmod +x conveyor-18.0/bin/conveyor - echo "$(pwd)/conveyor-18.0/bin" >> $GITHUB_PATH + wget https://downloads.hydraulic.dev/conveyor/conveyor-22.0-linux-amd64.tar.gz + tar -xzf conveyor-22.0-linux-amd64.tar.gz + chmod +x conveyor-22.0/bin/conveyor + echo "$(pwd)/conveyor-22.0/bin" >> $GITHUB_PATH - name: Build and push with conveyor uses: coactions/setup-xvfb@v1 diff --git a/desktop/conveyor.unix.conf b/desktop/conveyor.unix.conf index 88354f0b..dac92323 100644 --- a/desktop/conveyor.unix.conf +++ b/desktop/conveyor.unix.conf @@ -7,6 +7,13 @@ app { mac.aarch64 ] + # Default archive.ubuntu.com often times out on GitHub Actions; use runner-local mirror first. + linux.debian.distribution.mirrors = [ + "http://azure.archive.ubuntu.com/ubuntu/", + "http://archive.ubuntu.com/ubuntu/", + "http://mirrors.edge.kernel.org/ubuntu/", + ] + build.gradle { env = { From e33b1a8cc30f312527ed279a07a372ce4c2c4c32 Mon Sep 17 00:00:00 2001 From: StellaLupus Date: Tue, 19 May 2026 20:46:22 +0300 Subject: [PATCH 5/7] Update fixConveyorConfig task to depend on assemble instead of build Modify the `fixConveyorConfig` task to depend on the `assemble` task, ensuring that the necessary components are built before configuration adjustments are made. Additionally, add a newline at the end of the MainKtTest file for consistency. --- desktop/build.gradle.kts | 2 +- desktop/src/desktopTest/kotlin/org/angryscan/app/MainKtTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 4bd338c1..f39ec11e 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -177,7 +177,7 @@ tasks.register("fixConveyorConfig") { tempFile.renameTo(inputFile) println("Removed android/ios entries from generated.conveyor.conf") } - dependsOn("build", "writeConveyorConfig") + dependsOn("assemble", "writeConveyorConfig") } tasks.register("printVersion") { diff --git a/desktop/src/desktopTest/kotlin/org/angryscan/app/MainKtTest.kt b/desktop/src/desktopTest/kotlin/org/angryscan/app/MainKtTest.kt index af7a02f9..6f58872b 100644 --- a/desktop/src/desktopTest/kotlin/org/angryscan/app/MainKtTest.kt +++ b/desktop/src/desktopTest/kotlin/org/angryscan/app/MainKtTest.kt @@ -130,4 +130,4 @@ internal class MainKtTest : KoinTest { } } } -} \ No newline at end of file +} From f422df99bccd0970ff566df3d3d659bb3827fc74 Mon Sep 17 00:00:00 2001 From: StellaLupus Date: Tue, 19 May 2026 21:58:03 +0300 Subject: [PATCH 6/7] Add MongoDB as a legacy database type and update related logic Introduce MongoDB as a legacy database type in the DatabaseType enum, ensuring it is excluded from UI pickers. Update the ScreenStateSettings to migrate MongoDB connections to PostgreSQL during restoration. Adjust validation and connection logic to accommodate MongoDB, including updates to the DatabaseScreen and MainScreenSidebar for proper handling and display. Ensure drawable resources are assigned for MongoDB and update connection port defaults accordingly. --- .../org/angryscan/app/common/DatabaseType.kt | 13 ++++++++++++- .../app/common/ScreenStateSettings.kt | 18 +++++++++++++++--- .../common/SqlDatabaseScreenStateConnection.kt | 4 ++-- .../org/angryscan/app/logging/LogLevel.kt | 11 ++++++++++- .../app/resources/DatabaseTypeDrawable.kt | 1 + .../connectors/DatabaseConnectionValidator.kt | 2 +- .../main/components/MainScreenSidebar.kt | 4 +++- .../screens/main/subscreens/DatabaseScreen.kt | 11 ++++++----- 8 files changed, 50 insertions(+), 14 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/angryscan/app/common/DatabaseType.kt b/shared/src/commonMain/kotlin/org/angryscan/app/common/DatabaseType.kt index 1ce31756..1bd47e03 100644 --- a/shared/src/commonMain/kotlin/org/angryscan/app/common/DatabaseType.kt +++ b/shared/src/commonMain/kotlin/org/angryscan/app/common/DatabaseType.kt @@ -15,12 +15,23 @@ enum class DatabaseType { CockroachDB, ClickHouse, Redshift, - SqlServer + SqlServer, + + /** + * Legacy value kept only for deserializing old [ScreenStateSettings] / saved connection JSON. + * It is migrated to [PostgreSQL] on load; do not show in UI pickers. + */ + MongoDB, } +/** Database types shown in UI chips and sidebar (excludes legacy-only values). */ +fun databaseTypesForPicker(): List = + DatabaseType.entries.filter { it != DatabaseType.MongoDB } + /** Short label for type picker (sidebar, chips). */ fun DatabaseType.typePickerLabel(): String = when (this) { DatabaseType.Redshift -> "Amazon Redshift" DatabaseType.SqlServer -> "Microsoft SQL Server" + DatabaseType.MongoDB -> "MongoDB (legacy)" else -> name } diff --git a/shared/src/commonMain/kotlin/org/angryscan/app/common/ScreenStateSettings.kt b/shared/src/commonMain/kotlin/org/angryscan/app/common/ScreenStateSettings.kt index 99ae1f58..d5d4e532 100644 --- a/shared/src/commonMain/kotlin/org/angryscan/app/common/ScreenStateSettings.kt +++ b/shared/src/commonMain/kotlin/org/angryscan/app/common/ScreenStateSettings.kt @@ -278,8 +278,12 @@ class ScreenStateSettings : KoinComponent { ) this.httpScreenState.fastScan = prop.httpScreenState.fastScan - // Restore SqlDatabase state (migration: old PostgresScreenState → SqlDatabaseScreenState, databaseType defaults to PostgreSQL) - this.sqlScreenState.value = prop.sqlScreenState.value.copy(password = "") + // Restore SqlDatabase state (migration: old PostgresScreenState → SqlDatabaseScreenState; legacy MongoDB → PostgreSQL) + var restoredSql = prop.sqlScreenState.value.copy(password = "") + if (restoredSql.databaseType == DatabaseType.MongoDB) { + restoredSql = restoredSql.copy(databaseType = DatabaseType.PostgreSQL) + } + this.sqlScreenState.value = restoredSql this.sqlScreenState.value.extensions.clear() this.sqlScreenState.value.extensions.addAll(prop.sqlScreenState.value.extensions) this.sqlScreenState.value.matchers.clear() @@ -290,7 +294,15 @@ class ScreenStateSettings : KoinComponent { ) this.sqlScreenState.value.fastScan = prop.sqlScreenState.value.fastScan this.sqlSavedConnections.clear() - this.sqlSavedConnections.addAll(prop.sqlSavedConnections) + this.sqlSavedConnections.addAll( + prop.sqlSavedConnections.map { conn -> + if (conn.databaseType == DatabaseType.MongoDB) { + conn.copy(databaseType = DatabaseType.PostgreSQL) + } else { + conn + } + } + ) this.scanProfiles.clear() this.scanProfiles.addAll( diff --git a/shared/src/commonMain/kotlin/org/angryscan/app/common/SqlDatabaseScreenStateConnection.kt b/shared/src/commonMain/kotlin/org/angryscan/app/common/SqlDatabaseScreenStateConnection.kt index db4f63fa..c095891b 100644 --- a/shared/src/commonMain/kotlin/org/angryscan/app/common/SqlDatabaseScreenStateConnection.kt +++ b/shared/src/commonMain/kotlin/org/angryscan/app/common/SqlDatabaseScreenStateConnection.kt @@ -26,7 +26,7 @@ enum class DatabaseConnectionRequiredField { fun ScreenStateSettings.SqlDatabaseScreenState.missingRequiredConnectionFields(): Set = when (databaseType) { - DatabaseType.PostgreSQL, DatabaseType.MySQL, DatabaseType.GreenPlum, DatabaseType.Hive, DatabaseType.CockroachDB, DatabaseType.ClickHouse, DatabaseType.Redshift, DatabaseType.SqlServer -> buildSet { + DatabaseType.PostgreSQL, DatabaseType.MySQL, DatabaseType.GreenPlum, DatabaseType.Hive, DatabaseType.CockroachDB, DatabaseType.ClickHouse, DatabaseType.Redshift, DatabaseType.SqlServer, DatabaseType.MongoDB -> buildSet { if (host.isBlank()) add(DatabaseConnectionRequiredField.HOST) if (port.toIntOrNull() == null) add(DatabaseConnectionRequiredField.PORT) if (database.isBlank()) add(DatabaseConnectionRequiredField.DATABASE) @@ -48,7 +48,7 @@ fun ScreenStateSettings.SqlDatabaseScreenState.hasRequiredConnectionSettings(): fun ScreenStateSettings.SqlDatabaseScreenState.connectionPort(): Int = when (databaseType) { - DatabaseType.PostgreSQL -> port.toIntOrNull() ?: DefaultPostgresPort + DatabaseType.PostgreSQL, DatabaseType.MongoDB -> port.toIntOrNull() ?: DefaultPostgresPort DatabaseType.MySQL -> port.toIntOrNull() ?: DefaultMySqlPort DatabaseType.GreenPlum -> port.toIntOrNull() ?: DefaultGreenPlumPort DatabaseType.Hive -> port.toIntOrNull() ?: DefaultHivePort diff --git a/shared/src/commonMain/kotlin/org/angryscan/app/logging/LogLevel.kt b/shared/src/commonMain/kotlin/org/angryscan/app/logging/LogLevel.kt index 9504b471..a6e74c31 100644 --- a/shared/src/commonMain/kotlin/org/angryscan/app/logging/LogLevel.kt +++ b/shared/src/commonMain/kotlin/org/angryscan/app/logging/LogLevel.kt @@ -6,9 +6,13 @@ import org.slf4j.LoggerFactory object LogLevel { + /** + * Sets Logback root level when Logback is the active SLF4J binding. + * If another binding or NOP is on the classpath (e.g. wrong jar order with hive-jdbc), this is a no-op. + */ fun setLoggingLevel(level: Level) { val root = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME) - applyLevelOrThrow(root, level) + applyLevelIfLogback(root, level) } internal fun applyLevelOrThrow(rootLogger: Any, level: Level): Boolean { @@ -19,4 +23,9 @@ object LogLevel { logbackLogger.level = level return true } + + private fun applyLevelIfLogback(rootLogger: Any, level: Level) { + val logbackLogger = rootLogger as? Logger ?: return + logbackLogger.level = level + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/angryscan/app/resources/DatabaseTypeDrawable.kt b/shared/src/commonMain/kotlin/org/angryscan/app/resources/DatabaseTypeDrawable.kt index 73246634..e29f58b8 100644 --- a/shared/src/commonMain/kotlin/org/angryscan/app/resources/DatabaseTypeDrawable.kt +++ b/shared/src/commonMain/kotlin/org/angryscan/app/resources/DatabaseTypeDrawable.kt @@ -14,4 +14,5 @@ fun DatabaseType.drawableResource(): DrawableResource = when (this) { DatabaseType.ClickHouse -> Res.drawable.db_clickhouse_logo DatabaseType.Redshift -> Res.drawable.db_redshift_logo DatabaseType.SqlServer -> Res.drawable.db_sqlserver_logo + DatabaseType.MongoDB -> Res.drawable.db_default_logo } diff --git a/shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/DatabaseConnectionValidator.kt b/shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/DatabaseConnectionValidator.kt index eb11e8b1..7457f472 100644 --- a/shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/DatabaseConnectionValidator.kt +++ b/shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/DatabaseConnectionValidator.kt @@ -19,7 +19,7 @@ object DatabaseConnectionValidator { filePath: String = "" ): DatabaseConnectionError? = withContext(Dispatchers.IO) { when (databaseType) { - DatabaseType.PostgreSQL -> + DatabaseType.PostgreSQL, DatabaseType.MongoDB -> PostgresConnectionValidator.validate(host, port, database, user, password) DatabaseType.MySQL -> MySqlConnectionValidator.validate(host, port, database, user, password) diff --git a/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/components/MainScreenSidebar.kt b/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/components/MainScreenSidebar.kt index 17dd4095..fa7de37f 100644 --- a/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/components/MainScreenSidebar.kt +++ b/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/components/MainScreenSidebar.kt @@ -38,6 +38,7 @@ import androidx.navigation.NavController import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState import org.angryscan.app.common.DatabaseType +import org.angryscan.app.common.databaseTypesForPicker import org.angryscan.app.common.typePickerLabel import org.angryscan.app.common.ScreenStateSettings import org.angryscan.app.resources.drawableResource @@ -142,7 +143,7 @@ fun MainScreenSidebar( modifier = Modifier.padding(start = DB_TYPE_INDENT, top = 4.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { - DatabaseType.entries.forEach { dbType -> + databaseTypesForPicker().forEach { dbType -> SidebarDbTypeItem( label = dbType.typePickerLabel(), iconDrawable = dbType.drawableResource(), @@ -158,6 +159,7 @@ fun MainScreenSidebar( DatabaseType.Redshift -> "5439" DatabaseType.SqlServer -> "1433" DatabaseType.SQLite -> sqlScreenState.port + DatabaseType.MongoDB -> "5432" } screenStateSettings.sqlScreenState.value = sqlScreenState.copy( databaseType = dbType, diff --git a/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/subscreens/DatabaseScreen.kt b/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/subscreens/DatabaseScreen.kt index 67d84fe3..aa99f883 100644 --- a/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/subscreens/DatabaseScreen.kt +++ b/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/subscreens/DatabaseScreen.kt @@ -337,7 +337,7 @@ fun DatabaseScreen( return@launch } val connector = when (sqlScreenState.databaseType) { - DatabaseType.PostgreSQL -> ConnectorPostgres( + DatabaseType.PostgreSQL, DatabaseType.MongoDB -> ConnectorPostgres( host = sqlScreenState.host, port = sqlScreenState.connectionPort(), database = sqlScreenState.database, @@ -398,13 +398,13 @@ fun DatabaseScreen( ) } val taskName = when (sqlScreenState.databaseType) { - DatabaseType.PostgreSQL, DatabaseType.MySQL, DatabaseType.GreenPlum, DatabaseType.Hive, DatabaseType.CockroachDB, DatabaseType.ClickHouse, DatabaseType.Redshift, DatabaseType.SqlServer -> + DatabaseType.PostgreSQL, DatabaseType.MySQL, DatabaseType.GreenPlum, DatabaseType.Hive, DatabaseType.CockroachDB, DatabaseType.ClickHouse, DatabaseType.Redshift, DatabaseType.SqlServer, DatabaseType.MongoDB -> "${sqlScreenState.host}:${sqlScreenState.connectionPort()}/${sqlScreenState.database}" + if (sqlScreenState.schema.isNotEmpty()) " schema: ${sqlScreenState.schema}" else "" DatabaseType.SQLite -> sqlScreenState.filePath } val path = when (sqlScreenState.databaseType) { - DatabaseType.PostgreSQL, DatabaseType.MySQL, DatabaseType.GreenPlum, DatabaseType.Hive, DatabaseType.CockroachDB, DatabaseType.ClickHouse, DatabaseType.Redshift, DatabaseType.SqlServer -> sqlScreenState.schema + DatabaseType.PostgreSQL, DatabaseType.MySQL, DatabaseType.GreenPlum, DatabaseType.Hive, DatabaseType.CockroachDB, DatabaseType.ClickHouse, DatabaseType.Redshift, DatabaseType.SqlServer, DatabaseType.MongoDB -> sqlScreenState.schema DatabaseType.SQLite -> "" } val task = scanService.createTask( @@ -445,7 +445,7 @@ fun DatabaseScreen( horizontalArrangement = Arrangement.spacedBy(sourceTokens.inlineControlGap) ) { when (sqlScreenState.databaseType) { - DatabaseType.PostgreSQL, DatabaseType.MySQL, DatabaseType.GreenPlum, DatabaseType.Hive, DatabaseType.CockroachDB, DatabaseType.ClickHouse, DatabaseType.Redshift, DatabaseType.SqlServer -> { + DatabaseType.PostgreSQL, DatabaseType.MySQL, DatabaseType.GreenPlum, DatabaseType.Hive, DatabaseType.CockroachDB, DatabaseType.ClickHouse, DatabaseType.Redshift, DatabaseType.SqlServer, DatabaseType.MongoDB -> { OutlinedTextField( value = sqlScreenState.host, onValueChange = { @@ -1074,7 +1074,7 @@ fun DatabaseScreen( return (6f + labelLen).coerceAtLeast(8f) } - val chipTypes = DatabaseType.entries + val chipTypes = databaseTypesForPicker() Row( modifier = Modifier .fillMaxWidth() @@ -1103,6 +1103,7 @@ fun DatabaseScreen( DatabaseType.Redshift -> "5439" DatabaseType.SqlServer -> "1433" DatabaseType.SQLite -> sqlScreenState.port + DatabaseType.MongoDB -> "5432" } val updated = sqlScreenState.copy(databaseType = dbType, port = defaultPort) sqlScreenState = updated From 6a26f3d62e39ee0a0b25a839b4a0b0603a000743 Mon Sep 17 00:00:00 2001 From: StellaLupus Date: Tue, 19 May 2026 22:12:11 +0300 Subject: [PATCH 7/7] Add MongoDB support and update related components Introduce MongoDB as a supported database type in the application. Update the DatabaseType enum to include MongoDB, and modify connection logic across various components, including ScreenStateSettings, DatabaseScreen, and MainScreen. Ensure proper handling of MongoDB connections, including validation and serialization. Update drawable resources and default connection ports accordingly. Add tests to verify MongoDB functionality and integration. --- gradle/libs.versions.toml | 1 + shared/build.gradle.kts | 1 + .../drawable/db-mongodb-logo.svg | 1 + .../org/angryscan/app/common/DatabaseType.kt | 12 +- .../app/common/ScreenStateSettings.kt | 18 +-- .../SqlDatabaseScreenStateConnection.kt | 14 +- .../app/resources/DatabaseTypeDrawable.kt | 2 +- .../common/connectors/ConnectorMongoDB.kt | 132 ++++++++++++++++++ .../connectors/DatabaseConnectionValidator.kt | 4 +- .../connectors/MongoConnectionValidator.kt | 56 ++++++++ .../common/connectors/MongoDriverSupport.kt | 28 ++++ .../PolymorphicSerializationModule.kt | 1 + .../SqlDatabaseScreenStateConnectionTest.kt | 15 ++ .../searcher/ConnectorSerializationTest.kt | 33 +++++ .../app/ui/windows/screens/main/MainScreen.kt | 10 ++ .../main/components/MainScreenSidebar.kt | 5 +- .../screens/main/subscreens/DatabaseScreen.kt | 14 +- .../screens/scans/components/ScanTaskCard.kt | 1 + 18 files changed, 311 insertions(+), 37 deletions(-) create mode 100644 shared/src/commonMain/composeResources/drawable/db-mongodb-logo.svg create mode 100644 shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/ConnectorMongoDB.kt create mode 100644 shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/MongoConnectionValidator.kt create mode 100644 shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/MongoDriverSupport.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8f166e40..0a01acb9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -63,6 +63,7 @@ sql-mysql = { module = "com.mysql:mysql-connector-j", version = "9.1.0" } sql-clickhouse = { module = "com.clickhouse:clickhouse-jdbc", version = "0.9.8" } sql-redshift = { module = "com.amazon.redshift:redshift-jdbc42", version = "2.2.5" } sql-mssql = { module = "com.microsoft.sqlserver:mssql-jdbc", version = "12.10.0.jre11" } +sql-mongodb = { module = "org.mongodb:mongodb-driver-sync", version = "5.2.1" } sql-flyway = { module = "org.flywaydb:flyway-core", version.ref = "flyway" } ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index cd0a3da5..99bf65ab 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -37,6 +37,7 @@ kotlin { implementation(libs.sql.clickhouse) implementation(libs.sql.redshift) implementation(libs.sql.mssql) + implementation(libs.sql.mongodb) implementation(libs.sql.flyway) api(libs.exposed.core) diff --git a/shared/src/commonMain/composeResources/drawable/db-mongodb-logo.svg b/shared/src/commonMain/composeResources/drawable/db-mongodb-logo.svg new file mode 100644 index 00000000..54403d52 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/db-mongodb-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/angryscan/app/common/DatabaseType.kt b/shared/src/commonMain/kotlin/org/angryscan/app/common/DatabaseType.kt index 1bd47e03..4d0a6967 100644 --- a/shared/src/commonMain/kotlin/org/angryscan/app/common/DatabaseType.kt +++ b/shared/src/commonMain/kotlin/org/angryscan/app/common/DatabaseType.kt @@ -3,7 +3,7 @@ package org.angryscan.app.common import kotlinx.serialization.Serializable /** - * Supported SQL database types for scanning. + * Supported database types for scanning (relational SQL engines and MongoDB). */ @Serializable enum class DatabaseType { @@ -16,22 +16,12 @@ enum class DatabaseType { ClickHouse, Redshift, SqlServer, - - /** - * Legacy value kept only for deserializing old [ScreenStateSettings] / saved connection JSON. - * It is migrated to [PostgreSQL] on load; do not show in UI pickers. - */ MongoDB, } -/** Database types shown in UI chips and sidebar (excludes legacy-only values). */ -fun databaseTypesForPicker(): List = - DatabaseType.entries.filter { it != DatabaseType.MongoDB } - /** Short label for type picker (sidebar, chips). */ fun DatabaseType.typePickerLabel(): String = when (this) { DatabaseType.Redshift -> "Amazon Redshift" DatabaseType.SqlServer -> "Microsoft SQL Server" - DatabaseType.MongoDB -> "MongoDB (legacy)" else -> name } diff --git a/shared/src/commonMain/kotlin/org/angryscan/app/common/ScreenStateSettings.kt b/shared/src/commonMain/kotlin/org/angryscan/app/common/ScreenStateSettings.kt index d5d4e532..99ae1f58 100644 --- a/shared/src/commonMain/kotlin/org/angryscan/app/common/ScreenStateSettings.kt +++ b/shared/src/commonMain/kotlin/org/angryscan/app/common/ScreenStateSettings.kt @@ -278,12 +278,8 @@ class ScreenStateSettings : KoinComponent { ) this.httpScreenState.fastScan = prop.httpScreenState.fastScan - // Restore SqlDatabase state (migration: old PostgresScreenState → SqlDatabaseScreenState; legacy MongoDB → PostgreSQL) - var restoredSql = prop.sqlScreenState.value.copy(password = "") - if (restoredSql.databaseType == DatabaseType.MongoDB) { - restoredSql = restoredSql.copy(databaseType = DatabaseType.PostgreSQL) - } - this.sqlScreenState.value = restoredSql + // Restore SqlDatabase state (migration: old PostgresScreenState → SqlDatabaseScreenState, databaseType defaults to PostgreSQL) + this.sqlScreenState.value = prop.sqlScreenState.value.copy(password = "") this.sqlScreenState.value.extensions.clear() this.sqlScreenState.value.extensions.addAll(prop.sqlScreenState.value.extensions) this.sqlScreenState.value.matchers.clear() @@ -294,15 +290,7 @@ class ScreenStateSettings : KoinComponent { ) this.sqlScreenState.value.fastScan = prop.sqlScreenState.value.fastScan this.sqlSavedConnections.clear() - this.sqlSavedConnections.addAll( - prop.sqlSavedConnections.map { conn -> - if (conn.databaseType == DatabaseType.MongoDB) { - conn.copy(databaseType = DatabaseType.PostgreSQL) - } else { - conn - } - } - ) + this.sqlSavedConnections.addAll(prop.sqlSavedConnections) this.scanProfiles.clear() this.scanProfiles.addAll( diff --git a/shared/src/commonMain/kotlin/org/angryscan/app/common/SqlDatabaseScreenStateConnection.kt b/shared/src/commonMain/kotlin/org/angryscan/app/common/SqlDatabaseScreenStateConnection.kt index c095891b..e82017ae 100644 --- a/shared/src/commonMain/kotlin/org/angryscan/app/common/SqlDatabaseScreenStateConnection.kt +++ b/shared/src/commonMain/kotlin/org/angryscan/app/common/SqlDatabaseScreenStateConnection.kt @@ -8,10 +8,12 @@ private const val DefaultCockroachDBPort = 26257 private const val DefaultClickHousePort = 8123 private const val DefaultRedshiftPort = 5439 private const val DefaultSqlServerPort = 1433 +private const val DefaultMongoPort = 27_017 /** * Required connection fields for database types. - * - Server DBs (PostgreSQL, MySQL, GreenPlum, Hive, Redshift, Microsoft SQL Server, …): HOST, PORT, DATABASE, USER, PASSWORD + * - Server DBs (PostgreSQL, MySQL, …): HOST, PORT, DATABASE, USER, PASSWORD + * - MongoDB: HOST, PORT, DATABASE (user/password optional for unauthenticated deployments) * - SQLite: FILE_PATH only */ enum class DatabaseConnectionRequiredField { @@ -26,7 +28,12 @@ enum class DatabaseConnectionRequiredField { fun ScreenStateSettings.SqlDatabaseScreenState.missingRequiredConnectionFields(): Set = when (databaseType) { - DatabaseType.PostgreSQL, DatabaseType.MySQL, DatabaseType.GreenPlum, DatabaseType.Hive, DatabaseType.CockroachDB, DatabaseType.ClickHouse, DatabaseType.Redshift, DatabaseType.SqlServer, DatabaseType.MongoDB -> buildSet { + DatabaseType.MongoDB -> buildSet { + if (host.isBlank()) add(DatabaseConnectionRequiredField.HOST) + if (port.toIntOrNull() == null) add(DatabaseConnectionRequiredField.PORT) + if (database.isBlank()) add(DatabaseConnectionRequiredField.DATABASE) + } + DatabaseType.PostgreSQL, DatabaseType.MySQL, DatabaseType.GreenPlum, DatabaseType.Hive, DatabaseType.CockroachDB, DatabaseType.ClickHouse, DatabaseType.Redshift, DatabaseType.SqlServer -> buildSet { if (host.isBlank()) add(DatabaseConnectionRequiredField.HOST) if (port.toIntOrNull() == null) add(DatabaseConnectionRequiredField.PORT) if (database.isBlank()) add(DatabaseConnectionRequiredField.DATABASE) @@ -48,7 +55,8 @@ fun ScreenStateSettings.SqlDatabaseScreenState.hasRequiredConnectionSettings(): fun ScreenStateSettings.SqlDatabaseScreenState.connectionPort(): Int = when (databaseType) { - DatabaseType.PostgreSQL, DatabaseType.MongoDB -> port.toIntOrNull() ?: DefaultPostgresPort + DatabaseType.PostgreSQL -> port.toIntOrNull() ?: DefaultPostgresPort + DatabaseType.MongoDB -> port.toIntOrNull() ?: DefaultMongoPort DatabaseType.MySQL -> port.toIntOrNull() ?: DefaultMySqlPort DatabaseType.GreenPlum -> port.toIntOrNull() ?: DefaultGreenPlumPort DatabaseType.Hive -> port.toIntOrNull() ?: DefaultHivePort diff --git a/shared/src/commonMain/kotlin/org/angryscan/app/resources/DatabaseTypeDrawable.kt b/shared/src/commonMain/kotlin/org/angryscan/app/resources/DatabaseTypeDrawable.kt index e29f58b8..02b90fac 100644 --- a/shared/src/commonMain/kotlin/org/angryscan/app/resources/DatabaseTypeDrawable.kt +++ b/shared/src/commonMain/kotlin/org/angryscan/app/resources/DatabaseTypeDrawable.kt @@ -14,5 +14,5 @@ fun DatabaseType.drawableResource(): DrawableResource = when (this) { DatabaseType.ClickHouse -> Res.drawable.db_clickhouse_logo DatabaseType.Redshift -> Res.drawable.db_redshift_logo DatabaseType.SqlServer -> Res.drawable.db_sqlserver_logo - DatabaseType.MongoDB -> Res.drawable.db_default_logo + DatabaseType.MongoDB -> Res.drawable.db_mongodb_logo } diff --git a/shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/ConnectorMongoDB.kt b/shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/ConnectorMongoDB.kt new file mode 100644 index 00000000..5502911e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/ConnectorMongoDB.kt @@ -0,0 +1,132 @@ +package org.angryscan.app.scan.common.connectors + +import com.mongodb.client.MongoClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import org.angryscan.app.scan.common.ObjectCounter +import org.bson.Document +import org.bson.json.JsonMode +import org.bson.json.JsonWriterSettings + +/** + * MongoDB connector using the official sync driver (collections as scan targets; BSON documents as rows). + * [path] in [scanTables] is a semicolon-separated list of collection names; if empty, all non-system collections are listed. + * Table paths use `database.collection` (same pattern as other DB connectors). + */ +@Serializable +class ConnectorMongoDB( + val host: String, + val port: Int = 27_017, + val database: String, + val user: String, + val password: String, + val rowLimit: Int = 1000, +) : IDatabaseConnector { + + companion object { + private val jsonWriterSettings: JsonWriterSettings = + JsonWriterSettings.builder().outputMode(JsonMode.RELAXED).build() + } + + private fun openClient(): MongoClient = + createMongoClient(host, port, database, user, password) + + override suspend fun scanTables( + path: String, + tableSelected: (file: FoundedFile) -> Unit, + ): ObjectCounter = withContext(Dispatchers.IO) { + val counter = ObjectCounter() + openClient().use { client -> + val db = client.getDatabase(database) + val filters = path.split(";").map { it.trim() }.filter { it.isNotEmpty() }.toSet() + val names = db.listCollectionNames() + .asSequence() + .filter { !it.startsWith("system.") && (filters.isEmpty() || it in filters) } + .sorted() + .toList() + for (collName in names) { + val count = db.getCollection(collName).estimatedDocumentCount() + tableSelected( + FoundedFile( + path = "$database.$collName", + size = count, + ), + ) + counter.add(count) + } + } + counter + } + + override suspend fun getTableContent(tablePath: String): String = withContext(Dispatchers.IO) { + val collectionName = parseCollectionName(tablePath) + openClient().use { client -> + val coll = client.getDatabase(database).getCollection(collectionName) + buildString { + coll.find() + .limit(rowLimit) + .iterator() + .use { cursor -> + while (cursor.hasNext()) { + val doc = cursor.next() + append(doc.toJson(jsonWriterSettings)) + append('\n') + } + } + } + } + } + + override suspend fun getTableContentStructured(tablePath: String): List> = + withContext(Dispatchers.IO) { + val collectionName = parseCollectionName(tablePath) + openClient().use { client -> + val coll = client.getDatabase(database).getCollection(collectionName) + buildList { + coll.find() + .limit(rowLimit) + .iterator() + .use { cursor -> + while (cursor.hasNext()) { + add(documentToFlatRow(cursor.next())) + } + } + } + } + } + + private fun documentToFlatRow(doc: Document): Map = + doc.keys.associateWith { key -> bsonValueToCellString(doc[key]) } + + private fun bsonValueToCellString(value: Any?): String = + when (value) { + null -> "" + is String -> value + is Number, is Boolean -> value.toString() + is Document -> value.toJson(jsonWriterSettings) + is List<*> -> value.joinToString(separator = ",", prefix = "[", postfix = "]") { bsonValueToCellString(it) } + else -> try { + Document("v", value).toJson(jsonWriterSettings) + } catch (_: Exception) { + value.toString() + } + } + + private fun parseCollectionName(tablePath: String): String { + val separatorIndex = tablePath.indexOf('.') + require(separatorIndex > 0 && separatorIndex < tablePath.length - 1) { + "Table path must be database.collection format" + } + val dbPart = tablePath.substring(0, separatorIndex) + require(dbPart == database) { + "Table path database must match connection database" + } + return tablePath.substring(separatorIndex + 1) + } + + override fun logSummary(): String = + "Host: $host. Port: $port. Database: $database. Row limit: $rowLimit." + + override fun toString(): String = "ConnectorMongoDB" +} diff --git a/shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/DatabaseConnectionValidator.kt b/shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/DatabaseConnectionValidator.kt index 7457f472..e7041f99 100644 --- a/shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/DatabaseConnectionValidator.kt +++ b/shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/DatabaseConnectionValidator.kt @@ -19,8 +19,10 @@ object DatabaseConnectionValidator { filePath: String = "" ): DatabaseConnectionError? = withContext(Dispatchers.IO) { when (databaseType) { - DatabaseType.PostgreSQL, DatabaseType.MongoDB -> + DatabaseType.PostgreSQL -> PostgresConnectionValidator.validate(host, port, database, user, password) + DatabaseType.MongoDB -> + MongoConnectionValidator.validate(host, port, database, user, password) DatabaseType.MySQL -> MySqlConnectionValidator.validate(host, port, database, user, password) DatabaseType.GreenPlum -> diff --git a/shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/MongoConnectionValidator.kt b/shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/MongoConnectionValidator.kt new file mode 100644 index 00000000..a877a101 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/MongoConnectionValidator.kt @@ -0,0 +1,56 @@ +package org.angryscan.app.scan.common.connectors + +import com.mongodb.MongoException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bson.Document + +/** + * Validates MongoDB connection parameters before creating a scan task. + * Returns [DatabaseConnectionError] with field hint on failure, null on success. + */ +internal object MongoConnectionValidator { + + suspend fun validate( + host: String, + port: Int, + database: String, + user: String, + password: String, + ): DatabaseConnectionError? = withContext(Dispatchers.IO) { + try { + createMongoClient(host, port, database, user, password).use { client -> + client.getDatabase(database).runCommand(Document("ping", 1)) + } + null + } catch (e: MongoException) { + parseConnectionError(e) + } catch (e: Exception) { + DatabaseConnectionError( + field = DatabaseConnectionErrorField.HOST, + message = sanitizedConnectionErrorMessage(DatabaseConnectionErrorField.HOST), + ) + } + } + + internal fun parseConnectionError(e: MongoException): DatabaseConnectionError { + val msg = (e.message ?: "").lowercase() + val field = when { + msg.contains("authentication failed") || + msg.contains("bad auth") || + msg.contains("invalid credentials") -> + DatabaseConnectionErrorField.USER_PASSWORD + msg.contains("timed out") || + msg.contains("connection refused") || + msg.contains("couldn't connect") -> + DatabaseConnectionErrorField.HOST + msg.contains("database name") && msg.contains("invalid") -> + DatabaseConnectionErrorField.DATABASE + else -> DatabaseConnectionErrorField.HOST + } + return DatabaseConnectionError( + field = field, + message = sanitizedConnectionErrorMessage(field), + ) + } +} diff --git a/shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/MongoDriverSupport.kt b/shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/MongoDriverSupport.kt new file mode 100644 index 00000000..150132b4 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/angryscan/app/scan/common/connectors/MongoDriverSupport.kt @@ -0,0 +1,28 @@ +package org.angryscan.app.scan.common.connectors + +import com.mongodb.MongoClientSettings +import com.mongodb.MongoCredential +import com.mongodb.ServerAddress +import com.mongodb.client.MongoClient +import com.mongodb.client.MongoClients +import java.util.concurrent.TimeUnit + +/** Shared MongoDB sync driver client factory for [ConnectorMongoDB] and [MongoConnectionValidator]. */ +internal fun createMongoClient( + host: String, + port: Int, + database: String, + user: String, + password: String, +): MongoClient { + val builder = MongoClientSettings.builder() + .applyToClusterSettings { it.hosts(listOf(ServerAddress(host, port))) } + .applyToSocketSettings { + it.connectTimeout(15_000, TimeUnit.MILLISECONDS) + .readTimeout(120_000, TimeUnit.MILLISECONDS) + } + if (user.isNotBlank()) { + builder.credential(MongoCredential.createCredential(user, database, password.toCharArray())) + } + return MongoClients.create(builder.build()) +} diff --git a/shared/src/commonMain/kotlin/org/angryscan/app/serializers/PolymorphicSerializationModule.kt b/shared/src/commonMain/kotlin/org/angryscan/app/serializers/PolymorphicSerializationModule.kt index d6c93dc3..054a489a 100644 --- a/shared/src/commonMain/kotlin/org/angryscan/app/serializers/PolymorphicSerializationModule.kt +++ b/shared/src/commonMain/kotlin/org/angryscan/app/serializers/PolymorphicSerializationModule.kt @@ -108,6 +108,7 @@ val PolymorphicSerializationModule = SerializersModule { subclass(ConnectorClickHouse::class) subclass(ConnectorRedshift::class) subclass(ConnectorSqlServer::class) + subclass(ConnectorMongoDB::class) } polymorphic(IScanEngine::class) { subclass(KotlinEngine::class) diff --git a/shared/src/commonTest/kotlin/org/angryscan/app/common/SqlDatabaseScreenStateConnectionTest.kt b/shared/src/commonTest/kotlin/org/angryscan/app/common/SqlDatabaseScreenStateConnectionTest.kt index e8b03217..1224aac7 100644 --- a/shared/src/commonTest/kotlin/org/angryscan/app/common/SqlDatabaseScreenStateConnectionTest.kt +++ b/shared/src/commonTest/kotlin/org/angryscan/app/common/SqlDatabaseScreenStateConnectionTest.kt @@ -74,6 +74,21 @@ internal class SqlDatabaseScreenStateConnectionTest { assertTrue(state.hasRequiredConnectionSettings()) } + @Test + fun `MongoDB does not require user or password`() { + val state = ScreenStateSettings.SqlDatabaseScreenState( + databaseType = DatabaseType.MongoDB, + host = "localhost", + port = "27017", + database = "test", + user = "", + password = "", + ) + assertTrue(state.missingRequiredConnectionFields().isEmpty()) + assertTrue(state.hasRequiredConnectionSettings()) + assertEquals(27017, state.connectionPort()) + } + @Test fun `empty fields are not highlighted before validation click`() { val state = ScreenStateSettings.SqlDatabaseScreenState( diff --git a/shared/src/commonTest/kotlin/org/angryscan/app/searcher/ConnectorSerializationTest.kt b/shared/src/commonTest/kotlin/org/angryscan/app/searcher/ConnectorSerializationTest.kt index 9381ed6d..ea738469 100644 --- a/shared/src/commonTest/kotlin/org/angryscan/app/searcher/ConnectorSerializationTest.kt +++ b/shared/src/commonTest/kotlin/org/angryscan/app/searcher/ConnectorSerializationTest.kt @@ -160,6 +160,29 @@ internal class ConnectorSerializationTest { assertEquals(500, hive.rowLimit) } + @Test + fun `ConnectorMongoDB is serialized polymorphically`() { + val connector: IConnector = ConnectorMongoDB( + host = "localhost", + port = 27017, + database = "scanner", + user = "app", + password = "secret", + rowLimit = 800, + ) + + val serialized = PolymorphicFormatter.encodeToString(connector) + val decoded: IConnector = PolymorphicFormatter.decodeFromString(serialized) + val mongo = assertIs(decoded) + + assertEquals("localhost", mongo.host) + assertEquals(27017, mongo.port) + assertEquals("scanner", mongo.database) + assertEquals("app", mongo.user) + assertEquals("secret", mongo.password) + assertEquals(800, mongo.rowLimit) + } + @Test fun `connectors expose correct runtime contracts`() { assertIs(ConnectorFileShare()) @@ -219,5 +242,15 @@ internal class ConnectorSerializationTest { password = "secret", rowLimit = 1000 )) + assertIs( + ConnectorMongoDB( + host = "localhost", + port = 27017, + database = "test", + user = "root", + password = "secret", + rowLimit = 500, + ), + ) } } diff --git a/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/MainScreen.kt b/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/MainScreen.kt index 9a2fdcb8..935a50c3 100644 --- a/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/MainScreen.kt +++ b/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/MainScreen.kt @@ -303,6 +303,16 @@ fun MainScreen( password = connector.password, filePath = "" ) + is ConnectorMongoDB -> currentSql.copy( + databaseType = DatabaseType.MongoDB, + host = connector.host, + port = connector.port.toString(), + database = connector.database, + schema = replay.path, + user = connector.user, + password = connector.password, + filePath = "" + ) is ConnectorSqlite -> currentSql.copy( databaseType = DatabaseType.SQLite, filePath = connector.filePath, diff --git a/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/components/MainScreenSidebar.kt b/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/components/MainScreenSidebar.kt index fa7de37f..7836ffd6 100644 --- a/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/components/MainScreenSidebar.kt +++ b/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/components/MainScreenSidebar.kt @@ -38,7 +38,6 @@ import androidx.navigation.NavController import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState import org.angryscan.app.common.DatabaseType -import org.angryscan.app.common.databaseTypesForPicker import org.angryscan.app.common.typePickerLabel import org.angryscan.app.common.ScreenStateSettings import org.angryscan.app.resources.drawableResource @@ -143,7 +142,7 @@ fun MainScreenSidebar( modifier = Modifier.padding(start = DB_TYPE_INDENT, top = 4.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { - databaseTypesForPicker().forEach { dbType -> + DatabaseType.entries.forEach { dbType -> SidebarDbTypeItem( label = dbType.typePickerLabel(), iconDrawable = dbType.drawableResource(), @@ -158,8 +157,8 @@ fun MainScreenSidebar( DatabaseType.ClickHouse -> "8123" DatabaseType.Redshift -> "5439" DatabaseType.SqlServer -> "1433" + DatabaseType.MongoDB -> "27017" DatabaseType.SQLite -> sqlScreenState.port - DatabaseType.MongoDB -> "5432" } screenStateSettings.sqlScreenState.value = sqlScreenState.copy( databaseType = dbType, diff --git a/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/subscreens/DatabaseScreen.kt b/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/subscreens/DatabaseScreen.kt index aa99f883..07f11a52 100644 --- a/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/subscreens/DatabaseScreen.kt +++ b/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/main/subscreens/DatabaseScreen.kt @@ -337,13 +337,21 @@ fun DatabaseScreen( return@launch } val connector = when (sqlScreenState.databaseType) { - DatabaseType.PostgreSQL, DatabaseType.MongoDB -> ConnectorPostgres( + DatabaseType.PostgreSQL -> ConnectorPostgres( host = sqlScreenState.host, port = sqlScreenState.connectionPort(), database = sqlScreenState.database, user = sqlScreenState.user, password = sqlScreenState.password ) + DatabaseType.MongoDB -> ConnectorMongoDB( + host = sqlScreenState.host, + port = sqlScreenState.connectionPort(), + database = sqlScreenState.database, + user = sqlScreenState.user, + password = sqlScreenState.password, + rowLimit = sqlScreenState.rowLimit.toIntOrNull()?.takeIf { it > 0 } ?: 1000 + ) DatabaseType.MySQL -> ConnectorMySQL( host = sqlScreenState.host, port = sqlScreenState.connectionPort(), @@ -1074,7 +1082,7 @@ fun DatabaseScreen( return (6f + labelLen).coerceAtLeast(8f) } - val chipTypes = databaseTypesForPicker() + val chipTypes = DatabaseType.entries Row( modifier = Modifier .fillMaxWidth() @@ -1102,8 +1110,8 @@ fun DatabaseScreen( DatabaseType.ClickHouse -> "8123" DatabaseType.Redshift -> "5439" DatabaseType.SqlServer -> "1433" + DatabaseType.MongoDB -> "27017" DatabaseType.SQLite -> sqlScreenState.port - DatabaseType.MongoDB -> "5432" } val updated = sqlScreenState.copy(databaseType = dbType, port = defaultPort) sqlScreenState = updated diff --git a/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/scans/components/ScanTaskCard.kt b/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/scans/components/ScanTaskCard.kt index 05a5529e..b6fd73a1 100644 --- a/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/scans/components/ScanTaskCard.kt +++ b/shared/src/desktopMain/kotlin/org/angryscan/app/ui/windows/screens/scans/components/ScanTaskCard.kt @@ -273,6 +273,7 @@ fun ScanTaskCard( is ConnectorClickHouse -> "${connector.host}:${connector.port}/${connector.database}" is ConnectorRedshift -> "${connector.host}:${connector.port}/${connector.database}" is ConnectorSqlServer -> "${connector.host}:${connector.port}/${connector.database}" + is ConnectorMongoDB -> "${connector.host}:${connector.port}/${connector.database}" is ConnectorSqlite -> connector.filePath else -> null }