Storing a JSON / Codable type with SwiftData is (kinda) working out of the box but gets clunky when you start modifying the data which leaves you with a couple database migration errors which I’ll cover here with a possible solution to circumvent it
TLDR: Use Migrations or ValueTransformers
1. Context
Recently I’ve stored a Codable
struct in SwiftData working on a private project and went with the initial thought that SwiftData is able to handle it by itself which turned out to be true until I’ve made a couple of changes (introduced some new properties, changed types) and ran into migrations errors. With some digging I’ve found a solution that makes it quite easy to handle and much easier to debug and fallback in case something goes eek.
2. Approach
Should be that easy right..
@Model
final class Item {
var timestamp: Date
var meta: MetaData?
}
struct MetaData: Codable {
var remix: Bool = true
}
This will work until you change your mind…
struct MetaData: Codable {
var remix: Bool = true
var createdAt: Date = Date()
}
Unresolved error loading container Error Domain=NSCocoaErrorDomain Code=134110 “An error occurred during persistent store migration.”
Validation error missing attribute values on mandatory destination attribute`
Eeeeek~
Might be solvable with SwiftData’s Migration but I went for a different route.
3. ValueTransformer
I’ve stumpled upon ValueTransformer
which is supported via the @Attribute
.transformable
property wrapper which can transform values to the database in a custom way. Great fit for my case and useful for others as well.
A value transformer can take inputs of one type and return a value of a different type. For example, a value transformer could take an NSImage or UIImage object and return an NSData object containing the PNG representation of that image.
4. JSONValueTransformer
For convenience I’ve created a generic JSON ValueTransformer that encodes and decodes a Codable type via JSONEncoder
and JSONDecoder
and you’ll be able to actually intercept the value with the Decodable initializer.
class JSONValueClass: NSObject {}
final class JSONValueTransformer<M>: ValueTransformer where M: Codable {
override class func transformedValueClass() -> AnyClass {
return JSONValueClass.self
}
override class func allowsReverseTransformation() -> Bool {
return true
}
override func transformedValue(_ value: Any?) -> Any? {
guard let obj = value as? M else {
return nil
}
do {
let jsonEncoder = JSONEncoder()
let data = try jsonEncoder.encode(obj)
return data
} catch {
assertionFailure("Failed to transform `JSON` to `Data`")
return nil
}
}
override func reverseTransformedValue(_ value: Any?) -> Any? {
guard let data = value as? NSData else {
return nil
}
do {
let jsonDecoder = JSONDecoder()
let obj = try jsonDecoder.decode(M.self, from: data as Data)
return obj
} catch {
assertionFailure("Failed to transform `Data` to `JSON`")
return nil
}
}
}
extension JSONValueTransformer {
static func register() {
let transformer = JSONValueTransformer()
let name = String(describing: JSONValueTransformer.self)
ValueTransformer
.setValueTransformer(
transformer,
forName: NSValueTransformerName(rawValue: name)
)
}
}
Let’s define a struct that should be used in the Model
struct MetaData: Codable {
var active: Bool = false
enum CodingKeys: String, CodingKey {
case active
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.active = try container.decode(String.self, forKey: .active)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(active, forKey: .active)
}
}
Declare a typealias to make it reusable and less likely to misstype (define it where u feel it fits)
typealias MetaDataJSONValueTransformer = JSONValueTransformer<MetaData>
We can now define our model and use the @Attribute
property wrapper with .transformable(by: )
. This will make sure our property is transformed using the ValueTransformer we’ve just declared.
@Model
final class Book {
var name: String = ""
@Attribute(.transformable(by: MetaDataJSONValueTransformer.self))
var meta: MetaData
}
Finally let’s make sure we’re registering the ValueTransformer
before we initialize ModelContainer
@main
struct ExampleApp: App {
let container: ModelContainer
init() {
// don't forget this part
MetaDataJSONValueTransformer.register()
do {
container = try ModelContainer(
for: Book.self,
migrationPlan: ExampleMigrationPlan.self
)
} catch {
fatalError("Failed to create ModelContainer")
}
// possible migration
let context = ModelContext(container)
for item in try context.fetch(FetchDescriptor<Item>()) {
// ...
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
// ...
}
5. Changes
Introducing a new property like createdAt
becomes straight forward and the behavior can be controlled
struct MetaData: Codable {
var active: Bool = false
var createdAt: Date?
// ...
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.active = try container.decode(Bool.self, forKey: .active)
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(active, forKey: .active)
try container.encode(createdAt, forKey: .createdAt)
}
}
Last but not least migrate your data in case you want to update the data
// ExampleApp.swift
init() {
// ...
// migrate
let context = ModelContext(container)
for item in try context.fetch(FetchDescriptor<Item>()) {
if item.meta.createdAt == nil {
item.meta.createdAt = .now
}
}
}
6. Summary
ValueTransformer
can be quite useful in various ways (not even just for this particularly this case) by storing / retrieving your SwiftData in custom defined behaviors.
- Cheers & Stay Motivated