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/build.gradle.kts b/build.gradle.kts index ef712f07..64655e2a 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,101 @@ 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 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() + 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 +115,42 @@ 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) + } + } + } + + afterEvaluate { + tasks.named("fixConveyorConfig").configure { + doLast { + val configFile = layout.projectDirectory.file("generated.conveyor.conf").asFile + if (configFile.exists()) { + reorderConveyorJarInputBlocks(configFile) + } + } + } + } + } } dependencies { 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/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 = { 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 +} 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 6e0b4ae3..99bf65ab 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -37,10 +37,7 @@ 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.mongodb) implementation(libs.sql.flyway) api(libs.exposed.core) @@ -76,6 +73,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) 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 1ce31756..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 { @@ -15,7 +15,8 @@ enum class DatabaseType { CockroachDB, ClickHouse, Redshift, - SqlServer + SqlServer, + MongoDB, } /** Short label for type picker (sidebar, chips). */ 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..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,6 +28,11 @@ enum class DatabaseConnectionRequiredField { fun ScreenStateSettings.SqlDatabaseScreenState.missingRequiredConnectionFields(): Set = when (databaseType) { + 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) @@ -49,6 +56,7 @@ fun ScreenStateSettings.SqlDatabaseScreenState.hasRequiredConnectionSettings(): fun ScreenStateSettings.SqlDatabaseScreenState.connectionPort(): Int = when (databaseType) { 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/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..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,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_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 eb11e8b1..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 @@ -21,6 +21,8 @@ object DatabaseConnectionValidator { when (databaseType) { 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 17dd4095..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 @@ -157,6 +157,7 @@ fun MainScreenSidebar( DatabaseType.ClickHouse -> "8123" DatabaseType.Redshift -> "5439" DatabaseType.SqlServer -> "1433" + DatabaseType.MongoDB -> "27017" DatabaseType.SQLite -> sqlScreenState.port } screenStateSettings.sqlScreenState.value = sqlScreenState.copy( 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..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 @@ -344,6 +344,14 @@ fun DatabaseScreen( 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(), @@ -398,13 +406,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 +453,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 = { @@ -1102,6 +1110,7 @@ fun DatabaseScreen( DatabaseType.ClickHouse -> "8123" DatabaseType.Redshift -> "5439" DatabaseType.SqlServer -> "1433" + DatabaseType.MongoDB -> "27017" DatabaseType.SQLite -> sqlScreenState.port } val updated = sqlScreenState.copy(databaseType = dbType, port = defaultPort) 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 }