Skip to content

Instantly share code, notes, and snippets.

@Thomvis
Last active October 30, 2024 07:43
Show Gist options
  • Save Thomvis/60105b0db233ecb879be1802b869a4eb to your computer and use it in GitHub Desktop.
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
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