├── .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 | --------------------------------------------------------------------------------