From 09ae739d5a1bd36b3e87b24754f0f5e63b7b1c40 Mon Sep 17 00:00:00 2001 From: andiahmads Date: Sun, 5 Jul 2026 21:19:39 +0700 Subject: [PATCH 1/4] fix(plugin-mongodb): emit only JSON-valid numeric literals --- CHANGELOG.md | 1 + .../MongoDBQueryBuilder.swift | 11 ++- .../MongoDBStatementGenerator.swift | 17 +++-- .../Plugins/MongoDBQueryBuilderTests.swift | 23 +++++- .../MongoDBStatementGeneratorTests.swift | 74 ++++++++++++++++++- 5 files changed, 115 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0917da3f1..5e610199b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- MongoDB filters and generated edit statements now quote non-standard numeric-looking values such as `.5`, `1.`, `+7`, and `01` instead of emitting invalid JSON. - 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) ## [0.55.0] - 2026-07-04 diff --git a/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift b/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift index 5bdfe4cc5..d4bfc46ba 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 Self.isValidJsonNumber(value) { return value } return "\"\(Self.escapeJsonString(value))\"" } @@ -202,6 +201,14 @@ struct MongoDBQueryBuilder { "{\"$regex\": \"\(escapeJsonString(pattern))\", \"$options\": \"i\"}" } + private static func isValidJsonNumber(_ value: String) -> Bool { + let pattern = #"^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$"# + guard let range = value.range(of: pattern, options: .regularExpression) else { + return false + } + return range == value.startIndex ..< value.endIndex + } + static func escapeJsonString(_ value: String) -> String { var result = "" result.reserveCapacity((value as NSString).length) diff --git a/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift b/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift index a567494fa..1286a8206 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 isValidJsonNumber(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 isValidJsonNumber(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 isValidJsonNumber(value) { return value } // JSON object or array @@ -261,6 +258,14 @@ struct MongoDBStatementGenerator { return "\"\(escapeJsonString(value))\"" } + private func isValidJsonNumber(_ value: String) -> Bool { + let pattern = #"^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$"# + guard let range = value.range(of: pattern, options: .regularExpression) else { + return false + } + return range == value.startIndex ..< value.endIndex + } + /// Escape special characters for JSON strings (handles Unicode control chars U+0000-U+001F) private func escapeJsonString(_ value: String) -> String { var result = "" diff --git a/TableProTests/Plugins/MongoDBQueryBuilderTests.swift b/TableProTests/Plugins/MongoDBQueryBuilderTests.swift index 4235af55a..03318777f 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,27 @@ 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 with null literal") func filterDocumentNullLiteral() { let doc = builder.buildFilterDocument( diff --git a/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift b/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift index 7a6f6dc05..14bed18b0 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,68 @@ 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 not in insertedRowIndices is skipped") func insertNotInIndicesSkipped() { let gen = MongoDBStatementGenerator( @@ -760,3 +821,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] +} From 389661b3e93fce4b221114630558a824e37725c2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 6 Jul 2026 13:47:48 +0700 Subject: [PATCH 2/4] refactor(plugin-mongodb): share JSON-number check and quote Int64-overflow integers --- CHANGELOG.md | 2 +- .../MongoDBJsonNumber.swift | 29 ++++++++++ .../MongoDBQueryBuilder.swift | 10 +--- .../MongoDBStatementGenerator.swift | 14 ++--- .../PluginTestSources/MongoDBJsonNumber.swift | 1 + .../Plugins/MongoDBQueryBuilderTests.swift | 17 ++++++ .../MongoDBStatementGeneratorTests.swift | 54 +++++++++++++++++++ 7 files changed, 106 insertions(+), 21 deletions(-) create mode 100644 Plugins/MongoDBDriverPlugin/MongoDBJsonNumber.swift create mode 120000 TableProTests/PluginTestSources/MongoDBJsonNumber.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e610199b..60e143dcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- MongoDB filters and generated edit statements now quote non-standard numeric-looking values such as `.5`, `1.`, `+7`, and `01` instead of emitting invalid JSON. +- 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. - 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) ## [0.55.0] - 2026-07-04 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 d4bfc46ba..04eb34eff 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift @@ -179,7 +179,7 @@ struct MongoDBQueryBuilder { private func jsonValue(_ value: String) -> String { if value == "true" || value == "false" { return value } if value == "null" { return value } - if Self.isValidJsonNumber(value) { return value } + if MongoDBJsonNumber.isValid(value) { return value } return "\"\(Self.escapeJsonString(value))\"" } @@ -201,14 +201,6 @@ struct MongoDBQueryBuilder { "{\"$regex\": \"\(escapeJsonString(pattern))\", \"$options\": \"i\"}" } - private static func isValidJsonNumber(_ value: String) -> Bool { - let pattern = #"^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$"# - guard let range = value.range(of: pattern, options: .regularExpression) else { - return false - } - return range == value.startIndex ..< value.endIndex - } - static func escapeJsonString(_ value: String) -> String { var result = "" result.reserveCapacity((value as NSString).length) diff --git a/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift b/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift index 1286a8206..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 isValidJsonNumber(idValue) { + } 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 isValidJsonNumber(idValue) { + if MongoDBJsonNumber.isValid(idValue) { return "{\"_id\": \(idValue)}" } return "{\"_id\": \"\(escapeJsonString(idValue))\"}" @@ -247,7 +247,7 @@ struct MongoDBStatementGenerator { if value == "null" { return "null" } - if isValidJsonNumber(value) { + if MongoDBJsonNumber.isValid(value) { return value } // JSON object or array @@ -258,14 +258,6 @@ struct MongoDBStatementGenerator { return "\"\(escapeJsonString(value))\"" } - private func isValidJsonNumber(_ value: String) -> Bool { - let pattern = #"^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$"# - guard let range = value.range(of: pattern, options: .regularExpression) else { - return false - } - return range == value.startIndex ..< value.endIndex - } - /// Escape special characters for JSON strings (handles Unicode control chars U+0000-U+001F) private func escapeJsonString(_ value: String) -> String { var result = "" 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 03318777f..8cd0a9ce5 100644 --- a/TableProTests/Plugins/MongoDBQueryBuilderTests.swift +++ b/TableProTests/Plugins/MongoDBQueryBuilderTests.swift @@ -387,6 +387,23 @@ struct MongoDBQueryBuilderTests { } } + @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 14bed18b0..4515a0e4d 100644 --- a/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift +++ b/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift @@ -255,6 +255,36 @@ struct MongoDBStatementGeneratorTests { #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( @@ -566,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( From f561194629fbb011c0fd2bd02fd16748ea001fc8 Mon Sep 17 00:00:00 2001 From: andiahmads Date: Tue, 7 Jul 2026 21:36:16 +0700 Subject: [PATCH 3/4] fix: drain password command pipes deterministically --- .../Connection/PasswordSourceResolver.swift | 58 +++++++++++++------ 1 file changed, 39 insertions(+), 19 deletions(-) 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"] From 628acce4564b27a4c3f991a1c51d0ed72230f878 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 8 Jul 2026 01:06:01 +0700 Subject: [PATCH 4/4] fix(plugin-mongodb): quote non-finite numbers and keep decimal _id filters exact (#1813) --- CHANGELOG.md | 2 +- .../MongoDBJsonNumber.swift | 2 +- .../MongoDBStatementGenerator.swift | 4 +-- .../Plugins/MongoDBQueryBuilderTests.swift | 11 ++++++++ .../MongoDBStatementGeneratorTests.swift | 26 +++++++++++++++++++ 5 files changed, 41 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da8cb71c7..18c745255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,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. +- MongoDB filters and generated edit statements now quote numeric-looking values that would not round-trip, such as `.5`, `1.`, `+7`, `01`, integers larger than 64 bits, and exponents that overflow a double like `1e400`, instead of emitting invalid or lossy JSON. A row whose `_id` is a decimal or exponent string is matched as that string, so delete and update target the right document. (#1813) - Deleting many rows at once no longer freezes the app or the machine. Multi-row deletes now run as one batched statement, chunked to stay within the database's limits, instead of one query per row. (#1823) - Deleting rows from a table without a primary key now matches on every column instead of treating the first column as a key, so it won't remove other rows that happen to share that value. (#1823) - Sorting a table column no longer discards your rows-per-page setting. Clicking a column header to sort, or clicking again to clear the sort, now keeps the page size and returns to page 1 instead of loading the whole table. (#1826) diff --git a/Plugins/MongoDBDriverPlugin/MongoDBJsonNumber.swift b/Plugins/MongoDBDriverPlugin/MongoDBJsonNumber.swift index b96b5b684..ba63f8c6f 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBJsonNumber.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBJsonNumber.swift @@ -20,7 +20,7 @@ enum MongoDBJsonNumber { if isPlainInteger(value) { return Int64(value) != nil } - return true + return Double(value)?.isFinite ?? false } private static func isPlainInteger(_ value: String) -> Bool { diff --git a/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift b/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift index 55731bf25..cc57d21d4 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 MongoDBJsonNumber.isValid(idValue) { + } else if Int64(idValue) != nil { idValues.append(idValue) } else { idValues.append("\"\(escapeJsonString(idValue))\"") @@ -218,7 +218,7 @@ struct MongoDBStatementGenerator { if isObjectIdString(idValue) { return "{\"_id\": {\"$oid\": \"\(idValue)\"}}" } - if MongoDBJsonNumber.isValid(idValue) { + if Int64(idValue) != nil { return "{\"_id\": \(idValue)}" } return "{\"_id\": \"\(escapeJsonString(idValue))\"}" diff --git a/TableProTests/Plugins/MongoDBQueryBuilderTests.swift b/TableProTests/Plugins/MongoDBQueryBuilderTests.swift index 8cd0a9ce5..14d564b3c 100644 --- a/TableProTests/Plugins/MongoDBQueryBuilderTests.swift +++ b/TableProTests/Plugins/MongoDBQueryBuilderTests.swift @@ -396,6 +396,17 @@ struct MongoDBQueryBuilderTests { #expect(parsed?["code"] as? String == "12345678901234567890") } + @Test("Filter document quotes exponents that overflow Double instead of emitting Infinity") + func filterDocumentQuotesOutOfRangeExponent() { + for value in ["1e400", "-1e400", "1.5e400"] { + let doc = builder.buildFilterDocument( + from: [(column: "score", op: "=", value: value)] + ) + let parsed = parseFilter(doc) + #expect(parsed?["score"] as? String == value) + } + } + @Test("Filter document emits the largest Int64 integer unquoted") func filterDocumentEmitsMaxInt64() { let doc = builder.buildFilterDocument( diff --git a/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift b/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift index 4515a0e4d..6c610636b 100644 --- a/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift +++ b/TableProTests/Plugins/MongoDBStatementGeneratorTests.swift @@ -620,6 +620,32 @@ struct MongoDBStatementGeneratorTests { #expect(results[0].statement.contains("{\"_id\": \"12345678901234567890\"}")) } + @Test("Delete keeps a decimal or exponent _id quoted so a string _id still matches") + func deleteQuotesNonIntegerId() { + let gen = MongoDBStatementGenerator( + collectionName: "users", + columns: ["_id", "name"] + ) + + for id in ["1.5", "1e3"] { + let change = PluginRowChange( + rowIndex: 0, + type: .delete, + cellChanges: [], + originalRow: [PluginCellValue.text(id), "Alice"] + ) + + let results = gen.generateStatements( + from: [change], + insertedRowData: [:], + deletedRowIndices: [0], + insertedRowIndices: [] + ) + + #expect(results[0].statement.contains("{\"_id\": \"\(id)\"}")) + } + } + @Test("Single delete without _id falls back to all-field match") func singleDeleteNoIdFallback() { let gen = MongoDBStatementGenerator(