diff --git a/CHANGELOG.md b/CHANGELOG.md index 922dfa5c9..38e7f48aa 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 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. Update the MongoDB plugin to get the fix. (#1813) - Reading a connection password from a command, 1Password, Vault, or AWS Secrets Manager no longer occasionally returns corrupted output from a race while reading the command's output. (#1841) - The Structure tab filter and column sort now update the grid instead of leaving stale rows on screen. - The row details inspector now shows the selected row's values, including JSON, when a column value filter is active, and a JSON or serialized value you open now follows the selected row as you move between rows. (#1837) diff --git a/Plugins/MongoDBDriverPlugin/MongoDBJsonNumber.swift b/Plugins/MongoDBDriverPlugin/MongoDBJsonNumber.swift new file mode 100644 index 000000000..ba63f8c6f --- /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 Double(value)?.isFinite ?? false + } + + 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..cc57d21d4 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift @@ -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/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..14d564b3c 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,55 @@ 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 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( + 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..6c610636b 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,56 @@ 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("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( @@ -760,3 +901,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] +}