├── .github
└── workflows
│ └── swift.yml
├── .gitignore
├── .swiftpm
└── xcode
│ ├── package.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
│ └── xcshareddata
│ └── xcbaselines
│ └── VersionableTests.xcbaseline
│ ├── 4DC2FABD-30BA-45A0-9525-1F35CD77C05A.plist
│ └── Info.plist
├── LICENSE
├── Package.swift
├── README.md
├── Sources
└── Versionable
│ ├── Versionable.swift
│ ├── VersionableContainer.swift
│ └── VersionableDecoder.swift
└── Tests
└── VersionableTests
├── Stubs.swift
├── VersionableContainerTests.swift
├── VersionableDecoderTests.swift
└── VersionableTests.swift
/.github/workflows/swift.yml:
--------------------------------------------------------------------------------
1 | name: Swift
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 |
9 | jobs:
10 | build:
11 |
12 | runs-on: macos-latest
13 |
14 | steps:
15 | - uses: actions/checkout@v2
16 | - name: Build
17 | run: swift build -v
18 | - name: Run tests
19 | run: swift test -v
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcbaselines/VersionableTests.xcbaseline/4DC2FABD-30BA-45A0-9525-1F35CD77C05A.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | classNames
6 |
7 | VersionableDecoderTests
8 |
9 | testPerformanceOfHappyPath()
10 |
11 | com.apple.XCTPerformanceMetric_WallClockTime
12 |
13 | baselineAverage
14 | 0.016598
15 | baselineIntegrationDisplayName
16 | Local Baseline
17 |
18 |
19 | testPerformanceOfMigrationPath()
20 |
21 | com.apple.XCTPerformanceMetric_WallClockTime
22 |
23 | baselineAverage
24 | 0.041055
25 | baselineIntegrationDisplayName
26 | Local Baseline
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcbaselines/VersionableTests.xcbaseline/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | runDestinationsByUUID
6 |
7 | 4DC2FABD-30BA-45A0-9525-1F35CD77C05A
8 |
9 | localComputer
10 |
11 | busSpeedInMHz
12 | 100
13 | cpuCount
14 | 1
15 | cpuKind
16 | 8-Core Intel Core i9
17 | cpuSpeedInMHz
18 | 3601
19 | logicalCPUCoresPerPackage
20 | 16
21 | modelCode
22 | iMac19,1
23 | physicalCPUCoresPerPackage
24 | 8
25 | platformIdentifier
26 | com.apple.platform.macosx
27 |
28 | targetArchitecture
29 | x86_64
30 | targetDevice
31 |
32 | modelCode
33 | iPhone6,1
34 | platformIdentifier
35 | com.apple.platform.iphonesimulator
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Krzysztof Zabłocki
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.2
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "Versionable",
8 | platforms: [
9 | .iOS(.v12),
10 | .macOS(.v10_15)
11 | ],
12 | products: [
13 | // Products define the executables and libraries produced by a package, and make them visible to other packages.
14 | .library(
15 | name: "Versionable",
16 | targets: ["Versionable"]),
17 | ],
18 | dependencies: [
19 | // Dependencies declare other packages that this package depends on.
20 | // .package(url: /* package url */, from: "1.0.0"),
21 | ],
22 | targets: [
23 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
24 | // Targets can depend on other targets in this package, and on products in packages which this package depends on.
25 | .target(
26 | name: "Versionable",
27 | dependencies: []),
28 | .testTarget(
29 | name: "VersionableTests",
30 | dependencies: ["Versionable"]),
31 | ]
32 | )
33 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Versionable
2 |
3 | Example showing 2 ways to implement migrations for `Codable` models.
4 |
5 | [Related article](http://merowing.info/2020/06/adding-support-for-versioning-and-migration-to-your-codable-models./)
6 |
7 |
8 | # Protocol conformance
9 |
10 | Migrations in both ways are handled by conforming to `Versionable` protocol like so:
11 |
12 | ```swift
13 | extension Object: Versionable {
14 | enum Version: Int, VersionType {
15 | case v1 = 1
16 | case v2 = 2
17 | case v3 = 3
18 | }
19 |
20 | static func migrate(to: Version) -> Migration {
21 | switch to {
22 | case .v1:
23 | return .none
24 | case .v2:
25 | return .migrate { payload in
26 | payload["text"] = "defaultText"
27 | }
28 | case .v3:
29 | return .migrate { payload in
30 | payload["number"] = (payload["text"] as? String) == "defaultText" ? 1 : 200
31 | }
32 | }
33 | }
34 | }
35 | ```
36 |
37 | # Method 1
38 |
39 | If you are only encoding flat structures you can use provided `VersionableDecoder` to get back your models, in this approach we rely on using a special decoder for the logic, instead of swift standards `JSONDecoder`.
40 |
41 | - `+` Good for flat structures
42 | - `+` Faster than container method
43 | - `-` Won't work with composed tree hierarchy
44 |
45 | # Method 2
46 |
47 | If you want to encode whole tree's of objects (versionable object A containing other versionable objects via composition) then you want to be able to use regular system encoders, for this scenario you can wrap your objects into `VersionableContainer`, custom implementation of that container will deal with all migration logic.
48 |
49 | - `+` Works with any encoder
50 | - `+` Supports arbitrary model tree setup
51 | - `-` Slower
52 |
53 |
54 |
--------------------------------------------------------------------------------
/Sources/Versionable/Versionable.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public protocol VersionType: CaseIterable, Codable, Comparable, RawRepresentable {}
4 |
5 | public extension VersionType where RawValue: Comparable {
6 | static func < (a: Self, b: Self) -> Bool {
7 | return a.rawValue < b.rawValue
8 | }
9 | }
10 |
11 | public protocol Versionable: Codable {
12 | associatedtype Version: VersionType
13 | typealias MigrationClosure = (inout [String: Any]) -> Void
14 |
15 | static func migrate(to: Version) -> Migration
16 | static var version: Version { get }
17 |
18 | /// Persisted Version of this type
19 | var version: Version { get }
20 |
21 | static var mock: Self { get }
22 | }
23 |
24 | public extension Versionable {
25 | static var version: Version {
26 | let allCases = Version.allCases
27 | return allCases[allCases.index(allCases.endIndex, offsetBy: -1)]
28 | }
29 | }
30 |
31 | public enum Migration {
32 | case none
33 | case migrate(Versionable.MigrationClosure)
34 |
35 | func callAsFunction(_ payload: inout [String: Any]) {
36 | switch self {
37 | case .none:
38 | return
39 | case let .migrate(closure):
40 | closure(&payload)
41 | }
42 | }
43 | }
44 |
45 | struct VersionContainer: Codable {
46 | var version: Version
47 | }
48 |
49 | public extension Versionable {
50 | static var mockDirectoryRootPath: String {
51 | func simulatorOwnerUsername() -> String {
52 | //! running on simulator so just grab the name from home dir /Users/{username}/Library...
53 | let usernameComponents = NSHomeDirectory().components(separatedBy: "/")
54 | guard usernameComponents.count > 2 else { fatalError() }
55 | return usernameComponents[2]
56 | }
57 | return "/Users/\(simulatorOwnerUsername())/Desktop"
58 | }
59 |
60 | static var mockDirectoryPath: String {
61 | return "\(mockDirectoryRootPath)/\(self)/"
62 | }
63 |
64 | #if targetEnvironment(simulator)
65 | static func saveMockToFile() throws {
66 | let encoded = try JSONEncoder().encode(mock)
67 | if !FileManager.default.fileExists(atPath: mockDirectoryPath) {
68 | try FileManager.default.createDirectory(atPath: mockDirectoryPath, withIntermediateDirectories: true, attributes: nil)
69 | }
70 |
71 | try encoded.write(to: URL(fileURLWithPath: "\(mockDirectoryPath)/\(version).json"), options: .atomicWrite)
72 | }
73 | #endif
74 | }
75 |
--------------------------------------------------------------------------------
/Sources/Versionable/VersionableContainer.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public struct VersionableContainer: Codable {
4 | public let instance: Type
5 |
6 | enum CodingKeys: CodingKey {
7 | case version
8 | case instance
9 | }
10 |
11 | public init(instance: Type) {
12 | self.instance = instance
13 | }
14 |
15 | public func encode(to encoder: Encoder) throws {
16 | var container = encoder.container(keyedBy: CodingKeys.self)
17 |
18 | try container.encode(instance.version, forKey: .version)
19 |
20 | let data = try JSONEncoder().encode(instance)
21 | try container.encode(data, forKey: .instance)
22 | }
23 |
24 | public init(from decoder: Decoder) throws {
25 | let container = try decoder.container(keyedBy: CodingKeys.self)
26 | let encodedVersion = try container.decode(Type.Version.self, forKey: .version)
27 | let data = try container.decode(Data.self, forKey: .instance)
28 | let freeDecoder = JSONDecoder()
29 |
30 | if encodedVersion == Type.version {
31 | instance = try freeDecoder.decode(Type.self, from: data)
32 | return
33 | }
34 |
35 | var payload = try require(try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any])
36 |
37 | #if DEBUG
38 | let originalList = Type.Version.allCases
39 | let sorted = originalList.sorted(by: { $0 < $1 })
40 | assert(originalList.map { $0 } == sorted.map { $0 }, "\(Type.self) Versions should be sorted by their comparable order")
41 | #endif
42 |
43 | Type
44 | .Version
45 | .allCases
46 | .filter { encodedVersion < $0 }
47 | .forEach {
48 | Type.migrate(to: $0)(&payload)
49 | payload["version"] = $0.rawValue
50 | }
51 |
52 | instance = try freeDecoder.decode(Type.self, from: try JSONSerialization.data(withJSONObject: payload as Any, options: []))
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/Sources/Versionable/VersionableDecoder.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public final class VersionableDecoder {
4 | public init() {
5 | }
6 |
7 | public func decode(_ type: T.Type, from data: Data, usingDecoder decoder: JSONDecoder = .init()) throws -> T where T: Versionable {
8 | let serializedVersion = try decoder.decode(VersionContainer.self, from: data)
9 |
10 | if serializedVersion.version == type.version {
11 | return try decoder.decode(T.self, from: data)
12 | }
13 |
14 | var payload = try require(try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any])
15 |
16 | #if DEBUG
17 | let originalList = type.Version.allCases
18 | let sorted = originalList.sorted(by: { $0 < $1 })
19 | assert(originalList.map { $0 } == sorted.map { $0 }, "\(type) Versions should be sorted by their comparable order")
20 | #endif
21 |
22 | type
23 | .Version
24 | .allCases
25 | .filter { serializedVersion.version < $0 }
26 | .forEach {
27 | type.migrate(to: $0)(&payload)
28 | payload["version"] = $0.rawValue
29 | }
30 |
31 | let data = try JSONSerialization.data(withJSONObject: payload as Any, options: [])
32 | return try decoder.decode(T.self, from: data)
33 | }
34 | }
35 |
36 | internal enum RequireError: Error {
37 | case isNil
38 | }
39 |
40 | /// Lifts optional or throws requested error if its nil
41 | internal func require(_ optional: T?, or error: Error = RequireError.isNil) throws -> T {
42 | guard let value = optional else {
43 | throw error
44 | }
45 | return value
46 | }
47 |
--------------------------------------------------------------------------------
/Tests/VersionableTests/Stubs.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Versionable
3 |
4 | struct Simple {
5 | let text: String
6 | var version: Version = Self.version
7 | }
8 |
9 | extension Simple: Versionable {
10 | enum Version: Int, VersionType {
11 | case v1 = 1
12 | }
13 |
14 | static func migrate(to: Version) -> Migration {
15 | switch to {
16 | case .v1:
17 | return .none
18 | }
19 | }
20 |
21 | static var mock: Simple {
22 | .init(text: "mock")
23 | }
24 | }
25 |
26 |
27 | struct Complex {
28 | let text: String
29 | let number: Int
30 | var version: Version = Self.version
31 | }
32 |
33 | extension Complex: Versionable {
34 | enum Version: Int, VersionType {
35 | case v1 = 1
36 | case v2 = 2
37 | case v3 = 3
38 | }
39 |
40 | static func migrate(to: Version) -> Migration {
41 | switch to {
42 | case .v1:
43 | return .none
44 | case .v2:
45 | return .migrate { payload in
46 | payload["text"] = "defaultText"
47 | }
48 | case .v3:
49 | return .migrate { payload in
50 | payload["number"] = (payload["text"] as? String) == "defaultText" ? 1 : 200
51 | }
52 | }
53 | }
54 |
55 | static var mock: Complex {
56 | .init(text: "mock", number: 0)
57 | }
58 | }
59 |
60 |
--------------------------------------------------------------------------------
/Tests/VersionableTests/VersionableContainerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | //
4 | //
5 | // Created by merowing on 7/7/20.
6 | //
7 |
8 | import XCTest
9 | import Foundation
10 | import Versionable
11 |
12 | private struct Foo: Codable {
13 | let simple: VersionableContainer
14 | let complex: VersionableContainer?
15 | }
16 |
17 | final class VersionableContainerTests: XCTestCase {
18 | func testCanDecodeGivenCorrectData() throws {
19 | let data = try encode(simple: Simple(text: "payloadText"))
20 |
21 | let foo = try JSONDecoder().decode(Foo.self, from: data)
22 |
23 | XCTAssertEqual(foo.simple.instance.text, "payloadText")
24 | }
25 |
26 | func testThrowsGivenIncorrectData() {
27 | let data = encode([
28 | "version": 1,
29 | "wrong": "payloadText",
30 | ])
31 |
32 | XCTAssertThrowsError(try JSONDecoder().decode(Foo.self, from: data))
33 | }
34 |
35 | func testDecodingAutomaticallyMigratesWhenNeeded() throws {
36 | let foo = try JSONDecoder().decode(Foo.self, from: encode(complex: .init(text: "", number: 0, version: .v1)))
37 |
38 | XCTAssertEqual(foo.complex?.instance.version, Complex.version)
39 | XCTAssertEqual(foo.complex?.instance.text, "defaultText")
40 | XCTAssertEqual(foo.complex?.instance.number, 1)
41 | }
42 |
43 | func testPerformanceOfHappyPath() throws {
44 | let data = try encode(simple: Simple(text: "payloadText"))
45 | let decoder = JSONDecoder()
46 |
47 | measure {
48 | for _ in 0...1_000 {
49 | _ = try! decoder.decode(Foo.self, from: data)
50 | }
51 | }
52 | }
53 |
54 | func testPerformanceOfMigrationPath() throws {
55 | let data = try encode(complex: .init(text: "", number: 0, version: .v1))
56 | let decoder = JSONDecoder()
57 |
58 | measure {
59 | for _ in 0...1_000 {
60 | _ = try! decoder.decode(Foo.self, from: data)
61 | }
62 | }
63 | }
64 |
65 | private func encode(simple: Simple = Simple(text: ""), complex: Complex? = nil) throws -> Data {
66 | return try JSONEncoder().encode(Foo(simple: .init(instance: simple), complex: complex.flatMap({ VersionableContainer(instance: $0) })))
67 | }
68 |
69 | private func encode(_ content: [String: Any]) -> Data {
70 | return try! JSONSerialization.data(withJSONObject: content, options: [])
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Tests/VersionableTests/VersionableDecoderTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import Versionable
3 |
4 | final class VersionableDecoderTests: XCTestCase {
5 | var sut: VersionableDecoder?
6 |
7 | override func setUp() {
8 | super.setUp()
9 | sut = VersionableDecoder()
10 | }
11 |
12 | override func tearDown() {
13 | super.tearDown()
14 | sut = nil
15 | }
16 |
17 | func testCanDecodeGivenCorrectData() {
18 | let data = encode([
19 | "version": 1,
20 | "text": "payloadText",
21 | ])
22 |
23 | let foo = try! sut?.decode(Simple.self, from: data)
24 |
25 | XCTAssertEqual(foo?.text, "payloadText")
26 | }
27 |
28 | func testThrowsGivenIncorrectData() {
29 | let data = encode([
30 | "version": 1,
31 | "wrong": "payloadText",
32 | ])
33 |
34 | XCTAssertThrowsError(try sut?.decode(Simple.self, from: data))
35 | }
36 |
37 | func testDecodingAutomaticallyMigratesWhenNeeded() {
38 | let complex = try! sut?.decode(Complex.self, from: encode(["version": 1]))
39 |
40 | XCTAssertEqual(complex?.version, Complex.version)
41 | XCTAssertEqual(complex?.text, "defaultText")
42 | XCTAssertEqual(complex?.number, 1)
43 | }
44 |
45 | func testPerformanceOfHappyPath() {
46 | let data = encode([
47 | "version": 1,
48 | "text": "payloadText",
49 | ])
50 |
51 | measure {
52 | for _ in 0...1_000 {
53 | _ = try! sut?.decode(Simple.self, from: data)
54 | }
55 | }
56 | }
57 |
58 | func testPerformanceOfMigrationPath() {
59 | let data = encode([
60 | "version": 1,
61 | ])
62 |
63 | measure {
64 | for _ in 0...1_000 {
65 | _ = try! sut?.decode(Complex.self, from: data)
66 | }
67 | }
68 | }
69 |
70 | private func encode(_ content: [String: Any]) -> Data {
71 | return try! JSONSerialization.data(withJSONObject: content, options: [])
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/Tests/VersionableTests/VersionableTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import Versionable
3 |
4 | public extension Versionable {
5 | static func testMigrationPaths() throws {
6 | let files = try FileManager.default.contentsOfDirectory(atPath: mockDirectoryPath)
7 |
8 | let decoder = VersionableDecoder()
9 | files.forEach { mock in
10 | do {
11 | _ = try decoder.decode(self, from: Data(contentsOf: URL(fileURLWithPath: "\(mockDirectoryPath)/\(mock)")))
12 | }
13 | catch {
14 | XCTFail("Unable to migrate from version \((mock as NSString).lastPathComponent), error \(error)")
15 | }
16 | }
17 | }
18 | }
19 |
20 | private struct TestModel {
21 | var someData: String
22 | var version: Version = Self.version
23 | }
24 |
25 | extension TestModel: Versionable {
26 | enum Version: Int, VersionType {
27 | case v1 = 1
28 | }
29 |
30 | static func migrate(to: Version) -> Migration {
31 | switch to {
32 | case .v1:
33 | return .none
34 | }
35 | }
36 |
37 | static var mock: TestModel {
38 | .init(someData: "mock")
39 | }
40 | }
41 |
42 | final class VersionableTests: XCTestCase {
43 | func testThatModelVersionCanBeReadWithVersionContainer() {
44 | let model = TestModel(someData: "Foo", version: .v1)
45 | let encoded = try! JSONEncoder().encode(model)
46 |
47 | let decoded = try! JSONDecoder().decode(VersionContainer.self, from: encoded)
48 |
49 | XCTAssertEqual(model.version, decoded.version)
50 | }
51 | }
52 |
--------------------------------------------------------------------------------