diff --git a/CHANGELOG.md b/CHANGELOG.md index adb1b9564..c973fcbd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Custom and OpenAI-compatible AI providers now work when the base URL already ends in `/v1`, instead of building a doubled `/v1/v1/` path that failed. (#1400) +- MongoDB: opening a collection no longer crashes when a document contains a NaN or infinite number. (#1418) ## [0.45.0] - 2026-05-26 diff --git a/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift b/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift index cf8ff5f37..30fcb83bc 100644 --- a/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift +++ b/Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift @@ -73,23 +73,9 @@ struct BsonDocumentFlattener { case let str as String: return str case let num as NSNumber: - // Check if it's a boolean (NSNumber wraps booleans too) - if CFBooleanGetTypeID() == CFGetTypeID(num) { - return num.boolValue ? "true" : "false" - } - return num.stringValue - case let int as Int: - return String(int) - case let int32 as Int32: - return String(int32) - case let int64 as Int64: - return String(int64) - case let double as Double: - return String(double) - case let bool as Bool: - return bool ? "true" : "false" + return displayString(for: num) case let date as Date: - return ISO8601DateFormatter().string(from: date) + return iso8601Formatter.string(from: date) case let data as Data: return formatBinaryData(data) case let dict as [String: Any]: @@ -121,24 +107,20 @@ struct BsonDocumentFlattener { /// Serialize a dictionary or array to compact JSON string static func serializeToJson(_ value: Any) -> String { let sanitized = sanitizeForJson(value) - do { - let data = try JSONSerialization.data(withJSONObject: sanitized, options: [.sortedKeys]) - if let json = String(data: data, encoding: .utf8) { - // Cap at 10k chars to prevent mega-document display issues - let nsJson = json as NSString - if nsJson.length > 10_000 { - return String(json.prefix(10_000)) + "..." - } - return json - } - } catch { - // Fall through to description + guard JSONSerialization.isValidJSONObject(sanitized), + let data = try? JSONSerialization.data(withJSONObject: sanitized, options: [.sortedKeys]), + let json = String(data: data, encoding: .utf8) else { + return String(describing: value) } - return String(describing: value) + let nsJson = json as NSString + if nsJson.length > 10_000 { + return String(json.prefix(10_000)) + "..." + } + return json } - /// Recursively convert non-JSON-safe types (Data, Date, etc.) to JSON-safe representations - private static func sanitizeForJson(_ value: Any) -> Any { + /// Recursively convert every value into a JSON-safe representation + static func sanitizeForJson(_ value: Any) -> Any { switch value { case let dict as [String: Any]: return dict.mapValues { sanitizeForJson($0) } @@ -147,12 +129,50 @@ struct BsonDocumentFlattener { case let data as Data: return formatBinaryData(data) case let date as Date: - return ISO8601DateFormatter().string(from: date) - default: + return iso8601Formatter.string(from: date) + case is NSNull: return value + case let str as String: + return str + case let num as NSNumber: + return sanitizeNumber(num) + default: + return String(describing: value) } } + private static let iso8601Formatter = ISO8601DateFormatter() + + private static func displayString(for num: NSNumber) -> String { + if isBoolean(num) { + return num.boolValue ? "true" : "false" + } + if isFloatingPoint(num), !num.doubleValue.isFinite { + return nonFiniteToken(num.doubleValue) + } + return num.stringValue + } + + private static func sanitizeNumber(_ num: NSNumber) -> Any { + guard !isBoolean(num) else { return num } + guard isFloatingPoint(num), !num.doubleValue.isFinite else { return num } + return nonFiniteToken(num.doubleValue) + } + + private static func isBoolean(_ num: NSNumber) -> Bool { + CFBooleanGetTypeID() == CFGetTypeID(num) + } + + private static func isFloatingPoint(_ num: NSNumber) -> Bool { + let objCType = String(cString: num.objCType) + return objCType == "d" || objCType == "f" + } + + private static func nonFiniteToken(_ value: Double) -> String { + if value.isNaN { return "NaN" } + return value > 0 ? "Infinity" : "-Infinity" + } + /// Format binary data: 16-byte values as UUID, otherwise as hex string private static func formatBinaryData(_ data: Data) -> String { if data.count == 16 { @@ -193,27 +213,16 @@ struct BsonDocumentFlattener { switch value { case let num as NSNumber: - if CFBooleanGetTypeID() == CFGetTypeID(num) { + if isBoolean(num) { return 8 // Boolean } - let objCType = String(cString: num.objCType) - if objCType == "d" || objCType == "f" { + if isFloatingPoint(num) { return 1 // Double } - if objCType == "q" || objCType == "l" { - return 18 // Int64 - } - return 16 // Int32 + let objCType = String(cString: num.objCType) + return objCType == "q" || objCType == "l" ? 18 : 16 // Int64 : Int32 case is String: return 2 // String - case is Bool: - return 8 // Boolean - case is Int, is Int32: - return 16 // Int32 - case is Int64: - return 18 // Int64 - case is Double, is Float: - return 1 // Double case is Date: return 9 // Date case is Data: diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index 8bb8ec57d..b440299ea 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -908,7 +908,9 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } private func prettyJson(_ value: Any) -> String { - guard let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys, .prettyPrinted]), + let sanitized = BsonDocumentFlattener.sanitizeForJson(value) + guard JSONSerialization.isValidJSONObject(sanitized), + let data = try? JSONSerialization.data(withJSONObject: sanitized, options: [.sortedKeys, .prettyPrinted]), let json = String(data: data, encoding: .utf8) else { return String(describing: value) } diff --git a/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift b/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift index fd08bc9d3..374632df3 100644 --- a/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift +++ b/TableProTests/Core/MongoDB/BsonDocumentFlattenerTests.swift @@ -125,6 +125,15 @@ struct BsonDocumentFlattenerTests { let expected = ISO8601DateFormatter().string(from: date) #expect(result[0][1] == expected) } + + @Test("Nested non-finite double does not crash and renders as a token") + func nestedNonFiniteDouble() { + let metrics: [String: Any] = ["score": Double.nan, "ratio": Double.infinity] + let doc: [String: Any] = ["_id": "1", "metrics": metrics] + let columns = ["_id", "metrics"] + let result = BsonDocumentFlattener.flatten(documents: [doc], columns: columns) + #expect(result[0][1] == "{\"ratio\":\"Infinity\",\"score\":\"NaN\"}") + } } // MARK: - columnTypes(for:documents:) @@ -261,6 +270,24 @@ struct BsonDocumentFlattenerTests { #expect(result == "3.14") } + @Test("NaN double returns NaN token") + func nanValue() { + let result = BsonDocumentFlattener.stringValue(for: Double.nan) + #expect(result == "NaN") + } + + @Test("Positive infinity returns Infinity token") + func positiveInfinityValue() { + let result = BsonDocumentFlattener.stringValue(for: Double.infinity) + #expect(result == "Infinity") + } + + @Test("Negative infinity returns -Infinity token") + func negativeInfinityValue() { + let result = BsonDocumentFlattener.stringValue(for: -Double.infinity) + #expect(result == "-Infinity") + } + @Test("Bool true via NSNumber returns true") func boolTrueValue() { let result = BsonDocumentFlattener.stringValue(for: NSNumber(value: true)) @@ -335,7 +362,7 @@ struct BsonDocumentFlattenerTests { func capsAtTenThousandChars() { // Build a large dictionary that serializes to >10k chars var largeDict: [String: Any] = [:] - for i in 0 ..< 2000 { + for i in 0 ..< 2_000 { largeDict["key_\(String(format: "%04d", i))"] = String(repeating: "x", count: 10) } let result = BsonDocumentFlattener.serializeToJson(largeDict) @@ -343,6 +370,42 @@ struct BsonDocumentFlattenerTests { #expect(nsResult.length <= 10_003) // 10000 + "..." #expect(result.hasSuffix("...")) } + + @Test("NaN double in a dictionary serializes to NaN token instead of crashing") + func nanInDictionary() { + let dict: [String: Any] = ["v": Double.nan] + let result = BsonDocumentFlattener.serializeToJson(dict) + #expect(result == "{\"v\":\"NaN\"}") + } + + @Test("Positive infinity in a dictionary serializes to Infinity token") + func positiveInfinityInDictionary() { + let dict: [String: Any] = ["v": Double.infinity] + let result = BsonDocumentFlattener.serializeToJson(dict) + #expect(result == "{\"v\":\"Infinity\"}") + } + + @Test("Negative infinity in a dictionary serializes to -Infinity token") + func negativeInfinityInDictionary() { + let dict: [String: Any] = ["v": -Double.infinity] + let result = BsonDocumentFlattener.serializeToJson(dict) + #expect(result == "{\"v\":\"-Infinity\"}") + } + + @Test("Non-finite double in an array keeps finite siblings as numbers") + func nonFiniteInArray() { + let array: [Any] = [Double.nan, 1.5, Double.infinity] + let result = BsonDocumentFlattener.serializeToJson(array) + #expect(result == "[\"NaN\",1.5,\"Infinity\"]") + } + + @Test("Unsupported nested type is stringified instead of crashing") + func unsupportedTypeStringified() { + struct Custom: CustomStringConvertible { var description: String { "custom" } } + let dict: [String: Any] = ["v": Custom()] + let result = BsonDocumentFlattener.serializeToJson(dict) + #expect(result == "{\"v\":\"custom\"}") + } } } @@ -399,22 +462,9 @@ private struct BsonDocumentFlattener { case let str as String: return str case let num as NSNumber: - if CFBooleanGetTypeID() == CFGetTypeID(num) { - return num.boolValue ? "true" : "false" - } - return num.stringValue - case let int as Int: - return String(int) - case let int32 as Int32: - return String(int32) - case let int64 as Int64: - return String(int64) - case let double as Double: - return String(double) - case let bool as Bool: - return bool ? "true" : "false" + return displayString(for: num) case let date as Date: - return ISO8601DateFormatter().string(from: date) + return iso8601Formatter.string(from: date) case let data as Data: return formatBinaryData(data) case let dict as [String: Any]: @@ -441,22 +491,19 @@ private struct BsonDocumentFlattener { static func serializeToJson(_ value: Any) -> String { let sanitized = sanitizeForJson(value) - do { - let data = try JSONSerialization.data(withJSONObject: sanitized, options: [.sortedKeys]) - if let json = String(data: data, encoding: .utf8) { - let nsJson = json as NSString - if nsJson.length > 10_000 { - return String(json.prefix(10_000)) + "..." - } - return json - } - } catch { - // Fall through to description + guard JSONSerialization.isValidJSONObject(sanitized), + let data = try? JSONSerialization.data(withJSONObject: sanitized, options: [.sortedKeys]), + let json = String(data: data, encoding: .utf8) else { + return String(describing: value) } - return String(describing: value) + let nsJson = json as NSString + if nsJson.length > 10_000 { + return String(json.prefix(10_000)) + "..." + } + return json } - private static func sanitizeForJson(_ value: Any) -> Any { + static func sanitizeForJson(_ value: Any) -> Any { switch value { case let dict as [String: Any]: return dict.mapValues { sanitizeForJson($0) } @@ -465,12 +512,50 @@ private struct BsonDocumentFlattener { case let data as Data: return formatBinaryData(data) case let date as Date: - return ISO8601DateFormatter().string(from: date) - default: + return iso8601Formatter.string(from: date) + case is NSNull: return value + case let str as String: + return str + case let num as NSNumber: + return sanitizeNumber(num) + default: + return String(describing: value) } } + private static let iso8601Formatter = ISO8601DateFormatter() + + private static func displayString(for num: NSNumber) -> String { + if isBoolean(num) { + return num.boolValue ? "true" : "false" + } + if isFloatingPoint(num), !num.doubleValue.isFinite { + return nonFiniteToken(num.doubleValue) + } + return num.stringValue + } + + private static func sanitizeNumber(_ num: NSNumber) -> Any { + guard !isBoolean(num) else { return num } + guard isFloatingPoint(num), !num.doubleValue.isFinite else { return num } + return nonFiniteToken(num.doubleValue) + } + + private static func isBoolean(_ num: NSNumber) -> Bool { + CFBooleanGetTypeID() == CFGetTypeID(num) + } + + private static func isFloatingPoint(_ num: NSNumber) -> Bool { + let objCType = String(cString: num.objCType) + return objCType == "d" || objCType == "f" + } + + private static func nonFiniteToken(_ value: Double) -> String { + if value.isNaN { return "NaN" } + return value > 0 ? "Infinity" : "-Infinity" + } + private static func formatBinaryData(_ data: Data) -> String { if data.count == 16 { let uuid = UUID(uuid: ( @@ -503,27 +588,16 @@ private struct BsonDocumentFlattener { switch value { case let num as NSNumber: - if CFBooleanGetTypeID() == CFGetTypeID(num) { + if isBoolean(num) { return 8 } - let objCType = String(cString: num.objCType) - if objCType == "d" || objCType == "f" { + if isFloatingPoint(num) { return 1 } - if objCType == "q" || objCType == "l" { - return 18 - } - return 16 + let objCType = String(cString: num.objCType) + return objCType == "q" || objCType == "l" ? 18 : 16 case is String: return 2 - case is Bool: - return 8 - case is Int, is Int32: - return 16 - case is Int64: - return 18 - case is Double, is Float: - return 1 case is Date: return 9 case is Data: