swiftdatansvaluetransformernssecurecodingnsmeasurement

How to use SwiftData ValueTransformer with a custom NSMeasurement Dimension Unit?


I'm attempting to store an NSMeasurement value in SwiftData like so: Store NSMeasurement or NSUnit in Core Data

I'm using both an existing Dimension with a custom unit (UnitLength Smoot), and a fully custom measurement Dimension (Radioactivity), as directed by Apple:

extension UnitLength {
    static let smoot = UnitLength(symbol: "smoot", converter: UnitConverterLinear(coefficient: 1.70180))
}

class UnitRadioactivity: Dimension {
    static let becquerels = UnitRadioactivity(symbol: "Bq", converter: UnitConverterLinear(coefficient: 1))
    static let curie      = UnitRadioactivity(symbol: "Ci", converter: UnitConverterLinear(coefficient: 3.7e10))
    override class func baseUnit() -> Self { UnitRadioactivity.becquerels as! Self }
}

I'm using a ValueTransformer via @Attribute(.transformable(by: to store the value, but open to similar approaches.

@Model
class MeasurementModel {
    @Attribute(.transformable(by: MeasurementTransformer.self)) let measurement: NSMeasurement
    
    init(_ measurement: NSMeasurement) {
        self.measurement = measurement
    }
}

final class MeasurementTransformer: ValueTransformer {
    override func transformedValue(_ value: Any?) -> Any? {
        guard let measurement = value as? NSMeasurement else { return nil }
        do {
            let data = try NSKeyedArchiver.archivedData(withRootObject: measurement, requiringSecureCoding: true)
            return data
        } catch {
            debugPrint(error.localizedDescription)
            return nil
        }
    }
    
    override func reverseTransformedValue(_ value: Any?) -> Any? {
        guard let data = value as? Data else { return nil }
        do {
            let measurement = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSMeasurement.self, from: data)
            return measurement
        } catch {
            debugPrint(error.localizedDescription)
            return nil
        }
    }
    
    override class func allowsReverseTransformation() -> Bool {
        true
    }
    
    override class func transformedValueClass() -> AnyClass {
        NSMeasurement.self
    }
}

The transformer works fine for UnitMass and other built-in Unit types. It also works for the custom smoot measurement unit for the existing UnitLength Dimension.

However it does not work for my fully custom Dimension UnitRadioactivity.

func testMeasurementValueTransformer() throws {
    let transformer = MeasurementTransformer()
    
    let mass = NSMeasurement(doubleValue: 42, unit: UnitMass.stones)
    let massTransformed = transformer.transformedValue(mass)
    let massReverse = transformer.reverseTransformedValue(massTransformed)
    let massAgain = try XCTUnwrap(massReverse) as! NSMeasurement
    let massUnit = try XCTUnwrap(massAgain.unit) as! UnitMass
    XCTAssertEqual(massUnit, UnitMass.stones) // ok
    XCTAssertNotEqual(massUnit, UnitMass.kilograms) // ok
    
    let length = NSMeasurement(doubleValue: 364.4, unit: UnitLength.smoot)
    let lengthTransformed = transformer.transformedValue(length)
    let lengthReverse = transformer.reverseTransformedValue(lengthTransformed)
    let lengthAgain = try XCTUnwrap(lengthReverse) as! NSMeasurement
    let lengthUnit = try XCTUnwrap(lengthAgain.unit) as! UnitLength
    XCTAssertEqual(lengthUnit, UnitLength.smoot) // ok
    XCTAssertNotEqual(lengthUnit, UnitLength.meters) // ok
    
    let radiation = NSMeasurement(doubleValue: 1903, unit: UnitRadioactivity.curie)
    let radiationTransformed = transformer.transformedValue(radiation) // NSKeyedArchiver error: "The data couldn’t be written because it isn’t in the correct format."
    let radiationReverse = transformer.reverseTransformedValue(radiationTransformed)
    let radiationAgain = try XCTUnwrap(radiationReverse) as! NSMeasurement // testMeasurementValueTransformer(): XCTUnwrap failed: expected non-nil value of type "Any"
    let radiationUnit = try XCTUnwrap(radiationAgain.unit) as! UnitRadioactivity
    XCTAssertEqual(radiationUnit, UnitRadioactivity.curie)
    XCTAssertNotEqual(radiationUnit, UnitRadioactivity.becquerels)
}

Why doesn't transforming/archiving Measurements work with custom Dimension subclasses?

How can I modify transformation so that the custom Dimension measurement units are retained?


Solution

  • The problem is with the UnitRadioactivity implementation and partly with the error handling in the value transformer.

    If you replace debugPrint(error.localizedDescription) with debugPrint(error) what is printed is

    Error Domain=NSCocoaErrorDomain Code=4866 "The data couldn’t be written because it isn’t in the correct format." UserInfo={NSUnderlyingError=0x600000b26df0 {Error Domain=NSCocoaErrorDomain Code=4864 "Class '__lldb_expr_88.UnitRadioactivity' has a superclass that supports secure coding, but '__lldb_expr_88.UnitRadioactivity' overrides -initWithCoder: and does not override +supportsSecureCoding. The class must implement +supportsSecureCoding and return YES to verify that its implementation of -initWithCoder: is secure coding compliant." ...

    So simply overriding this method removes the thrown error and the transformation works.

    class UnitRadioactivity: Dimension {
        // existing code ...
    
        override class var supportsSecureCoding: Bool { true }
    }
    

    The reverse transformation also seems to work but I am not sure how the result should be properly casted