Last active
October 30, 2024 07:43
-
-
Save Thomvis/60105b0db233ecb879be1802b869a4eb to your computer and use it in GitHub Desktop.
An approach towards migrating properties in a Codable struct without having to write a custom Codable implementation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Cocoa | |
// Say we have a Person model | |
enum V1 { | |
struct Person: Codable { | |
let name: String | |
var age: Int | |
} | |
} | |
// We use it to create a person | |
let me = V1.Person(name: "Thomas", age: 32) | |
// and we store it using JSONEncoder | |
let personData = try! JSONEncoder().encode(me) | |
// In a later app version, we change the person model e.g. to better represent age, | |
// using a new type instead of a plain Int. | |
// If we would decode the json back to the new model, it would fail. | |
// We could write a custom init(encoder:) implementation that is backward compatible. | |
// But that would require us to add logic for decoding `name` as well. And we'd have to guess | |
// what the generated implementation for V1.Person looked like. | |
// | |
// Enter the "Migrated" property wrapper. It supports a migration of the old value to the new, | |
// without having to write any custom decoding logic. | |
// The definition of the second model version with that migrated property would look like this: | |
// | |
enum V2 { | |
struct Person: Codable { | |
let name: String | |
@Migrated var age: Age | |
struct Age: Codable { | |
var years: Int | |
var months: Int? | |
} | |
} | |
} | |
// We need to make the new Age type a migration target from the old type (Int). | |
extension V2.Person.Age: MigrationTarget { | |
init(migrateFrom source: Int) { | |
self.years = source | |
self.months = nil | |
} | |
} | |
// now we can decode the json data of the v1 model into the v2 model: | |
let newPerson = try! JSONDecoder().decode(V2.Person.self, from: personData) | |
newPerson.age.years // 32 | |
newPerson.age.months // nil | |
protocol MigrationTarget { | |
associatedtype Source | |
init(migrateFrom source: Source) | |
} | |
protocol MigratedWrapper { | |
associatedtype OldValue | |
associatedtype Value: MigrationTarget where Value.Source == OldValue | |
init(_ wrappedValue: Value) | |
} | |
@propertyWrapper | |
struct Migrated<OldValue, Value>: MigratedWrapper, Codable where Value: Codable, OldValue: Codable, Value: MigrationTarget, Value.Source == OldValue { | |
var wrappedValue: Value | |
init(_ wrappedValue: Value) { | |
self.wrappedValue = wrappedValue | |
} | |
init(from decoder: Decoder) throws { | |
do { | |
// try to decode a value of the new type | |
var container = try decoder.unkeyedContainer() | |
self.wrappedValue = try container.decode(Value.self) | |
return | |
} catch let newError { | |
// if that fails, we try to decode a value of the old type and migrate | |
do { | |
let old = try OldValue(from: decoder) | |
self.wrappedValue = Value(migrateFrom: old) | |
return | |
} catch let oldError { | |
throw Error.migrationFailed(newError, oldError) | |
} | |
} | |
} | |
func encode(to encoder: Encoder) throws { | |
// We need to store the new value wrapped in a container otherwise the decoder might | |
// not be able to distinguish between values of Value and OldValue that look identical | |
// when encoded (e.g. if they're both optional and nil) | |
var container = encoder.unkeyedContainer() | |
try container.encode(wrappedValue) | |
} | |
enum Error: Swift.Error { | |
case migrationFailed(Swift.Error, Swift.Error) | |
} | |
} | |
// from https://forums.swift.org/t/using-property-wrappers-with-codable/29804/12 | |
extension KeyedDecodingContainer { | |
// This is used to override the default decoding behavior for OptionalCodingWrapper to allow a value to avoid a missing key Error | |
func decode<T>(_ type: T.Type, forKey key: KeyedDecodingContainer<K>.Key) throws -> T where T: Decodable, T: MigratedWrapper, T.OldValue: OptionalProtocol { | |
return try decodeIfPresent(T.self, forKey: key) ?? T(T.Value(migrateFrom: T.OldValue.emptyOptional())) | |
} | |
} | |
protocol OptionalProtocol { | |
associatedtype Wrapped | |
static func emptyOptional() -> Self | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment