diff --git a/CHANGELOG.md b/CHANGELOG.md index a34d1c9fc..2fa360a4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- MongoDB filters and generated edit statements now quote numeric-looking values that would not round-trip, such as `.5`, `1.`, `+7`, `01`, and integers larger than 64 bits, instead of emitting invalid or lossy JSON. - Elasticsearch connections using Username & Password now show a Password field, so basic auth on a secured cluster can be set up. It was hidden before, leaving only the Username field. Update the Elasticsearch plugin to get the fix. (#1816) - Switching schemas on an Oracle connection no longer hangs on an infinite loading spinner. Oracle now switches by schema like BigQuery, the sidebar lists every schema with its tables loading on expand, Oracle queries respect the query timeout setting and reconnect automatically after a timeout, and a schema load that fails shows an error with a Retry button instead of spinning forever. Works with an already-installed Oracle plugin; updating the plugin adds the query timeout enforcement. (#1807) diff --git a/Plugins/MongoDBDriverPlugin/MongoDBJsonNumber.swift b/Plugins/MongoDBDriverPlugin/MongoDBJsonNumber.swift new file mode 100644 index 000000000..b96b5b684 --- /dev/null +++ b/Plugins/MongoDBDriverPlugin/MongoDBJsonNumber.swift @@ -0,0 +1,29 @@ +// +// MongoDBJsonNumber.swift +// MongoDBDriverPlugin +// + +import Foundation + +enum MongoDBJsonNumber { + private static let regex = try? NSRegularExpression( + pattern: #"^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$"# + ) + + static func isValid(_ value: String) -> Bool { + guard let regex else { return false } + let nsValue = value as NSString + let range = NSRange(location: 0, length: nsValue.length) + guard let match = regex.firstMatch(in: value, range: range), match.range == range else { + return false + } + if isPlainInteger(value) { + return Int64(value) != nil + } + return true + } + + private static func isPlainInteger(_ value: String) -> Bool { + !value.contains(".") && !value.contains("e") && !value.contains("E") + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift b/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift index 5bdfe4cc5..04eb34eff 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift @@ -179,8 +179,7 @@ struct MongoDBQueryBuilder { private func jsonValue(_ value: String) -> String { if value == "true" || value == "false" { return value } if value == "null" { return value } - if Int64(value) != nil { return value } - if Double(value) != nil, value.contains(".") { return value } + if MongoDBJsonNumber.isValid(value) { return value } return "\"\(Self.escapeJsonString(value))\"" } diff --git a/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift b/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift index a567494fa..55731bf25 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift @@ -169,7 +169,7 @@ struct MongoDBStatementGenerator { } if isObjectIdString(idValue) { idValues.append("{\"$oid\": \"\(idValue)\"}") - } else if Int64(idValue) != nil { + } else if MongoDBJsonNumber.isValid(idValue) { idValues.append(idValue) } else { idValues.append("\"\(escapeJsonString(idValue))\"") @@ -218,7 +218,7 @@ struct MongoDBStatementGenerator { if isObjectIdString(idValue) { return "{\"_id\": {\"$oid\": \"\(idValue)\"}}" } - if Int64(idValue) != nil { + if MongoDBJsonNumber.isValid(idValue) { return "{\"_id\": \(idValue)}" } return "{\"_id\": \"\(escapeJsonString(idValue))\"}" @@ -247,10 +247,7 @@ struct MongoDBStatementGenerator { if value == "null" { return "null" } - if Int64(value) != nil { - return value - } - if Double(value) != nil, value.contains(".") { + if MongoDBJsonNumber.isValid(value) { return value } // JSON object or array diff --git a/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift b/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift index 1f928df55..4173cde27 100644 --- a/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift +++ b/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift @@ -156,20 +156,26 @@ enum PasswordSourceResolver { let stdoutCollector = PipeDataCollector(maxBytes: maxOutputBytes) let stderrCollector = PipeDataCollector(maxBytes: maxOutputBytes) - stdoutPipe.fileHandleForReading.readabilityHandler = { handle in - let chunk = handle.availableData - guard !chunk.isEmpty else { return } - stdoutCollector.append(chunk) - if stdoutCollector.overflowed, process.isRunning { + try process.run() + + let drainGroup = DispatchGroup() + let drainQueue = DispatchQueue(label: "com.TablePro.PasswordSourceResolver.pipe-drain", attributes: .concurrent) + drainPipe( + stdoutPipe.fileHandleForReading, + into: stdoutCollector, + using: drainGroup, + queue: drainQueue + ) { + if process.isRunning { process.terminate() } } - stderrPipe.fileHandleForReading.readabilityHandler = { handle in - let chunk = handle.availableData - if !chunk.isEmpty { stderrCollector.append(chunk) } - } - - try process.run() + drainPipe( + stderrPipe.fileHandleForReading, + into: stderrCollector, + using: drainGroup, + queue: drainQueue + ) let didTimeout = AtomicFlag() let timeoutTask = Task.detached { @@ -182,14 +188,7 @@ enum PasswordSourceResolver { process.waitUntilExit() timeoutTask.cancel() - - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - - let remainingStdout = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - if !remainingStdout.isEmpty { stdoutCollector.append(remainingStdout) } - let remainingStderr = stderrPipe.fileHandleForReading.readDataToEndOfFile() - if !remainingStderr.isEmpty { stderrCollector.append(remainingStderr) } + drainGroup.wait() if stdoutCollector.overflowed { throw ResolutionError.outputTooLarge @@ -212,6 +211,27 @@ enum PasswordSourceResolver { return try nonEmpty(output.trimmingCharacters(in: .whitespacesAndNewlines)) } + private static func drainPipe( + _ handle: FileHandle, + into collector: PipeDataCollector, + using group: DispatchGroup, + queue: DispatchQueue, + onOverflow: (() -> Void)? = nil + ) { + group.enter() + queue.async { + defer { group.leave() } + while true { + let chunk = handle.readData(ofLength: 8_192) + guard !chunk.isEmpty else { return } + collector.append(chunk) + if collector.overflowed { + onOverflow?() + } + } + } + } + private static func augmentedEnvironment() -> [String: String] { var environment = ProcessInfo.processInfo.environment let toolPaths = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"] diff --git a/TableProTests/PluginTestSources/MongoDBJsonNumber.swift b/TableProTests/PluginTestSources/MongoDBJsonNumber.swift new file mode 120000 index 000000000..d4a78a31e --- /dev/null +++ b/TableProTests/PluginTestSources/MongoDBJsonNumber.swift @@ -0,0 +1 @@ +../../Plugins/MongoDBDriverPlugin/MongoDBJsonNumber.swift \ No newline at end of file diff --git a/TableProTests/Plugins/MongoDBQueryBuilderTests.swift b/TableProTests/Plugins/MongoDBQueryBuilderTests.swift index 4235af55a..8cd0a9ce5 100644 --- a/TableProTests/Plugins/MongoDBQueryBuilderTests.swift +++ b/TableProTests/Plugins/MongoDBQueryBuilderTests.swift @@ -6,8 +6,8 @@ // import Foundation -import Testing import TableProPluginKit +import Testing @Suite("MongoDB Query Builder") struct MongoDBQueryBuilderTests { @@ -366,6 +366,44 @@ struct MongoDBQueryBuilderTests { #expect(doc.contains("\"price\": 19.99")) } + @Test("Filter document emits JSON-valid scientific numbers") + func filterDocumentScientificNumber() { + let doc = builder.buildFilterDocument( + from: [(column: "score", op: "=", value: "1.5e-3")] + ) + let parsed = parseFilter(doc) + #expect(parsed?["score"] as? Double == 0.0015) + } + + @Test("Filter document quotes non-JSON numeric spellings") + func filterDocumentQuotesNonJsonNumericSpellings() { + let values = [".5", "1.", "+7", "01", "NaN", "Infinity"] + for value in values { + let doc = builder.buildFilterDocument( + from: [(column: "score", op: "=", value: value)] + ) + let parsed = parseFilter(doc) + #expect(parsed?["score"] as? String == value) + } + } + + @Test("Filter document quotes integers that overflow Int64 to preserve precision") + func filterDocumentQuotesInt64Overflow() { + let doc = builder.buildFilterDocument( + from: [(column: "code", op: "=", value: "12345678901234567890")] + ) + let parsed = parseFilter(doc) + #expect(parsed?["code"] as? String == "12345678901234567890") + } + + @Test("Filter document emits the largest Int64 integer unquoted") + func filterDocumentEmitsMaxInt64() { + let doc = builder.buildFilterDocument( + from: [(column: "code", op: "=", value: "9223372036854775807")] + ) + #expect(doc.contains("\"code\": 9223372036854775807")) + } + @Test("Filter document with null literal") func filterDocumentNullLiteral() { let doc = builder.buildFilterDocument( diff --git a/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift b/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift index 7a6f6dc05..4515a0e4d 100644 --- a/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift +++ b/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift @@ -6,12 +6,11 @@ // import Foundation -import Testing import TableProPluginKit +import Testing @Suite("MongoDB Statement Generator") struct MongoDBStatementGeneratorTests { - // MARK: - INSERT @Test("Simple insert generates insertOne, skipping _id") @@ -194,6 +193,98 @@ struct MongoDBStatementGeneratorTests { #expect(results[0].statement.contains("\"count\": 42")) } + @Test("Insert emits JSON-valid decimal and exponent numbers") + func insertEmitsJsonValidNumbers() { + let gen = MongoDBStatementGenerator( + collectionName: "users", + columns: ["_id", "decimal", "exponent"] + ) + + let change = PluginRowChange( + rowIndex: 0, + type: .insert, + cellChanges: [], + originalRow: nil + ) + + let insertedData: [Int: [PluginCellValue]] = [ + 0: [nil, "0.5", "1e3"] + ] + + let results = gen.generateStatements( + from: [change], + insertedRowData: insertedData, + deletedRowIndices: [], + insertedRowIndices: [0] + ) + + let document = firstArgumentObject(in: results[0].statement) + #expect(document?["decimal"] as? Double == 0.5) + #expect(document?["exponent"] as? Double == 1_000) + } + + @Test("Insert quotes non-JSON numeric spellings") + func insertQuotesNonJsonNumericSpellings() { + let gen = MongoDBStatementGenerator( + collectionName: "users", + columns: ["_id", "leadingDecimal", "trailingDecimal", "leadingPlus", "leadingZero"] + ) + + let change = PluginRowChange( + rowIndex: 0, + type: .insert, + cellChanges: [], + originalRow: nil + ) + + let insertedData: [Int: [PluginCellValue]] = [ + 0: [nil, ".5", "1.", "+7", "01"] + ] + + let results = gen.generateStatements( + from: [change], + insertedRowData: insertedData, + deletedRowIndices: [], + insertedRowIndices: [0] + ) + + let document = firstArgumentObject(in: results[0].statement) + #expect(document?["leadingDecimal"] as? String == ".5") + #expect(document?["trailingDecimal"] as? String == "1.") + #expect(document?["leadingPlus"] as? String == "+7") + #expect(document?["leadingZero"] as? String == "01") + } + + @Test("Insert quotes integers that overflow Int64 but keeps in-range integers numeric") + func insertQuotesInt64Overflow() { + let gen = MongoDBStatementGenerator( + collectionName: "users", + columns: ["_id", "overflow", "maxInt64"] + ) + + let change = PluginRowChange( + rowIndex: 0, + type: .insert, + cellChanges: [], + originalRow: nil + ) + + let insertedData: [Int: [PluginCellValue]] = [ + 0: [nil, "12345678901234567890", "9223372036854775807"] + ] + + let results = gen.generateStatements( + from: [change], + insertedRowData: insertedData, + deletedRowIndices: [], + insertedRowIndices: [0] + ) + + let document = firstArgumentObject(in: results[0].statement) + #expect(document?["overflow"] as? String == "12345678901234567890") + #expect(document?["maxInt64"] as? Int64 == 9_223_372_036_854_775_807) + } + @Test("Insert not in insertedRowIndices is skipped") func insertNotInIndicesSkipped() { let gen = MongoDBStatementGenerator( @@ -505,6 +596,30 @@ struct MongoDBStatementGeneratorTests { #expect(stmt.contains("\"$in\": [1, 2]")) } + @Test("Delete quotes an _id that overflows Int64 to preserve precision") + func deleteQuotesInt64OverflowId() { + let gen = MongoDBStatementGenerator( + collectionName: "users", + columns: ["_id", "name"] + ) + + let change = PluginRowChange( + rowIndex: 0, + type: .delete, + cellChanges: [], + originalRow: ["12345678901234567890", "Alice"] + ) + + let results = gen.generateStatements( + from: [change], + insertedRowData: [:], + deletedRowIndices: [0], + insertedRowIndices: [] + ) + + #expect(results[0].statement.contains("{\"_id\": \"12345678901234567890\"}")) + } + @Test("Single delete without _id falls back to all-field match") func singleDeleteNoIdFallback() { let gen = MongoDBStatementGenerator( @@ -760,3 +875,12 @@ struct MongoDBStatementGeneratorTests { #expect(results[0].statement.contains("\"tags\": [1, 2, 3]")) } } + +private func firstArgumentObject(in statement: String) -> [String: Any]? { + guard let openParen = statement.firstIndex(of: "("), + let closeParen = statement.lastIndex(of: ")"), + openParen < closeParen else { return nil } + let json = String(statement[statement.index(after: openParen) ..< closeParen]) + guard let data = json.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] +}