Codable
is a great protocol for automating simple model persistence, but it lacks support for any kind of versioning or migrating the data from older versions.
You can, of course, implement custom Codable
adherence and throw in a bunch of if statements and manual decoding to achieve this goal, but isn't that kind of killing the main selling point of Codable
?
Let's look at an idea that adds Versoning yet still leverages derived Codable
.
Requirements
-
We want to be able to leverage automatically derived
Codable
implementation even as our models change over time. -
If we want to persist/decode our models without using versioning support we should be able to (leveraging pure
Codable
implementation). -
Migrations should be pure functions localized to specific Models and require minimal work to be added.
Design
First off Versionable
protocol:
public protocol VersionType: CaseIterable, Codable, Comparable, RawRepresentable {}
public protocol Versionable: Codable {
associatedtype Version: VersionType
static func migrate(to: Version) -> Migration
static var version: Version { get }
/// Persisted Version of this type
var version: Version { get }
}
You need to provide enumeration for all available model versions + a function that can migrate to each of them.
An example of how you'd adhere to this protocol is the following:
private struct Complex {
let text: String
let number: Int
var version: Version = Self.version
}
extension Complex: Versionable {
enum Version: Int, VersionType {
case v1 = 1
case v2 = 2
case v3 = 3
}
static func migrate(to: Version) -> Migration {
switch to {
case .v1:
return .none
case .v2:
return .migrate { payload in
payload["text"] = "defaultText"
}
case .v3:
return .migrate { payload in
payload["number"] = (payload["text"] as? String) == "defaultText" ? 1 : 200
}
}
}
}
Migrations are pure functions (closures) that modify the JSON Dictionary
before the actual decoding, you can add default values or derive values based on previously available data.
Now to decode this model we provide a custom VersionableDecoder
that has a single method that implements all our migration logic:
func decode<T>(type: T.Type, from data: Data, usingDecoder decoder: JSONDecoder = .init()) throws -> T where T: Versionable
The way this function works is the following:
- First checks if the persisted model has the same version as the newest model our app has if so we simply use provided
JSONDecoder
- If our persisted version doesn't match the current model version we filter out all migrations that apply to that route
- We decode the data payload into a native dictionary and route it through all migrations one by one
- We run updated payload through provided
JSONDecoder()
The whole function is simply:
public func decode<T>(_ type: T.Type, from data: Data, usingDecoder decoder: JSONDecoder = .init()) throws -> T where T: Versionable {
let serializedVersion = try decoder.decode(VersionContainer<T.Version>.self, from: data)
if serializedVersion.version == type.version {
return try decoder.decode(T.self, from: data)
}
var payload = try require(try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any])
type
.Version
.allCases
.filter { serializedVersion.version < $0 }
.forEach {
type.migrate(to: $0)(&payload)
payload["version"] = $0.rawValue
}
let data = try JSONSerialization.data(withJSONObject: payload as Any, options: [])
return try decoder.decode(T.self, from: data)
}
We can now decode our models while applying all available migrations by doing:
let model = VersionableDecoder().decode(data, type: Object.self)
Performance consideration
If you want to use something like this in production, I'd suggest changing the decoding algorithm slightly to avoid using both JSONDecoder
and JSONSerialization
.
That can be done by using a decoder that allows you to create Codable
from native dictionary directly rather than through Data
like https://github.com/elegantchaos/DictionaryCoding
You can find all source code and few tests for this prototype on my GitHub repo https://github.com/krzysztofzablocki/Versionable
Special Thanks
I've updated the article to use Enum
as a VersionType
rather than simple Integer
. This idea came from Manuel Maly 🙇🙇🙇.
This approach means that if you add a new version of the model the compiler will warn you if you forgot to add migration, compile level errors are always best safety net for human errors.