Search code examples
iosswiftcore-dataswiftdata

SwiftData with ValueTransformer similar to core data


In Core Data, you are able to use ValueTransformer to transform individual attributes to/from core data. This would be useful in cases like encrypting/decrypting certain attributes when storing/fetching from managed context.

Is there a similar way with SwiftData? I would like to save my data encrypted but have it decrypted in my @Query variable.

I need this for iOS.

Here is an example of using ValueTransformer via core data (credit: https://www.avanderlee.com/swift/valuetransformer-core-data/):

override public func transformedValue(_ value: Any?) -> Any? {
    guard let color = value as? UIColor else { return nil }
    
    do {
        let data = try NSKeyedArchiver.archivedData(withRootObject: color, requiringSecureCoding: true)
        return data
    } catch {
        assertionFailure("Failed to transform `UIColor` to `Data`")
        return nil
    }
}

override public func reverseTransformedValue(_ value: Any?) -> Any? {
    guard let data = value as? NSData else { return nil }
    
    do {
        let color = try NSKeyedUnarchiver.unarchivedObject(ofClass: UIColor.self, from: data as Data)
        return color
    } catch {
        assertionFailure("Failed to transform `Data` to `UIColor`")
        return nil
    }
}

Solution

  • The suggestion in the comment from @joakim-danielson worked beautifully :)

    Essentially it is Core Data's ValueTransformer hooked into SwiftData. The below example converts between Date and String.

    This is the SwiftData model Item:

    import Foundation
    import SwiftData
    
    @Model
    final class Item {
        @Attribute(.transformable(by: DateValueTransformer.name.rawValue))
        var timestamp: Date
        
        init(timestamp: Date) {
            self.timestamp = timestamp
        }
    }
    

    This is the ValueTransformer subclass, which persists the date as an encoded string:

    import Foundation
    
    @objc(DateValueTransformer)
    class DateValueTransformer: ValueTransformer {
        var randomKey = "nothing"
        
        private var dateFormatter: DateFormatter = {
            let formatter = DateFormatter()
            formatter.dateFormat = "YYYY-MM-dd HH:mm:ss"
            return formatter
        }()
        
        override class func transformedValueClass() -> AnyClass {
            return NSDate.self
        }
        
        override class func allowsReverseTransformation() -> Bool {
            return true
        }
        
        override func transformedValue(_ value: Any?) -> Any? {
            guard let date = value as? Date else { return nil }
            
            let dateString = dateFormatter.string(from: date) + randomKey
            print("transform: \(dateString)")
            
            do {
                return try NSKeyedArchiver.archivedData(withRootObject: dateString, requiringSecureCoding: true)
            } catch {
                assertionFailure("Failed to transform `Date` to `Data(String)`")
                return nil
            }
        }
        
        override func reverseTransformedValue(_ value: Any?) -> Any? {
            guard let dateData = value as? Data else { return nil }
            do {
                guard let dateString = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSString.self, from: dateData as Data) as? String
                else { return nil }
                print("reverse: \(dateString)")
                
                return dateFormatter.date(from: String(dateString.dropLast(randomKey.count)))
            } catch {
                assertionFailure("Failed to transform `Data(String)` to `Date`")
                return nil
            }
        }
    }
    
    extension DateValueTransformer {
        static let name = NSValueTransformerName(rawValue: String(describing: DateValueTransformer.self))
        
        public static func register(randomKey: String) {
            let transformer = DateValueTransformer()
            transformer.randomKey = randomKey
            ValueTransformer.setValueTransformer(transformer, forName: name)
        }
    }
    

    In order to demonstrate how to pass dynamic values to the ValueTransformer, this snippet registers an instance of DateValueTransformer with a custom randomKey:

    import SwiftUI
    import SwiftData
    
    @main
    struct SwiftDataExperimentApp: App {
        var sharedModelContainer: ModelContainer = {
            DateValueTransformer.register(randomKey: "something")
            
            let schema = Schema([
                Item.self,
            ])
            let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
    
            do {
                return try ModelContainer(for: schema, configurations: [modelConfiguration])
            } catch {
                fatalError("Could not create ModelContainer: \(error)")
            }
        }()
    
        var body: some Scene {
            WindowGroup {
                ContentView()
            }
            .modelContainer(sharedModelContainer)
        }
    }
    

    When storing an instance of Item through SwiftData, you will get the following console output:

    transform: 2023-10-23 09:56:53something
    reverse: 2023-10-23 09:56:53something
    

    It shows that our custom randomKey is also working.