Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 34 additions & 13 deletions Sources/MetaRouter/analytics/CodableValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,36 +184,57 @@ 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<Any> 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<T>.none → null; Optional<T>.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)
case let double as Double: return .double(double)
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<Any>.none: return .null
default: return nil
}
}
Expand Down
117 changes: 117 additions & 0 deletions Tests/MetaRouterTests/CodableValueTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]],
Expand Down
Loading