diff --git a/Sources/MetaRouter/analytics/CodableValue.swift b/Sources/MetaRouter/analytics/CodableValue.swift index 3ee0939..e4ea5c1 100644 --- a/Sources/MetaRouter/analytics/CodableValue.swift +++ b/Sources/MetaRouter/analytics/CodableValue.swift @@ -184,18 +184,29 @@ extension CodableValue { /// Convert any supported value to CodableValue /// - Parameter value: The value to convert /// - Returns: A CodableValue if the conversion was successful, nil otherwise + /// + /// Container handling is tolerant: a single unsupported value inside a dict + /// or array drops only that key/element (with a warning logged) rather than + /// invalidating the entire container. public static func from(_ value: Any) -> CodableValue? { - // Handle Optional first - if let unwrapped = Mirror(reflecting: value).unwrapOptional() { - return from(unwrapped) + // NSNull (common from JSONSerialization / Obj-C bridging) → null + if value is NSNull { + return .null } - - // Handle already converted values + + // Optional.none → null; Optional.some(x) → recurse with x + if Mirror(reflecting: value).displayStyle == .optional { + if let unwrapped = Mirror(reflecting: value).unwrapOptional() { + return from(unwrapped) + } + return .null + } + + // Already a CodableValue if let codableValue = value as? CodableValue { return codableValue } - - // Handle primitive types + switch value { case let string as String: return .string(string) case let int as Int: return .int(int) @@ -203,17 +214,27 @@ extension CodableValue { case let float as Float: return .double(Double(float)) case let bool as Bool: return .bool(bool) case let array as [Any]: - let converted = array.compactMap(from) - return converted.count == array.count ? .array(converted) : nil + var converted: [CodableValue] = [] + converted.reserveCapacity(array.count) + for element in array { + if let cv = from(element) { + converted.append(cv) + } else { + Logger.warn("CodableValue: dropping unsupported array element of type \(type(of: element))") + } + } + return .array(converted) case let dict as [String: Any]: var converted: [String: CodableValue] = [:] - converted.reserveCapacity(dict.count) // Performance optimization + converted.reserveCapacity(dict.count) for (key, val) in dict { - guard let codableVal = from(val) else { return nil } - converted[key] = codableVal + if let cv = from(val) { + converted[key] = cv + } else { + Logger.warn("CodableValue: dropping unsupported value for key '\(key)' of type \(type(of: val))") + } } return .object(converted) - case Optional.none: return .null default: return nil } } diff --git a/Tests/MetaRouterTests/CodableValueTests.swift b/Tests/MetaRouterTests/CodableValueTests.swift index be5ef2c..d59668d 100644 --- a/Tests/MetaRouterTests/CodableValueTests.swift +++ b/Tests/MetaRouterTests/CodableValueTests.swift @@ -430,6 +430,123 @@ final class CodableValueTests: XCTestCase { } } + // MARK: - Nil / Unsupported-Type Tolerance + // + // Regression coverage: previously a single value that failed to convert + // (NSNull, Date, custom struct, etc.) caused `from(_:)` to return nil for + // the entire enclosing dict/array, dropping all sibling keys/elements. + // Per-key/per-element tolerance is now expected. + + func testConvertNSNullDirectlyReturnsNullCase() { + let value = CodableValue.from(NSNull()) + XCTAssertEqual(value, .null) + } + + func testConvertOptionalNoneDirectlyReturnsNullCase() { + let none: String? = nil + let value = CodableValue.from(none as Any) + XCTAssertEqual(value, .null) + } + + func testConvertDictWithNSNullPreservesOtherKeys() { + let input: [String: Any] = [ + "name": "Chris", + "phone": NSNull(), + "age": 30 + ] + + let converted = CodableValue.convert(input) + + XCTAssertNotNil(converted, "Dict containing NSNull must not collapse to nil") + XCTAssertEqual(converted?["name"], .string("Chris")) + XCTAssertEqual(converted?["age"], .int(30)) + XCTAssertEqual(converted?["phone"], .null) + } + + func testConvertDictWithOptionalNonePreservesOtherKeys() { + let phone: String? = nil + let input: [String: Any] = [ + "name": "Chris", + "phone": phone as Any, + "age": 30 + ] + + let converted = CodableValue.convert(input) + + XCTAssertNotNil(converted, "Dict containing Optional.none must not collapse to nil") + XCTAssertEqual(converted?["name"], .string("Chris")) + XCTAssertEqual(converted?["age"], .int(30)) + XCTAssertEqual(converted?["phone"], .null) + } + + func testConvertDictWithUnsupportedTypeDoesNotPoisonDict() { + struct Custom {} + let input: [String: Any] = [ + "name": "Chris", + "weird": Custom(), + "age": 30 + ] + + let converted = CodableValue.convert(input) + + XCTAssertNotNil(converted, "Dict containing an unsupported value must not collapse to nil") + XCTAssertEqual(converted?["name"], .string("Chris")) + XCTAssertEqual(converted?["age"], .int(30)) + XCTAssertNil(converted?["weird"], "Unsupported value should be dropped, not poison the dict") + } + + func testConvertArrayWithUnsupportedElementDoesNotPoisonArray() { + struct Custom {} + let input: [String: Any] = [ + "tags": ["a", Custom(), "b"] + ] + + let converted = CodableValue.convert(input) + + XCTAssertNotNil(converted, "Array containing an unsupported value must not collapse to nil") + if case .array(let elements) = converted?["tags"] ?? .null { + XCTAssertEqual(elements, [.string("a"), .string("b")]) + } else { + XCTFail("Expected tags to remain an array with surviving elements") + } + } + + func testConvertArrayWithNSNullPreservesNullElement() { + let input: [String: Any] = [ + "vals": [1, NSNull(), 3] + ] + + let converted = CodableValue.convert(input) + + XCTAssertNotNil(converted) + if case .array(let elements) = converted?["vals"] ?? .null { + XCTAssertEqual(elements, [.int(1), .null, .int(3)]) + } else { + XCTFail("Expected vals to remain an array with null preserved") + } + } + + func testNestedDictWithNSNullPreservesStructure() { + let input: [String: Any] = [ + "user": [ + "id": "u1", + "email": NSNull() + ] as [String: Any], + "score": 10 + ] + + let converted = CodableValue.convert(input) + + XCTAssertNotNil(converted) + XCTAssertEqual(converted?["score"], .int(10)) + if case .object(let user) = converted?["user"] ?? .null { + XCTAssertEqual(user["id"], .string("u1")) + XCTAssertEqual(user["email"], .null) + } else { + XCTFail("Expected user to remain an object with null email preserved") + } + } + func testLiteralSyntaxWithComplexStructures() { let value: CodableValue = [ "mixed_array": [1, "two", 3.0, true, ["nested"]],