├── .github └── workflows │ ├── deploy_docs.yml │ └── swift.yml ├── .gitignore ├── .spi.yml ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcschemes │ └── VersionedCodable.xcscheme ├── CONTRIBUTING.md ├── LICENSE.md ├── Package.resolved ├── Package.swift ├── Package@swift-5.7.1.swift ├── README.md ├── Sources └── VersionedCodable │ ├── Foundation Extensions │ ├── JSONDecoder+versioned.swift │ ├── JSONEncoder+versioned.swift │ ├── Never+VersionedCodable.swift │ ├── PropertyListDecoder+versioned.swift │ └── PropertyListEncoder+versioned.swift │ ├── NothingEarlier.swift │ ├── PrivacyInfo.xcprivacy │ ├── VersionPathSpec.swift │ ├── VersionedCodable+decode.swift │ ├── VersionedCodable+encode.swift │ ├── VersionedCodable.docc │ ├── GettingStarted.md │ ├── Resources │ │ ├── VersionedCodable@2x.png │ │ └── VersionedCodable~dark@2x.png │ ├── VersionKeyAtRootVersionPathSpec.md │ ├── VersionPathSpec.md │ ├── VersionedCodable.md │ └── theme-settings.json │ ├── VersionedCodable.swift │ ├── VersionedDecodingError.swift │ └── VersionedEncodingError.swift └── Tests ├── VersionedCodable.xctestplan └── VersionedCodableTests ├── NeverVersionedCodableConformanceTests.swift ├── NothingEarlierConformanceConfidenceTests.swift ├── Support ├── ExamplePoems.swift ├── IsTypeMismatchVs.swift ├── PerformanceTestDocuments.swift ├── PoemDocuments.swift ├── TestCategories.swift ├── UnusualKeyPaths │ ├── Sonnets.swift │ ├── sonnet-v1.json │ └── sonnet-v2.json ├── expectedEncoded.plist ├── expectedOlder.plist └── expectedUnsupported.plist ├── UnusualVersionKeyPathsTests.swift ├── VersionedCodableJSONTests.swift ├── VersionedCodablePerformanceTests.swift └── VersionedCodablePropertyListTests.swift /.github/workflows/deploy_docs.yml: -------------------------------------------------------------------------------- 1 | name: Build documentation catalogue & deploy to GitHub Pages 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 14 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: false 18 | 19 | jobs: 20 | # Single deploy job since we're just deploying 21 | deploy: 22 | environment: 23 | name: github-pages 24 | url: ${{ steps.deployment.outputs.page_url }} 25 | runs-on: macos-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v3 29 | - name: Setup Pages 30 | uses: actions/configure-pages@v3 31 | - name: Select Xcode 16 32 | run: sudo xcode-select -s /Applications/Xcode_16.app 33 | - name: Build with Swift-DocC 34 | run: | 35 | swift package --allow-writing-to-directory _site \ 36 | generate-documentation \ 37 | --target VersionedCodable \ 38 | --disable-indexing \ 39 | --transform-for-static-hosting \ 40 | --include-extended-types \ 41 | --hosting-base-path VersionedCodable \ 42 | --output-path _site \ 43 | --source-service github \ 44 | --source-service-base-url https://github.com/jrothwell/VersionedCodable/blob/main \ 45 | --checkout-path ${GITHUB_WORKSPACE} 46 | - name: Upload artifact 47 | uses: actions/upload-pages-artifact@v1 48 | with: 49 | path: '_site' 50 | - name: Deploy to GitHub Pages 51 | id: deployment 52 | uses: actions/deploy-pages@v2 53 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Main 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: macos-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Select Xcode 16 20 | run: sudo xcode-select -s /Applications/Xcode_16.app 21 | - name: Build 22 | run: swift build -v 23 | - name: Run tests 24 | run: swift test -v --enable-code-coverage 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | external_links: 3 | documentation: "https://jrothwell.github.io/VersionedCodable/documentation/versionedcodable/" 4 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/VersionedCodable.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 48 | 49 | 50 | 51 | 53 | 59 | 60 | 61 | 62 | 63 | 73 | 74 | 80 | 81 | 87 | 88 | 89 | 90 | 92 | 93 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. 12 | 13 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 14 | 15 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 1.1.0, available at https://www.contributor-covenant.org/version/1/1/0/code-of-conduct.html 16 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jonathan Rothwell 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. -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "ab77ebabf88b4a83386ada207c7ce343dc787ecdb82149f3b0a036fcbb024481", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-docc-plugin", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-docc-plugin", 8 | "state" : { 9 | "revision" : "9b1258905c21fc1b97bf03d1b4ca12c4ec4e5fda", 10 | "version" : "1.2.0" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-docc-symbolkit", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/apple/swift-docc-symbolkit", 17 | "state" : { 18 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", 19 | "version" : "1.0.0" 20 | } 21 | } 22 | ], 23 | "version" : 3 24 | } 25 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "VersionedCodable", 7 | products: [ 8 | .library( 9 | name: "VersionedCodable", 10 | targets: ["VersionedCodable"]), 11 | ], 12 | dependencies: [ 13 | .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), 14 | ], 15 | targets: [ 16 | .target( 17 | name: "VersionedCodable", 18 | dependencies: [], 19 | resources: [ 20 | .copy("PrivacyInfo.xcprivacy") 21 | ] 22 | ), 23 | .testTarget( 24 | name: "VersionedCodableTests", 25 | dependencies: ["VersionedCodable"], 26 | resources: [ 27 | .copy("Support/expectedEncoded.plist"), 28 | .copy("Support/expectedOlder.plist"), 29 | .copy("Support/expectedUnsupported.plist"), 30 | .copy("Support/UnusualKeyPaths/sonnet-v1.json"), 31 | .copy("Support/UnusualKeyPaths/sonnet-v2.json") 32 | ] 33 | ), 34 | ] 35 | ) 36 | -------------------------------------------------------------------------------- /Package@swift-5.7.1.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "VersionedCodable", 7 | products: [ 8 | .library( 9 | name: "VersionedCodable", 10 | targets: ["VersionedCodable"]), 11 | ], 12 | targets: [ 13 | .target( 14 | name: "VersionedCodable", 15 | dependencies: [], 16 | resources: [ 17 | .copy("PrivacyInfo.xcprivacy") 18 | ]) 19 | ] 20 | ) 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VersionedCodable ![main workflow](https://github.com/jrothwell/VersionedCodable/actions/workflows/swift.yml/badge.svg) [![Swift version compatibility](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fjrothwell%2FVersionedCodable%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/jrothwell/VersionedCodable) [![Swift platform compatibility](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fjrothwell%2FVersionedCodable%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/jrothwell/VersionedCodable) 2 | 3 | A wrapper around Swift's [`Codable`](https://developer.apple.com/documentation/swift/codable) that allows you to version your `Codable` type, and facilitates incremental migrations from older versions. This handles a specific case where you want to be able to change the structure of a type, while retaining the ability to decode older versions of it. 4 | 5 | You make your types versioned by making them conform to ``VersionedCodable``. Migrations take place on a step-by-step basis (i.e. v1 to v2 to v3) which reduces the maintenance burden of making potentially breaking changes to your types. 6 | 7 | This is especially useful for document types where things regularly get added, refactored, and moved around. 8 | 9 | 10 | 11 | 12 | Three type definitions next to each other: Poem, PoemV1, and PoemPreV1. Poem has a `static let version = 2` and has a reference to PoemV1 as its `PreviousVersion`. PoemV1's version is 1 and its PreviousVersion is PoemPreV1, whose version is nil. There's also an initializer that allows a PoemV1 to be initialized from a PoemPreV1, and a PoemV2 from a `PoemV1`. 13 | 14 | 15 | 16 | You can encode and decode using extensions for `Foundation`'s built-in JSON and property list encoders/decoders. It's also easy to add support to other encoders and decoders. By default, the version key is encoded in the root of the `VersionedCodable` type: you can also specify your own version path if you need to. 17 | 18 | ## Quick Start 19 | 20 | ### You will need 21 | * A functioning computer. 22 | * Swift 5.7.1 or later. 23 | 24 | > [!NOTE] 25 | > **There is a problem with the current 1.2.x series and Swift 5.7-5.9.** This is tracked as [issue #24.](https://github.com/jrothwell/VersionedCodable/issues/24) please use the 1.1.x series if you are stuck on an older Swift version for now. 26 | 27 | ### What to do 28 | 29 | **In a Swift package:** Add this line to your `Package.swift` file's dependencies section... 30 | 31 | ```swift 32 | dependencies: [ 33 | .package(url: "https://github.com/jrothwell/VersionedCodable.git", .upToNextMinor(from: "1.1.0")) 34 | ], 35 | ``` 36 | 37 | **Or: open your project in Xcode,** pick "Package Dependencies," click "Add," and enter the URL for this repository. 38 | 39 | Read the [documentation for `VersionedCodable`, available on the Web here](https://jrothwell.github.io/VersionedCodable/documentation/versionedcodable/). If you use Xcode, it will also appear in the documentation browser. 40 | 41 | ## Problem statement 42 | Some `Codable` types might change over time, but you may still need to decode data in the old format. `VersionedCodable` allows you to retain older versions of the type and decode them as if they were the current version, using step-by-step migrations. 43 | 44 | Older versions of the type get decoded using their original decoding logic. They then get transformed into successively newer types until the decoder reaches the target type. 45 | 46 | ### Example 47 | 48 | Say you've just finished refactoring your `Poem` type, which now looks like this: 49 | 50 | ```swift 51 | struct Poem: Codable { 52 | var author: String 53 | var poem: String 54 | var rating: Rating 55 | 56 | enum Rating: String, Codable { 57 | case love, meh, hate 58 | } 59 | } 60 | ``` 61 | 62 | Encoded as JSON, this would look like: 63 | 64 | ```json 65 | { 66 | "version": 2, 67 | "author": "Anonymous", 68 | "poem": "An epicure dining at Crewe\nFound a rather large mouse in his stew", 69 | "rating": "love" 70 | } 71 | ``` 72 | 73 | However, you might still need to be able to handle documents in an older version of the format, which look like this: 74 | 75 | ```json 76 | { 77 | "version": 1, 78 | "author": "Anonymous", 79 | "poem": "An epicure dining at Crewe\nFound a rather large mouse in his stew", 80 | "starRating": 4 81 | } 82 | ``` 83 | 84 | The original type might look like this: 85 | 86 | ```swift 87 | struct OldPoem: Codable { 88 | var author: String 89 | var poem: String 90 | var starRating: Int 91 | } 92 | ``` 93 | 94 | To decode and use existing `OldPoem` JSONs, you follow the following steps: 95 | 96 | 1. Make `OldPoem` conform to `VersionedCodable`. Set its `version` to 1, and its 97 | `PreviousVersion` to `NothingEarlier`. 98 | 2. Make `Poem` conform to `VersionedCodable`. Set its `version` to 2, and its 99 | `PreviousVersion` to `OldPoem`. 100 | 3. Define an initializer for `Poem` that accepts an `OldPoem`. Define how you'll 101 | transform your older type into the newer type. 102 | 4. In places where you decode JSON versions of `Poem`, update it to use the 103 | `VersionedCodable` extensions to `Foundation`. 104 | 105 | ## How to use it 106 | 107 | You declare conformance to `VersionedCodable` like this: 108 | 109 | ```swift 110 | extension Poem: VersionedCodable { 111 | // Declare the current version. 112 | // It tells us on decoding that this type is capable of decoding itself from 113 | // an input with `"version": 3`. It also gets encoded with this version key. 114 | static let version: Int? = 3 115 | 116 | // The next oldest version of the `Poem` type. 117 | typealias PreviousVersion = PoemV2 118 | 119 | 120 | // Now we need to specify how to make a `Poem` from the previous version of the 121 | // type. For the sake of argument, we are replacing the numeric `starRating` 122 | // field with a "love/meh/hate" rating. 123 | init(from oldVersion: OldPoem) { 124 | self.author = oldVersion.author 125 | self.poem = oldVersion.poem 126 | switch oldVersion.starRating { 127 | case ...2: 128 | self.rating = .hate 129 | case 3: 130 | self.rating = .meh 131 | case 4...: 132 | self.rating = .love 133 | default: // the field is no longer valid in the new model, so we throw an error 134 | throw VersionedDecodingError.fieldNoLongerValid( 135 | DecodingError.Context( 136 | codingPath: [CodingKeys.rating], 137 | debugDescription: "Star rating \(oldVersion.starRating) is invalid") 138 | } 139 | } 140 | } 141 | ``` 142 | 143 | The chain of previous versions of the type can be as long as the call stack will allow. 144 | 145 | If you're converting an older type into a newer type and run across some data that means it no longer makes sense in the new data model, you can throw a `VersionedDecodingError.fieldNoLongerValid`. 146 | 147 | For the earliest version of the type with nothing older to try decoding, you set `PreviousVersion` to `NothingEarlier`. This is necessary to make the compiler work. Any attempts to decode a type not covered by the chain of `VersionedCodable`s will throw a `VersionedDecodingError.unsupportedVersion(tried:)`. 148 | 149 | ```swift 150 | struct PoemV1 { 151 | var author: String 152 | var poem: [String] 153 | } 154 | 155 | extension PoemOldVersion: VersionedCodable { 156 | static let version: Int? = 1 157 | 158 | typealias PreviousVersion = NothingEarlier 159 | // You don't need to provide an initializer here since you've defined `PreviousVersion` as `NothingEarlier.` 160 | } 161 | ``` 162 | 163 | ## Testing 164 | 165 | **It is a very good idea to write acceptance tests that decode old versions of your types.** This will give you confidence that all your existing data still makes sense in your current data model, and that your migrations are doing the right thing. 166 | 167 | `VersionedCodable` provides the infrastructure to make these kinds of migrations easy, but you still need to think carefully about how you map fields between different versions of your types. Type safety isn't a substitute for testing. 168 | 169 | > [!TIP] 170 | > This kind of logic is a great candidate for test driven development, since you already know what a successful input and output looks like. 171 | 172 | 173 | ## Encoding and decoding 174 | `VersionedCodable` provides thin wrappers around Swift's default `encode(_:)` and `decode(_:from:)` functions for both the JSON and property list decoders. 175 | 176 | You decode a versioned type like this: 177 | 178 | ```swift 179 | let decoder = JSONDecoder() 180 | try decoder.decode(versioned: Poem.self, from: data) // where `data` contains your old poem 181 | ``` 182 | 183 | Encoding happens like this: 184 | ```swift 185 | let encoder = JSONEncoder() 186 | encoder.encode(versioned: myPoem) // where myPoem is of type `Poem` which conforms to `VersionedCodable` 187 | ``` 188 | 189 | ## Applications 190 | 191 | This is mainly intended for situations where you are encoding and decoding complex types such as documents that live in storage somewhere (on someone's device's storage, in a document database, etc.) and can't all be migrated at once. In these cases, the format often changes, and decoding logic can often become unwieldy. 192 | 193 | `VersionedCodable` was originally developed for use in [Unspool](https://unspool.app), a photo tagging app for MacOS which is not ready for the public yet. 194 | 195 | ### Hasn't this been Sherlocked by `SwiftData`? 196 | 197 | Not really. [SwiftData](https://developer.apple.com/xcode/swiftdata/), new in iOS/iPadOS/tvOS 17, macOS 14, watchOS 10, and visionOS, is a Swifty interface over [Core Data](https://developer.apple.com/documentation/coredata). It does support schema versioning and has a number of ways to configure how you want your data persisted. It even works with `DocumentGroup`. 198 | 199 | However, there are a few limitations to consider: 200 | * `@Model` types have to be classes. This may not be appropriate if you want to use value types. 201 | * `SwiftData` is part of the OS, and **not** part of Swift's standard library like `Codable` is. If you're intending to target non-Apple platforms, or OS versions earlier than the ones released in 2023, you'll find your code doesn't compile if it references `SwiftData`. 202 | 203 | I encourage you to experiment and find the solution that works for you. But my current suggestion is: 204 | 205 | * If you need a very lightweight way of versioning your `Codable` types and will handle persistence yourself, or if you need to version value types (`struct`s instead of `class`es)---consider `VersionedCodable`. 206 | * If you're creating very complex types that have relations between them, and you're only targeting Apple platforms including and after the 2023 major versions---consider `SwiftData`. 207 | 208 | ### Is there a version for Kotlin/Java/Android? 209 | **No.** `VersionedCodable` is an open-source part of [Unspool](https://unspool.app), a photo tagging app for macOS which will not have an Android version for the foreseeable future. I don't see why it *wouldn't* be feasible to do something similar in Kotlin, but be warned that `VersionedCodable` relies heavily on Swift having a built-in encoding/decoding mechanism and an expressive type system. The JVM may make it difficult to achieve the same behaviour in a similarly safe and expressive way. 210 | 211 | ### We want to use this in our financial/medical/regulated app but need guarantees about security, provenance, non-infringement, etc. 212 | Well, I must tell you that [under the terms of the MIT licence](LICENSE.md), `VersionedCodable` 'is provided "AS IS", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement,' and 'in no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the software or the use or other dealings in the software.' 213 | 214 | As a full-time software engineer and architect for whom `VersionedCodable` is a side project, I am not in a position to spend any time providing support, fulfilling adopters' regulatory or traceability requirements, or (e.g.) helping you compile your SBOM or SOUP list. You are, of course, welcome to fork it to create a "trusted version," or create your own solution inspired by it. 215 | 216 | ## Still Missing - Wish List 217 | 218 | - [X] Allow different keypaths to the version field - **Implemented in version 1.1!** 219 | - [ ] Some kind of type solution to prevent clashes between the version field and a field in the `VersionedCodable` type at compile time. Needs more research, may not be possible with the current Swift compiler. 220 | - [ ] ~~Swift 5.9 Macros support to significantly reduce boilerplate~~ - *likely to be a separate package, probably not necessary for most adopters* 221 | - [ ] ~~(?) Potentially allow semantically versioned types. (This could be dangerous, though, as semantic versions have a very specific meaning—it's hard to see how you'd validate that v2.1 only adds to v2 and doesn't deprecate anything without some kind of static analysis, which is beyond the scope of `VersionedCodable`. It would also run the risk that backported releases to older versions would have no automatic migration path.)~~ Won't do because it increases the risk of diverging document versions with no guaranteed migration path when maintaining older versions of the system. 222 | -------------------------------------------------------------------------------- /Sources/VersionedCodable/Foundation Extensions/JSONDecoder+versioned.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension JSONDecoder { 4 | 5 | 6 | /// Returns a value of the type you specify, decoded from a JSON object, **where** the type is 7 | /// versioned. It will try and find a version of the type that matches the version (if any) encoded 8 | /// in the JSON object. 9 | /// 10 | /// This behaves in the same way as `decode(_:from:)` but also throws the 11 | /// ``VersionedDecodingError/unsupportedVersion(tried:)`` error if there are no 12 | /// versions of the type where their `version` matches what's in (or not in) `data`. 13 | /// 14 | /// - Parameters: 15 | /// - expectedType: The type of the value to decode from the supplied JSON object— 16 | /// which may be an older version. Must conform to ``VersionedCodable/VersionedCodable``. 17 | /// - data: The JSON object to decode. 18 | /// - Returns: A value of the specified type, if the decoder can parse the data. 19 | public func decode( 20 | versioned expectedType: ExpectedType.Type, 21 | from data: Data) throws -> ExpectedType { 22 | try ExpectedType.decodeTransparently(from: data, 23 | using: { try self.decode($0, from: $1) }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/VersionedCodable/Foundation Extensions/JSONEncoder+versioned.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONEncoder+versioned.swift 3 | // 4 | // 5 | // Created by Jonathan Rothwell on 14/04/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | extension JSONEncoder { 11 | /// Returns a JSON-encoded representation of the value you supply, with a `version` field that matches 12 | /// the current version of its type. 13 | /// 14 | /// This behaves identically to `encode(_:)` except it encodes your type's 15 | /// ``VersionedCodable/VersionedCodable/version``, in accordance with its ``VersionedCodable/VersionedCodable/VersionSpec``. 16 | /// By default, this will be a `version` field at the root. 17 | /// 18 | /// - Warning: We always encode the requested version of the type, with the most recent `version` 19 | /// value. If you must encode an older version, then encode that type directly: don't try to add a 20 | /// `version` property to your type & try to modify that. The behaviour if you do so is undefined. 21 | /// - Parameter value: The value to encode as a JSON object. Must conform to ``VersionedCodable/VersionedCodable``. 22 | /// - Returns: The encoded JSON object, complete with a `version` field. 23 | public func encode(versioned value: any VersionedCodable) throws -> Foundation.Data { 24 | try value.encodeTransparently { try self.encode($0) } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/VersionedCodable/Foundation Extensions/Never+VersionedCodable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Never+VersionedCodable.swift 3 | // VersionedCodable 4 | // 5 | // Created by Jonathan Rothwell on 01/10/2024. 6 | // 7 | 8 | @available(swift, introduced: 5.9) 9 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 10 | extension Never: VersionedCodable { 11 | public typealias PreviousVersion = NothingEarlier 12 | 13 | public static var version: Int? { 14 | nil 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/VersionedCodable/Foundation Extensions/PropertyListDecoder+versioned.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PropertyListDecoder+versioned.swift 3 | // 4 | // 5 | // Created by Jonathan Rothwell on 18/04/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | extension PropertyListDecoder { 11 | /// Returns a value of the type you specify, decoded from a property list, where the type is 12 | /// versioned. It will try and find a version of the type that matches the version (if any) encoded 13 | /// in the property list. 14 | /// 15 | /// This behaves in the same way as `decode(_:from:)` but also throws the 16 | /// ``VersionedDecodingError/unsupportedVersion(tried:)`` error if there are no 17 | /// versions of the type where their `version` matches what's in (or not in) `data`. 18 | /// 19 | /// - Parameters: 20 | /// - expectedType: The type of the value to decode from the supplied property list— 21 | /// which may be an older version. 22 | /// - data: The property list to decode. 23 | /// - Returns: A value of the specified type, if the decoder can parse the data. 24 | public func decode( 25 | versioned expectedType: ExpectedType.Type, 26 | from data: Data) throws -> ExpectedType { 27 | try ExpectedType.decodeTransparently(from: data, 28 | using: { try self.decode($0, from: $1) }) 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /Sources/VersionedCodable/Foundation Extensions/PropertyListEncoder+versioned.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PropertyListEncoder+versioned.swift 3 | // 4 | // 5 | // Created by Jonathan Rothwell on 18/04/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | extension PropertyListEncoder { 11 | /// Returns a property list that represents an encoded version of the value you supply, with a 12 | /// `version` field that matches the current version of its type. 13 | /// 14 | /// This behaves identically to `encode(_:)` except it encodes your type's 15 | /// ``VersionedCodable/VersionedCodable/version``, in accordance with its ``VersionedCodable/VersionedCodable/VersionSpec``. 16 | /// By default, this will be a `version` field at the root. 17 | /// 18 | /// - Warning: We always encode the requested version of the type, with the most recent `version` 19 | /// value. If you must encode an older version, then encode that type directly: don't try to add a 20 | /// `version` property to your type & try to modify that. The behaviour if you do so is undefined. 21 | /// 22 | /// - Parameter value: The value to encode as a property list. Must conform to 23 | /// ``VersionedCodable`` and thus supply a ``VersionedCodable/version`` value. 24 | /// - Returns: The encoded property list, complete with a `version` field. 25 | public func encode(versioned value: any VersionedCodable) throws -> Foundation.Data { 26 | try value.encodeTransparently { try self.encode($0) } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/VersionedCodable/NothingEarlier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NothingEarlier.swift 3 | // 4 | // 5 | // Created by Jonathan Rothwell on 15/04/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | /// The type to indicate that a ``VersionedCodable/VersionedCodable`` does **not** have any 11 | /// older versions, and the decoder should stop trying to decode it. 12 | /// 13 | /// You typically use this when creating a new ``VersionedCodable/VersionedCodable`` type, or 14 | /// conforming an existing type to ``VersionedCodable/VersionedCodable``. Set it as the 15 | /// ``VersionedCodable/VersionedCodable/PreviousVersion`` of any type that has no previous 16 | /// version. 17 | /// 18 | /// ## Discussion 19 | /// The behaviour of ``NothingEarlier``'s ``VersionedCodable`` conformances is similar to 20 | /// how `Never` conforms to `Codable`, as defined in [SE-0396](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0396-never-codable.md) 21 | /// and implemented in Swift 5.9. But generally you don't need to care about this—because you will never 22 | /// try to (or be able to) encode or decode a `NothingEarlier` type. 23 | /// 24 | /// - SeeAlso: ``VersionedCodable/VersionedCodable/PreviousVersion`` 25 | /// - Important: There's no way to create an instance of ``NothingEarlier``. It's an *uninhabited type*, 26 | /// similar to `Never` in the standard library. 27 | /// - Warning: Don't decode or initialize ``NothingEarlier``. The behaviour on decoding and 28 | /// encoding is undefined and may result in a crash. 29 | public enum NothingEarlier {} 30 | 31 | extension VersionedCodable where PreviousVersion == NothingEarlier { 32 | // For the oldest type, this avoids people having to declare a useless 33 | // initializer from `NothingEarlier`. 34 | 35 | /// - Warning: Do not invoke this initializer. The behaviour on initialization is undefined: in future it 36 | /// may result in an unrecoverable fatal error or assertion failure. 37 | public init(from: NothingEarlier) throws { 38 | throw VersionedDecodingError.unsupportedVersion(tried: Self.self) 39 | } 40 | } 41 | 42 | extension NothingEarlier: VersionedCodable { 43 | public typealias PreviousVersion = NothingEarlier 44 | public static let version: Int? = nil 45 | 46 | 47 | /// - Warning: Do not try to decode ``NothingEarlier``. The behvaiour if you do so is 48 | /// undefined. In future it may result in an unrecoverable fatal error or assertion failure. 49 | public init(from decoder: Decoder) throws { 50 | let context = DecodingError.Context( 51 | codingPath: decoder.codingPath, 52 | debugDescription: "Unable to decode an instance of NothingEarlier." 53 | ) 54 | throw DecodingError.typeMismatch(NothingEarlier.self, context) 55 | } 56 | 57 | /// - Note: It is impossible to encode ``NothingEarlier`` because ``NothingEarlier`` is 58 | /// an uninhabited type, similar to `Never` in the Swift standard library---so you can never have 59 | /// an instance of ``NothingEarlier`` to encode. 60 | public func encode(to encoder: Encoder) throws { } 61 | } 62 | 63 | -------------------------------------------------------------------------------- /Sources/VersionedCodable/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyCollectedDataTypes 6 | 7 | NSPrivacyAccessedAPITypes 8 | 9 | NSPrivacyTrackingDomains 10 | 11 | NSPrivacyTracking 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Sources/VersionedCodable/VersionPathSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VersionPathSpec.swift 3 | // 4 | // 5 | // Created by Jonathan Rothwell on 23/01/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Describes how to decode or encode the version of a ``VersionedCodable`` type. 11 | public protocol VersionPathSpec: Codable { 12 | /// The key path to the version of this type. Used to find the version key during decoding. 13 | /// 14 | /// Generally you declare this as a `static let`, for instance, like this: 15 | /// 16 | /// ```swift 17 | /// static let keyPathToVersion = \Self.metadata.version 18 | /// ``` 19 | /// - Important: It is your responsibility to make sure that `keyPathToVersion` is immutable 20 | /// and does not change. The behaviour if it does change mid-execution is undefined. It's not a good 21 | /// idea to declare `keyPathToVersion` as a computed property, or a `var` which the caller 22 | /// can then change. Nor should you capture the `KeyPath` and mutate it elsewhere. 23 | nonisolated(unsafe) static var keyPathToVersion: KeyPath { get } 24 | 25 | /// Initializes the type with the provided version. 26 | /// - Parameter version: The version of the document being encoded. 27 | /// - Note: Generally you will not need to initialize this directly. 28 | init(withVersion version: Int?) 29 | } 30 | 31 | /// Describes how to encode and decode the version of a ``VersionedCodable`` type where the version is encoded at the root of the type in a field called `version`. 32 | public struct VersionKeyAtRootVersionPathSpec: Codable, VersionPathSpec { 33 | public static let keyPathToVersion: KeyPath = \Self.version 34 | public init(withVersion version: Int? = nil) { 35 | self.version = version 36 | } 37 | 38 | let version: Int? 39 | } 40 | 41 | 42 | -------------------------------------------------------------------------------- /Sources/VersionedCodable/VersionedCodable+decode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VersionedCodable+decode.swift 3 | // 4 | // 5 | // Created by Jonathan Rothwell on 29/04/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | extension VersionedCodable { 11 | 12 | /// Returns a value of the type you specify, where the type is versioned, delegating the 13 | /// decoding to `decode` function you provide. It will try and find a version of the type that 14 | /// matches the version (if any) encoded in the data and transparently decode it. 15 | /// - Parameters: 16 | /// - data: The data to decode. Will be passed to `decode`. 17 | /// - decode: A function capable of decoding a `Decodable` type from `data`. 18 | /// - Returns: A value of the specified type, if the `decode` function can parse the data. 19 | /// - Warning: The return type of `decode` is **not** constrained to the expected 20 | /// type (as provided as its first parameter.) Returning a type which cannot be downcast 21 | /// to the expected type has undefined behaviour and will result in a crash. 22 | public static func decodeTransparently( 23 | from data: Data, 24 | using decode: ((Decodable.Type, Data) throws -> Decodable) 25 | ) throws -> ExpectedType { 26 | let versionPathSpec = try decode(VersionSpec.self, data) as? VersionSpec 27 | let version = versionPathSpec.flatMap { $0[keyPath: VersionSpec.keyPathToVersion] } 28 | return try decodeTransparently(targetVersion: version, 29 | from: data, 30 | using: decode) 31 | } 32 | 33 | private static func decodeTransparently( 34 | targetVersion: Int?, 35 | from data: Data, 36 | using decode: ((Decodable.Type, Data) throws -> Decodable) 37 | ) throws -> ExpectedType { 38 | if targetVersion == Self.version { 39 | return try decode(Self.self, data) as! ExpectedType 40 | } else if Self.PreviousVersion.self == NothingEarlier.self { 41 | throw VersionedDecodingError.unsupportedVersion(tried: Self.self) 42 | } else { 43 | return try ExpectedType( 44 | from: Self.PreviousVersion.decodeTransparently( 45 | targetVersion: targetVersion, 46 | from: data, 47 | using: decode)) 48 | } 49 | 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/VersionedCodable/VersionedCodable+encode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VersionedCodable+encode.swift 3 | // 4 | // 5 | // Created by Jonathan Rothwell on 29/04/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | extension VersionedCodable { 11 | /// Returns an encoded representation of `self`, with a `version` field that 12 | /// matches the current version of its type, delegating the encoding to the `encode` function 13 | /// you provide. 14 | /// 15 | /// 16 | /// - Warning: We always encode the requested version of the type, with the most recent `version` 17 | /// value. If you must encode an older version, then encode that type directly: don't try to add a 18 | /// `version` property to your type & try to modify that. The behaviour if you do so is undefined. 19 | /// - Parameter encode: The value to encode. Must conform to ``VersionedCodable``. 20 | /// - Returns: `self`, encoded by the `encode` parameter. 21 | public func encodeTransparently(using encode: (Encodable) throws -> Data) rethrows -> Data { 22 | try encode(VersionedCodableWritingWrapper(wrapped: self, spec: VersionSpec.self)) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/VersionedCodable/VersionedCodable.docc/GettingStarted.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | Make a `Codable` type conform to ``VersionedCodable/VersionedCodable``, build a new version to try an incremental migration, and learn to encode and decode it. 4 | 5 | ## Overview 6 | 7 | Some `Codable` types might change over time, but you may still need to decode data in the old format. `VersionedCodable` allows you to retain older versions of the type and decode them as if they were the current version, using step-by-step migrations. 8 | 9 | Older versions of the type get decoded using their original decoding logic. They then get transformed into successively newer types until the decoder reaches the target type. 10 | 11 | ![Three type definitions next to each other: Poem, PoemV1, and PoemPreV1. Poem has a `static let version = 2` and has a reference to PoemV1 as its `PreviousVersion`. PoemV1's version is 1 and its PreviousVersion is PoemPreV1, whose version is nil. There's also an initializer that allows a PoemV1 to be initialized from a PoemPreV1, and a PoemV2 from a `PoemV1`.](VersionedCodable.png) 12 | 13 | * When you encode a ``VersionedCodable/VersionedCodable`` type using the extensions on the `Foundation` encoders or using ``VersionedCodable/VersionedCodable/encodeTransparently(using:)``, it encodes an additional `version` key (by default, at the root of the type as a sibling to the other keys.) This matches the value of ``VersionedCodable/VersionedCodable/version``. 14 | * When you decode a type in the using the extensions on the `Foundation` decoders or using ``VersionedCodable/VersionedCodable/decodeTransparently(from:using:)``, it checks to see if the ``VersionedCodable/VersionedCodable/version`` property of the type matches the `version` field in the data it wants to decode. 15 | * **If it matches,** then it decodes the type in the usual way. 16 | * **If it doesn't match, it then checks ``VersionedCodable/VersionedCodable/PreviousVersion``.** If the previous type's ``VersionedCodable/VersionedCodable/version`` matches the `version` key of the data, it decodes it, and then converts this into the current type using the initializer you provide as part of ``VersionedCodable/VersionedCodable`` conformance. 17 | * **If ``VersionedCodable/VersionedCodable/PreviousVersion`` is ``NothingEarlier``, it cannot decode the type** because this *is* the earliest possible version. It throws a ``VersionedDecodingError/unsupportedVersion(tried:)``. 18 | 19 | ## Making your types versioned 20 | You make your type versioned by making it conform to ``VersionedCodable/VersionedCodable``. This inherits from `Codable` and adds new requirements where you specify: 21 | 22 | - The current version number of the type (``VersionedCodable/VersionedCodable/version``.) This may be `nil`, to account for cases where you need to decode examples from before you adopted ``VersionedCodable``. 23 | - What the type of the *previous* version is (``VersionedCodable/PreviousVersion``.) If you're using the oldest version, you set this to ``NothingEarlier``. 24 | - An initializer for the current type which accepts the previous version of the type. 25 | - *(optionally)* a specification for where to encode and decode the version key (``VersionedCodable/VersionedCodable/VersionSpec``.) By default, it assumes you have a `version` key at the root of the type. If necessary, you customise this behaviour by providing your own implementation of ``VersionPathSpec``. 26 | 27 | Consider a `Poem` type which we want to change, which currently looks like this: 28 | 29 | ```swift 30 | struct Poem: Codable { 31 | var author: String 32 | var poem: String 33 | var starRating: Int 34 | } 35 | ``` 36 | 37 | ### Adding VersionedCodable conformance to an existing type 38 | 39 | You conform to ``VersionedCodable/VersionedCodable`` by specifying: 40 | 41 | - ``VersionedCodable/VersionedCodable/version``: `nil` in this case, since our existing Poems don't have a `version` key 42 | - ``VersionedCodable/VersionedCodable/PreviousVersion``: Since this *is* the oldest version of the type, this is ``NothingEarlier``. 43 | - Since there is no earlier version, you don't need to provide an initializer from ``NothingEarlier`` (this wouldn't make sense anyway.) 44 | 45 | So an extension might look like this: 46 | 47 | ```swift 48 | extension Poem: VersionedCodable { 49 | static let version: Int? = nil 50 | typealias PreviousVersion = NothingEarlier 51 | } 52 | ``` 53 | 54 | You can now decode and encode the type using the versioned extensions to Foundation's built-in encoders and decoders. 55 | 56 | ### Creating a new version of the type 57 | Now let's say our product owner has decided that we don't want to use star ratings any more. Instead we want to have a love/hate/neutral field. If there are any star ratings outside the 0...5 range, that's now considered invalid, so trying to decode this will produce an error. 58 | 59 | Let's start by making a copy of our existing type, calling it `OldPoem`, making it `private`, and putting it out of the way: 60 | 61 | ```swift 62 | private struct OldPoem: VersionedCodable { 63 | static let version: Int? = nil 64 | typealias PreviousVersion = NothingEarlier 65 | 66 | var author: String 67 | var poem: String 68 | var starRating: Int 69 | } 70 | ``` 71 | 72 | Now let's change our existing type to fit our new requirement: 73 | 74 | ```swift 75 | struct Poem: Codable { 76 | var author: String 77 | var poem: String 78 | var rating: Rating 79 | 80 | enum Rating: String, Codable { 81 | case love, meh, hate 82 | } 83 | } 84 | ``` 85 | 86 | Now you need to: 87 | * increment the version number (let's set it to 1) 88 | * indicate that `OldPoem` is the previous version of `Poem` 89 | * provide an initializer for `Poem` that accepts an `OldPoem`---doing the necessary transformation on `rating` 90 | 91 | ```swift 92 | extension Poem: VersionedCodable { 93 | static let version: Int? = 1 94 | typealias PreviousVersion = OldPoem 95 | 96 | init(from oldVersion: OldPoem) { 97 | self.author = oldVersion.author 98 | self.poem = oldVersion.poem 99 | switch oldVersion.starRating { 100 | case ...2: 101 | self.rating = .hate 102 | case 3: 103 | self.rating = .meh 104 | case 4...: 105 | self.rating = .love 106 | default: // the field is no longer valid 107 | throw VersionedDecodingError.fieldNoLongerValid( 108 | DecodingError.Context( 109 | codingPath: [CodingKeys.rating], 110 | debugDescription: "Star rating \(oldVersion.starRating) is invalid") 111 | } 112 | } 113 | } 114 | ``` 115 | 116 | ## About testing 117 | 118 | It is a very good idea to write acceptance tests that decode old versions of your types. ``VersionedCodable/VersionedCodable`` provides the types and logic to make this kind of migration easy, **but** you still need to think carefully about how you map fields between different versions of your types. Type safety isn't a substitute for testing. 119 | 120 | A comprehensive set of test cases will give you confidence that: 121 | - you can still decode earlier versions of documents 122 | - what comes out of them is what you expect. 123 | 124 | - Tip: Although we haven't used it in this example, this kind of encoding and decoding logic is a great candidate for test driven development. 125 | 126 | ## Decoding a versioned type 127 | ``VersionedCodable`` provides extensions to Foundation's built-in `JSONDecoder` and `PropertyListDecoder` types to allow you decode these out of the box, like this: 128 | 129 | ```swift 130 | let poem = try JSONDecoder().decode(versioned: Poem.self, from: oldPoem) 131 | ``` 132 | 133 | ## Encoding a versioned type 134 | 135 | When encoding, the version key is encoded **after** the other keys in the `Encodable`. 136 | 137 | ```swift 138 | let data = try JSONEncoder().encode(versioned: poem) 139 | ``` 140 | 141 | - Warning: ``VersionedCodable`` can't guarantee at compile or run time that there isn't a clashing `version` field on the type you're encoding. It is your responsibility to make sure there is no clash. Don't try to set the version number of an instance manually. The behaviour is undefined if you do so. 142 | 143 | ## Decoding and encoding things other than JSON and property lists 144 | 145 | For other kinds of encoders and decoders you need to do a little more work, but not much. Define an extension on your decoder type to use ``VersionedCodable``'s logic to determine which type to decode: 146 | 147 | ```swift 148 | extension MagneticTapeDecoder { 149 | public func decode( 150 | versioned expectedType: ExpectedType.Type, 151 | from data: Data) throws -> ExpectedType { 152 | try ExpectedType.decodeTransparently( 153 | from: data, 154 | using: { try self.decode($0, from: $1) }) // delegate decoding to your decoder's usual logic 155 | } 156 | } 157 | ``` 158 | 159 | And for the encoder, you need to define this extension: 160 | 161 | ```swift 162 | extension MagneticTapeEncoder { 163 | public func encode(versioned value: any VersionedCodable) throws -> Foundation.Data { 164 | try value.encodeTransparently { try self.encode($0) } // delegate encoding to your encoder's usual logic 165 | } 166 | } 167 | ``` 168 | 169 | Internally this uses your encoding function to encode a wrapper type, which encodes all the keys of your contained type followed by the `version` key. But you don't need to define this logic yourself. 170 | -------------------------------------------------------------------------------- /Sources/VersionedCodable/VersionedCodable.docc/Resources/VersionedCodable@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrothwell/VersionedCodable/7c077929fd9c210ee5a6eade3bae89c8a6e863bc/Sources/VersionedCodable/VersionedCodable.docc/Resources/VersionedCodable@2x.png -------------------------------------------------------------------------------- /Sources/VersionedCodable/VersionedCodable.docc/Resources/VersionedCodable~dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrothwell/VersionedCodable/7c077929fd9c210ee5a6eade3bae89c8a6e863bc/Sources/VersionedCodable/VersionedCodable.docc/Resources/VersionedCodable~dark@2x.png -------------------------------------------------------------------------------- /Sources/VersionedCodable/VersionedCodable.docc/VersionKeyAtRootVersionPathSpec.md: -------------------------------------------------------------------------------- 1 | # ``VersionKeyAtRootVersionPathSpec`` 2 | 3 | Types encoded with this ``VersionPathSpec`` look like this: 4 | 5 | ```json 6 | { 7 | "name": "Charlie Smith", 8 | "version": 1 9 | } 10 | ``` 11 | 12 | This is the default behaviour of ``VersionedCodable``. If you are creating a new type, you might want to adopt this behaviour, which requires no additional work over just conforming your type to ``VersionedCodable`` in the usual way. 13 | 14 | - Note: If you're decoding existing documents with the version field in a different place, or if the behaviour provided by ``VersionKeyAtRootVersionPathSpec`` is unacceptable for any other reason, you can implement your own ``VersionPathSpec``. 15 | 16 | - Warning: ``VersionedCodable`` can't guarantee at compile or run time that there isn't a clashing `version` field on the type on which ``VersionKeyAtRootVersionPathSpec`` is used. As always, the version number is transparent at the point of use---you should not try to set it manually. 17 | -------------------------------------------------------------------------------- /Sources/VersionedCodable/VersionedCodable.docc/VersionPathSpec.md: -------------------------------------------------------------------------------- 1 | # ``VersionPathSpec`` 2 | 3 | @Metadata { 4 | @Available("VersionedCodable", introduced: "1.1") 5 | } 6 | 7 | ## Overview 8 | 9 | Specifies how the coder will encode and decode versions of your ``VersionedCodable/VersionedCodable`` type. 10 | 11 | By default, the encoder and decoder will use ``VersionKeyAtRootVersionPathSpec``, which will expect a `version` field at the root of the type. 12 | 13 | If you need to customise this behaviour, you can create a custom implementation of ``VersionPathSpec``: 14 | 15 | 1. Create a `Codable` type in the usual way and make it conform to ``VersionPathSpec``. 16 | 2. Implement ``VersionPathSpec/keyPathToVersion``, specifying the `KeyPath` where the version field can be found. 17 | 18 | ## Example 19 | 20 | Consider a document in this format: 21 | 22 | ```json 23 | { 24 | "recipeTitle": "Potato dauphinoise", 25 | "_metadata": { 26 | "lastModifiedBy": "Sophie", 27 | "version": 2 28 | } 29 | } 30 | ``` 31 | 32 | You start by implementing `Codable` in the usual way: 33 | 34 | ```swift 35 | struct RecipeV2: Codable { 36 | var recipeTitle: String 37 | var lastModifiedBy: String 38 | 39 | struct RawRecipeV2: Codable { 40 | var recipeTitle: String 41 | var _metadata: Metadata 42 | 43 | struct Metadata: Codable { 44 | var lastModifiedBy: String 45 | } 46 | } 47 | 48 | init(from decoder: Decoder) throws { 49 | let container = try decoder.singleValueContainer() 50 | let rawRecipe = try container.decode(RawRecipeV2.self) 51 | self.recipeTitle = rawRecipe.recipeTitle 52 | self.lastModifiedBy = rawRecipe._metadata.lastModifiedBy 53 | } 54 | 55 | func encode(to encoder: Encoder) throws { 56 | var container = encoder.singleValueContainer() 57 | try container.encode( 58 | RawRecipeV2(recipeTitle: self.recipeTitle, 59 | _metadata: .init(lastModifiedBy: self.lastModifiedBy)) 60 | ) 61 | } 62 | } 63 | ``` 64 | 65 | Now you create a **second** `Codable` type detailing how to encode the version, and the version **only**. Make this conform to ``VersionPathSpec``. 66 | 67 | ```swift 68 | private struct RecipeV2VersionPath: VersionPathSpec { 69 | static let keyPathToVersion: KeyPath = \Self._metadata.version 70 | 71 | let _metadata: Metadata 72 | struct Metadata: Codable { 73 | var version: Int? 74 | } 75 | 76 | init(withVersion version: Int?) { 77 | self._metadata = Metadata(version: version) 78 | } 79 | } 80 | ``` 81 | 82 | Now you need to conform your original type to ``VersionedCodable``: 83 | 84 | ```swift 85 | extension RecipeV2: VersionedCodable { 86 | static let version: Int? = 2 87 | typealias PreviousVersion = RecipeV1 88 | 89 | typealias VersionSpec = RecipeV2VersionPath 90 | } 91 | ``` 92 | 93 | - Warning: ``VersionedCodable`` can't guarantee at compile or run time that there isn't a clashing version field on the type on which any ``VersionPathSpec`` is used. As always, the version number is transparent at the point of use---you should not try to set it manually. 94 | 95 | 96 | ## Topics 97 | - ``VersionPathSpec/keyPathToVersion`` 98 | 99 | ## See Also 100 | - ``VersionKeyAtRootVersionPathSpec`` 101 | -------------------------------------------------------------------------------- /Sources/VersionedCodable/VersionedCodable.docc/VersionedCodable.md: -------------------------------------------------------------------------------- 1 | # ``VersionedCodable`` 2 | 3 | A wrapper around Swift's `Codable` that lets you version your types, and facilitates migrations from older versions. 4 | 5 | You make your types versioned by making them conform to ``VersionedCodable/VersionedCodable``. Migrations take place on a step-by-step basis (i.e. v1 to v2 to v3) which reduces the maintenance burden of making potentially breaking changes to your types. 6 | 7 | This is especially useful for document types where things regularly get added, refactored, and moved around. 8 | 9 | ### Encoding and decoding 10 | 11 | Decode and encode using the version-aware extensions to `Foundation`'s built-in property list encoders and decoders, or by extending your own decoders using ``VersionedCodable/VersionedCodable/decodeTransparently(from:using:)`` and ``VersionedCodable/VersionedCodable/encodeTransparently(using:)``. 12 | 13 | The version number is transparent at the point of use. You don't need to worry about it and shouldn't try to set it manually. 14 | 15 | ### Behaviour 16 | 17 | By default, it encodes a version number for the type as a sibling to the other fields: 18 | 19 | ```json 20 | { 21 | "version": 1, 22 | "author": "Anonymous", 23 | "poem": "An epicure dining at Crewe\\nFound a rather large mouse in his stew" 24 | } 25 | ``` 26 | 27 | If this behaviour is not acceptable, you can implement ``VersionPathSpec`` to customise where the version number is encoded and decoded. 28 | 29 | Version numbers are always of type `Int?`. You can adopt `VersionedCodable` in a situation where you already have existing saved or encoded types by setting ``VersionedCodable/VersionedCodable/version`` to `nil`. 30 | 31 | - Note: Other `Comparable` types are not supported, including semantic versions. This is because there is no way to guarantee no breaking changes in minor or patch versions, and therefore no way to guarantee an exhaustive migration path. 32 | 33 | ### Testing 34 | 35 | It's a very good idea to write acceptance tests for decoding old versions of your types. 36 | 37 | ``VersionedCodable/VersionedCodable`` provides the types and logic to make this kind of migration easy, **but** you still need to think carefully about how you map fields between different versions of your types. 38 | 39 | A comprehensive set of test cases will give you confidence that: 40 | 41 | * you can still decode earlier versions of documents 42 | * what comes out of them is what you expect 43 | 44 | - Tip: This kind of encoding and decoding logic is a great candidate for test driven development. 45 | 46 | ## Topics 47 | 48 | ### Essentials 49 | - 50 | - ``VersionedCodable/VersionedCodable`` 51 | 52 | ### Version field specifications 53 | - ``VersionPathSpec`` 54 | - ``VersionKeyAtRootVersionPathSpec`` 55 | - ``VersionedCodable/VersionedCodable/VersionSpec`` 56 | 57 | ### Handling Errors 58 | - ``VersionedEncodingError`` 59 | - ``VersionedDecodingError`` 60 | - ``NothingEarlier`` 61 | -------------------------------------------------------------------------------- /Sources/VersionedCodable/VersionedCodable.docc/theme-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme": { 3 | "color": { 4 | "hue-viridian": "161", 5 | "documentation-intro-fill": { 6 | "dark": "hsl(var(--color-hue-viridian), 100%, 10%)", 7 | "light": "hsl(var(--color-hue-viridian), 100%, 90%)" 8 | }, 9 | }, 10 | "typography": { 11 | "html-font": "-apple-system-ui-serif, ui-serif, 'Georgia', serif" 12 | } 13 | }, 14 | "meta": { 15 | "title": "VersionedCodable Documentation", 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/VersionedCodable/VersionedCodable.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | /// A type that can convert itself into and out of an external representation, which is versioned and can be 5 | /// decoded from old versions of itself. 6 | /// 7 | /// Should be used with the extensions on the Foundation decoders (e.g. ``Foundation/JSONEncoder/encode(versioned:)``, 8 | /// ``Foundation/PropertyListDecoder/decode(versioned:from:)``.) 9 | /// 10 | /// You can also implement support in other decoders using ``encodeTransparently(using:)`` and ``decodeTransparently(from:using:)``. 11 | /// 12 | /// ## Decoding 13 | /// If the ``version`` field matches the version field on the encoded type (also an optional `Int`), 14 | /// this type will be the one that is decoded from the rest of the document. 15 | /// 16 | /// ## Encoding 17 | /// Upon encoding, the type will be encoded as normal and then an additional version field will be 18 | /// encoded with the contents of ``version``. 19 | /// 20 | /// - Note: ``version`` is optional, to account for versions of the type that were created and encoded 21 | /// before you adopted ``VersionedCodable`` (hence making any encoded `version` value `nil`.) 22 | public protocol VersionedCodable: Codable { 23 | 24 | /// The current version of this type. This is what is encoded into the `version` key on this type 25 | /// when it is encoded. 26 | /// 27 | /// Generally you declare it as a `static let`, for instance: 28 | /// 29 | /// ```swift 30 | /// static let version = 3 31 | /// ``` 32 | /// 33 | /// - Note: It's possible for this to be `nil`, to account for versions of the type that were created and 34 | /// encoded/persisted to disk before you adopted ``VersionedCodable``. 35 | /// - Important: It is your responsibility to make sure that `version` is immutable and does not 36 | /// change. The behaviour if it does change mid-execution is undefined. It's not a good idea to 37 | /// declare `version` as a computed property, or a `var` which the caller can then change. 38 | static var version: Int? { get } 39 | 40 | /// The next oldest version of this type, or ``NothingEarlier`` if this *is* the oldest version. 41 | /// - Note: If this **is** the oldest version of the type, then use ``NothingEarlier``. This 42 | /// signals the decoder to throw an error if it can't get a match for this version. 43 | associatedtype PreviousVersion: VersionedCodable 44 | 45 | /// The ``VersionSpec`` used to determine how to encode and decode the version number of this 46 | /// type. 47 | associatedtype VersionSpec: VersionPathSpec = VersionKeyAtRootVersionPathSpec 48 | 49 | /// Initializes a new instance of this type from a type of ``PreviousVersion``. This is where to do 50 | /// mapping between the old and new version of the type. 51 | /// - Note: You don't need to provide this if ``PreviousVersion`` is ``NothingEarlier``. 52 | init(from: PreviousVersion) throws 53 | } 54 | 55 | 56 | struct VersionedCodableWritingWrapper: Encodable { 57 | var wrapped: any VersionedCodable 58 | var spec: any VersionPathSpec.Type 59 | 60 | func encode(to encoder: Encoder) throws { 61 | try wrapped.encode(to: encoder) 62 | try spec.init(withVersion: type(of: wrapped).version).encode(to: encoder) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/VersionedCodable/VersionedDecodingError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VersionedDecodingError.swift 3 | // 4 | // 5 | // Created by Jonathan Rothwell on 18/04/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A problem that occurs during the decoding of a ``VersionedCodable``. 11 | /// - Note: The decoding of a ``VersionedCodable`` can also result in the same kinds of errors 12 | /// that are thrown during the decoding of any other `Codable`. 13 | public enum VersionedDecodingError: Error { 14 | 15 | /// A field that was valid is no longer valid , such that this value no longer makes any sense in 16 | /// the newer version of the type. 17 | /// 18 | /// Used in `VersionedCodable.init(from: PreviousVersion)`. 19 | /// 20 | /// - Parameter context: A decoding context to indicate the coding path to the offending field 21 | /// and (optionally) the error that stopped decoding. 22 | case fieldNoLongerValid(DecodingError.Context) 23 | 24 | /// There is no previous version available to attempt decoding, so this type cannot be decoded. 25 | /// - Parameter tried: The last ``VersionedCodable`` attempted. 26 | case unsupportedVersion(tried: any VersionedCodable.Type) 27 | } 28 | -------------------------------------------------------------------------------- /Sources/VersionedCodable/VersionedEncodingError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VersionedEncodingError.swift 3 | // 4 | // 5 | // Created by Jonathan Rothwell on 22/04/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A problem that occurs during the encoding of a ``VersionedCodable``. 11 | /// - Note: The encoding of a ``VersionedCodable`` can also result in the same kinds of errors 12 | /// that are thrown during the encoding of any other `Codable`. 13 | public enum VersionedEncodingError: Error { 14 | 15 | /// Occurs when the type we are trying to encode has a property called `version`. 16 | /// 17 | /// This is an error because this it can and will be overridden by the current value of 18 | /// ``VersionedCodable/version``. You should not try to set the contents of the 19 | /// `version` field in your document yourself. 20 | /// - Tip: If you absolutely **must** encode an old version of your type (e.g. for compatibility reasons), 21 | /// encode that type directly. Don't try to manually set the contents of the `version` field. 22 | @available(*, deprecated, message: 23 | """ 24 | Not possible in VersionedCodable v1.1 or above. 25 | It is your responsibility as the programmer to not have a field whose name and path clashes with your version field. 26 | """) 27 | case typeHasClashingVersionField 28 | } 29 | -------------------------------------------------------------------------------- /Tests/VersionedCodable.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "115D639F-F0DA-4AED-912A-6F8AC28190E0", 5 | "name" : "Configuration 1", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | 13 | }, 14 | "testTargets" : [ 15 | { 16 | "target" : { 17 | "containerPath" : "container:", 18 | "identifier" : "VersionedCodableTests", 19 | "name" : "VersionedCodableTests" 20 | } 21 | } 22 | ], 23 | "version" : 1 24 | } 25 | -------------------------------------------------------------------------------- /Tests/VersionedCodableTests/NeverVersionedCodableConformanceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NeverVersionedCodableConformanceTests.swift 3 | // VersionedCodable 4 | // 5 | // Created by Jonathan Rothwell on 01/10/2024. 6 | // 7 | 8 | import Foundation 9 | import Testing 10 | @testable import VersionedCodable 11 | 12 | 13 | @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) 14 | @Test("`Never` won't decode using the `VersionedCodable` decoding method", .tags(.configuration)) 15 | func neverDoesntDecode() async throws { 16 | let neverJSON = Data(#"{"no": "no"}"#.utf8) 17 | #expect { 18 | try JSONDecoder().decode(versioned: Never.self, from: neverJSON) 19 | } throws: { error in 20 | isTypeMismatch(error, vs: Never.self) 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /Tests/VersionedCodableTests/NothingEarlierConformanceConfidenceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NothingEarlierConformanceConfidenceTests.swift 3 | // 4 | // 5 | // Created by Jonathan Rothwell on 15/04/2023. 6 | // 7 | 8 | import Foundation 9 | import Testing 10 | @testable import VersionedCodable 11 | 12 | let emptyJSONObject = "{}".data(using: .utf8)! 13 | 14 | 15 | @Suite("NothingEarlier") 16 | struct NothingEarlierTests { 17 | 18 | @Suite("Configuration", .tags(.configuration)) 19 | struct ConfigurationTests { 20 | @Test( 21 | "has a version of `nil`" 22 | ) func nothingEarlierVersionIsNil() throws { 23 | #expect(NothingEarlier.version == nil) 24 | } 25 | 26 | @Test( 27 | "throws if you try to decode anything into it" 28 | ) func decodingNothingEarlierThrowsAnError() throws { 29 | #expect { 30 | try JSONDecoder().decode( 31 | NothingEarlier.self, 32 | from: emptyJSONObject 33 | ) 34 | } throws: { error in 35 | return isTypeMismatch(error, vs: NothingEarlier.self) 36 | } 37 | } 38 | } 39 | 40 | @Test( 41 | "works properly as the 'stopper' type where there are no previous versions", 42 | .tags(.behaviour) 43 | ) func decodingFromSlightlyEarlierType() throws { 44 | #expect(throws: VersionedDecodingError.unsupportedVersion(tried: VersionedCodableWithoutOlderVersion.self)) { 45 | try JSONDecoder().decode( 46 | versioned: VersionedCodableWithoutOlderVersion.self, 47 | from: emptyJSONObject 48 | ) 49 | } 50 | } 51 | } 52 | 53 | 54 | struct VersionedCodableWithoutOlderVersion: VersionedCodable { 55 | static let version: Int? = 1 56 | 57 | typealias PreviousVersion = NothingEarlier 58 | 59 | var text: String 60 | } 61 | 62 | extension VersionedDecodingError: Equatable { 63 | public static func == (lhs: VersionedDecodingError, rhs: VersionedDecodingError) -> Bool { 64 | switch (lhs, rhs) { 65 | case let (.unsupportedVersion(leftVersion), .unsupportedVersion(rightVersion)): 66 | leftVersion == rightVersion 67 | case (.fieldNoLongerValid, .fieldNoLongerValid): 68 | true 69 | default: 70 | false 71 | } 72 | } 73 | 74 | 75 | } 76 | -------------------------------------------------------------------------------- /Tests/VersionedCodableTests/Support/ExamplePoems.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExamplePoem.swift 3 | // 4 | // 5 | // Created by Jonathan Rothwell on 18/04/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | /// **Glasgow**, by William Topaz McGonagall 11 | let examplePoem = 12 | [ 13 | "Beautiful city of Glasgow, with your streets so neat and clean,", 14 | "Your stateley mansions, and beautiful Green!", 15 | "Likewise your beautiful bridges across the River Clyde,", 16 | "And on your bonnie banks I would like to reside." 17 | ] 18 | 19 | let poemForEncoding = Poem( 20 | author: .init(name: "William Topaz McGonagall", 21 | born: DateComponents(calendar: .current, 22 | timeZone: TimeZone(identifier: "UTC"), 23 | year: 1825, 24 | month: 3).date!, 25 | died: DateComponents(calendar: .current, 26 | timeZone: TimeZone(identifier: "UTC"), 27 | year: 1902, 28 | month: 9, 29 | day: 29).date!), 30 | lines: examplePoem) 31 | -------------------------------------------------------------------------------- /Tests/VersionedCodableTests/Support/IsTypeMismatchVs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IsTypeMismatchVs.swift 3 | // VersionedCodable 4 | // 5 | // Created by Jonathan Rothwell on 01/10/2024. 6 | // 7 | 8 | func isTypeMismatch(_ error: Error, vs otherType: T.Type) -> Bool { 9 | switch error { 10 | case DecodingError.typeMismatch(let thisType, _): 11 | return thisType == T.self 12 | default: 13 | return false 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /Tests/VersionedCodableTests/Support/PoemDocuments.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PoemDocuments.swift 3 | // 4 | // 5 | // Created by Jonathan Rothwell on 15/04/2023. 6 | // 7 | 8 | import Foundation 9 | import VersionedCodable 10 | 11 | struct Poem: VersionedCodable { 12 | var author: Author? 13 | var lines: [String] 14 | 15 | struct Author: Codable { 16 | var name: String 17 | var born: Date? 18 | var died: Date? 19 | } 20 | 21 | 22 | static let version: Int? = 4 23 | typealias PreviousVersion = PoemV3 24 | init(from old: PreviousVersion) throws { 25 | self.lines = old.lines 26 | if let name = old.authorName { 27 | self.author = Author(name: name, 28 | born: old.authorDateOfBirth, 29 | died: old.authorDateOfDeath) 30 | } 31 | } 32 | 33 | 34 | struct PoemPreV1: VersionedCodable { 35 | var author: String? 36 | var poem: String? 37 | 38 | static let version: Int? = nil 39 | typealias PreviousVersion = NothingEarlier 40 | } 41 | 42 | struct PoemV1: VersionedCodable { 43 | var author: String? 44 | var poem: String 45 | 46 | static let version: Int? = 1 47 | typealias PreviousVersion = PoemPreV1 48 | init(from old: PreviousVersion) throws { 49 | self.author = old.author 50 | guard let poem = old.poem else { 51 | throw VersionedDecodingError.fieldNoLongerValid( 52 | DecodingError.Context(codingPath: [CodingKeys.poem], 53 | debugDescription: "Poem is no longer optional") 54 | ) 55 | } 56 | self.poem = poem 57 | } 58 | 59 | } 60 | 61 | struct PoemV2: VersionedCodable { 62 | var authorName: String? 63 | var authorDateOfBirth: Date? 64 | var authorDateOfDeath: Date? 65 | var poem: String 66 | 67 | static let version: Int? = 2 68 | typealias PreviousVersion = PoemV1 69 | init(from old: PreviousVersion) throws { 70 | self.authorName = old.author 71 | self.poem = old.poem 72 | } 73 | } 74 | 75 | struct PoemV3: VersionedCodable { 76 | var authorName: String? 77 | var authorDateOfBirth: Date? 78 | var authorDateOfDeath: Date? 79 | var lines: [String] 80 | 81 | static let version: Int? = 3 82 | typealias PreviousVersion = PoemV2 83 | init(from old: PreviousVersion) { 84 | self.authorName = old.authorName 85 | self.lines = old.poem.components(separatedBy: "\n") 86 | } 87 | } 88 | } 89 | 90 | struct PoemWithClash: VersionedCodable { 91 | static let version: Int? = 1 92 | typealias PreviousVersion = NothingEarlier 93 | 94 | var content: String 95 | var version: Int 96 | } 97 | 98 | extension Poem { 99 | internal init(author: Poem.Author? = nil, lines: [String]) { 100 | self.author = author 101 | self.lines = lines 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Tests/VersionedCodableTests/Support/TestCategories.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestCategories.swift 3 | // 4 | // 5 | // Created by Jonathan Rothwell on 11/06/2024. 6 | // 7 | 8 | import Testing 9 | 10 | extension Tag { 11 | @Tag static var configuration: Self 12 | @Tag static var behaviour: Self 13 | @Tag static var confidence: Self 14 | } 15 | -------------------------------------------------------------------------------- /Tests/VersionedCodableTests/Support/UnusualKeyPaths/Sonnets.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sonnets.swift 3 | // 4 | // 5 | // Created by Jonathan Rothwell on 23/01/2024. 6 | // 7 | 8 | import Foundation 9 | import VersionedCodable 10 | 11 | struct SonnetV2: VersionedCodable { 12 | static let version: Int? = 2 13 | typealias PreviousVersion = SonnetV1 14 | typealias VersionSpec = VersionedSpec 15 | 16 | var author: String 17 | var body: [BodyElement] 18 | 19 | enum BodyElement: Codable { 20 | case quatrain([String]) 21 | case couplet([String]) 22 | } 23 | 24 | init(from old: SonnetV1) throws { 25 | self.author = old.author 26 | self.body = old.body.quatrains.map { BodyElement.quatrain($0) } + 27 | old.body.couplets.map { BodyElement.couplet($0) } 28 | } 29 | 30 | struct VersionedSpec: VersionPathSpec { 31 | static let keyPathToVersion: KeyPath = \Self.metadata.documentVersion 32 | 33 | init(withVersion version: Int?) { 34 | self.metadata = Metadata(documentVersion: version) 35 | } 36 | 37 | var metadata: Metadata 38 | 39 | struct Metadata: Codable { 40 | var documentVersion: Int? 41 | } 42 | } 43 | } 44 | 45 | struct SonnetV1: VersionedCodable { 46 | static let version: Int? = 1 47 | typealias PreviousVersion = NothingEarlier 48 | typealias VersionSpec = VersionedSpec 49 | 50 | var author: String 51 | var body: Body 52 | 53 | struct Body: Codable { 54 | var quatrains: [[String]] 55 | var couplets: [[String]] 56 | } 57 | 58 | struct VersionedSpec: VersionPathSpec { 59 | static let keyPathToVersion: KeyPath = \Self._version 60 | 61 | init(withVersion _version: Int? = nil) { 62 | self._version = _version 63 | } 64 | 65 | var _version: Int? 66 | } 67 | } 68 | 69 | 70 | struct SonnetWithClash: VersionedCodable { 71 | static let version: Int? = 1 72 | typealias PreviousVersion = NothingEarlier 73 | typealias VersionSpec = VersionPath 74 | 75 | let metadata: Metadata 76 | 77 | struct Metadata: Codable { 78 | var version: String 79 | } 80 | 81 | struct VersionPath: VersionPathSpec { 82 | static let keyPathToVersion: KeyPath = \Self.metadata.version 83 | 84 | let metadata: Metadata 85 | 86 | init(withVersion version: Int?) { 87 | self.metadata = Metadata(version: version) 88 | } 89 | 90 | struct Metadata: Codable { 91 | var version: Int? 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Tests/VersionedCodableTests/Support/UnusualKeyPaths/sonnet-v1.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "William Shakespeare", 3 | "body": { 4 | "quatrains": [ 5 | [ 6 | "When, in disgrace with fortune and men’s eyes,", 7 | "I all alone beweep my outcast state,", 8 | "And trouble deaf heaven with my bootless cries,", 9 | "And look upon myself, and curse my fate," 10 | ], [ 11 | "Wishing me like to one more rich in hope,", 12 | "Featur’d like him, like him with friends possess’d,", 13 | "Desiring this man’s art and that man’s scope,", 14 | "With what I most enjoy contented least;" 15 | ], [ 16 | "Yet in these thoughts myself almost despising,", 17 | "Haply I think on thee, and then my state,", 18 | "Like to the lark at break of day arising", 19 | "From sullen earth, sings hymns at heaven’s gate;" 20 | ] 21 | ], 22 | "couplets": [ 23 | [ 24 | "For thy sweet love remember’d such wealth brings", 25 | "That then I scorn to change my state with kings." 26 | ] 27 | ] 28 | }, 29 | "_version": 1 30 | } 31 | -------------------------------------------------------------------------------- /Tests/VersionedCodableTests/Support/UnusualKeyPaths/sonnet-v2.json: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": { 3 | "documentVersion": 2 4 | }, 5 | "author": "William Shakespeare", 6 | "body": [ 7 | { 8 | "quatrain": { 9 | "_0": [ 10 | "When, in disgrace with fortune and men’s eyes,", 11 | "I all alone beweep my outcast state,", 12 | "And trouble deaf heaven with my bootless cries,", 13 | "And look upon myself, and curse my fate," 14 | ] 15 | } 16 | }, 17 | { 18 | "quatrain": { 19 | "_0": [ 20 | "Wishing me like to one more rich in hope,", 21 | "Featur’d like him, like him with friends possess’d,", 22 | "Desiring this man’s art and that man’s scope,", 23 | "With what I most enjoy contented least;" 24 | ] 25 | } 26 | }, 27 | { 28 | "quatrain": { 29 | "_0": [ 30 | "Yet in these thoughts myself almost despising,", 31 | "Haply I think on thee, and then my state,", 32 | "Like to the lark at break of day arising", 33 | "From sullen earth, sings hymns at heaven’s gate;" 34 | ] 35 | } 36 | }, 37 | { 38 | "couplet": { 39 | "_0": [ 40 | "For thy sweet love remember’d such wealth brings", 41 | "That then I scorn to change my state with kings." 42 | ] 43 | } 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /Tests/VersionedCodableTests/Support/expectedEncoded.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | author 6 | 7 | born 8 | 1825-03-01T00:00:00Z 9 | died 10 | 1902-09-29T00:00:00Z 11 | name 12 | William Topaz McGonagall 13 | 14 | lines 15 | 16 | Beautiful city of Glasgow, with your streets so neat and clean, 17 | Your stateley mansions, and beautiful Green! 18 | Likewise your beautiful bridges across the River Clyde, 19 | And on your bonnie banks I would like to reside. 20 | 21 | version 22 | 4 23 | 24 | 25 | -------------------------------------------------------------------------------- /Tests/VersionedCodableTests/Support/expectedOlder.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | author 6 | William Topaz McGonagall 7 | poem 8 | Beautiful city of Glasgow, with your streets so neat and clean, 9 | Your stateley mansions, and beautiful Green! 10 | Likewise your beautiful bridges across the River Clyde, 11 | And on your bonnie banks I would like to reside. 12 | 13 | 14 | -------------------------------------------------------------------------------- /Tests/VersionedCodableTests/Support/expectedUnsupported.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | author 6 | William Topaz McGonagall 7 | poem 8 | Beautiful city of Glasgow, with your streets so neat and clean, 9 | Your stateley mansions, and beautiful Green! 10 | Likewise your beautiful bridges across the River Clyde, 11 | And on your bonnie banks I would like to reside. 12 | version 13 | -2 14 | 15 | 16 | -------------------------------------------------------------------------------- /Tests/VersionedCodableTests/UnusualVersionKeyPathsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UnusualPathToVersionFieldTests.swift 3 | // 4 | // 5 | // Created by Jonathan Rothwell on 23/01/2024. 6 | // 7 | 8 | import Testing 9 | import Foundation 10 | @testable import VersionedCodable 11 | 12 | /// Tests unusual paths to the version field in VersionedCodable. 13 | @Suite("Custom version KeyPaths", .tags(.behaviour)) 14 | struct UnusualVersionKeyPathsTests { 15 | @Test("decodes a `_version` field at the root of the type with a simple spec") 16 | func fieldAtRootWithSimpleName() throws { 17 | let data = try Data( 18 | contentsOf: Bundle.module.url(forResource: "sonnet-v1", 19 | withExtension: "json")!) 20 | let decoded = try JSONDecoder().decode(versioned: SonnetV1.self, from: data) 21 | #expect("William Shakespeare" == decoded.author) 22 | } 23 | 24 | @Test("decodes a version field nested further inside a `metadata` key") 25 | func fieldWithMoreComplexPath() throws { 26 | let data = try Data( 27 | contentsOf: Bundle.module.url(forResource: "sonnet-v2", 28 | withExtension: "json")!) 29 | let decoded = try JSONDecoder().decode(versioned: SonnetV2.self, from: data) 30 | #expect("William Shakespeare" == decoded.author) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /Tests/VersionedCodableTests/VersionedCodableJSONTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import Foundation 3 | @testable import VersionedCodable 4 | 5 | struct VersionedDocument: Codable { 6 | var version: Int? 7 | } 8 | 9 | 10 | @Suite("JSONDecoder encoding/decoding", 11 | .tags(.behaviour)) 12 | struct VersionedCodableJSONTests { 13 | @Test("decodes a grandfathered type with no version field") 14 | func decodingPoemPreV1ToPoemV1() throws { 15 | let oldPoem = """ 16 | { 17 | "author": "John Smith", 18 | "poem": "Hello" 19 | } 20 | """.data(using: .utf8)! 21 | 22 | 23 | let migrated = try JSONDecoder() 24 | .decode(versioned: Poem.PoemV1.self, 25 | from: oldPoem) 26 | #expect("John Smith" == migrated.author) 27 | #expect("Hello" == migrated.poem) 28 | } 29 | 30 | @Test("throws when trying to decode type if some fields are now mandatory") 31 | func decodingPoemPreV1ToPoemV1ThrowsDueToNewMandatoryFields() throws { 32 | let oldPoem = """ 33 | { 34 | "author": "John Smith", 35 | "poem": null 36 | } 37 | """.data(using: .utf8)! 38 | 39 | 40 | #expect { 41 | try JSONDecoder().decode( 42 | versioned: Poem.PoemV1.self, 43 | from: oldPoem 44 | ) 45 | } throws: { error in 46 | guard let error = error as? VersionedDecodingError, 47 | case .fieldNoLongerValid(let context) = error else { 48 | return false 49 | } 50 | 51 | return ( 52 | "poem" == context.codingPath[0].stringValue 53 | ) && ( 54 | "Poem is no longer optional" == context.debugDescription 55 | ) 56 | 57 | } 58 | } 59 | 60 | @Test("throws when decoding an older version than we support") 61 | func throwsWhenDecodingOlderVersionThanWeSupport() throws { 62 | let oldPoem = """ 63 | { 64 | "version": -1, 65 | "author": "John Smith", 66 | "poem": null 67 | } 68 | """.data(using: .utf8)! 69 | 70 | #expect(throws: VersionedDecodingError.unsupportedVersion(tried: Poem.PoemPreV1.self)) { 71 | try JSONDecoder().decode( 72 | versioned: Poem.PoemV1.self, 73 | from: oldPoem 74 | ) 75 | } 76 | } 77 | 78 | @Test("performs a multi-stage migration") 79 | func multiStageMigration() throws { 80 | let oldPoem = """ 81 | { 82 | "version": 1, 83 | "author": "A Clever Man", 84 | "poem": "An epicure dining at Crewe\\nFound a rather large mouse in his stew\\nCried the waiter: Don't shout\\nAnd wave it about\\nOr the rest will be wanting one too!" 85 | } 86 | """.data(using: .utf8)! 87 | 88 | let migrated = try JSONDecoder().decode( 89 | versioned: Poem.self, 90 | from: oldPoem) 91 | 92 | #expect( 93 | [ 94 | "An epicure dining at Crewe", 95 | "Found a rather large mouse in his stew", 96 | "Cried the waiter: Don\'t shout", 97 | "And wave it about", 98 | "Or the rest will be wanting one too!" 99 | ] == migrated.lines 100 | ) 101 | #expect("A Clever Man" == migrated.author?.name) 102 | } 103 | 104 | @Test("encodes the version properly") 105 | func testVersionEncodedCorrectly() throws { 106 | let oldPoem = """ 107 | { 108 | "version": 1, 109 | "author": "A Clever Man", 110 | "poem": "An epicure dining at Crewe\\nFound a rather large mouse in his stew\\nCried the waiter: Don't shout\\nAnd wave it about\\nOr the rest will be wanting one too!" 111 | } 112 | """.data(using: .utf8)! 113 | 114 | let migrated = try JSONDecoder() 115 | .decode(versioned: Poem.self, 116 | from: oldPoem) 117 | 118 | 119 | let encoded = try JSONEncoder().encode(versioned: migrated) 120 | 121 | let versionedDocument = try JSONDecoder().decode(VersionedDocument.self, from: encoded) 122 | 123 | #expect(4 == versionedDocument.version) 124 | 125 | let encodedAndDecodedDocument = try JSONDecoder().decode(Poem.self, from: encoded) 126 | #expect( 127 | [ 128 | "An epicure dining at Crewe", 129 | "Found a rather large mouse in his stew", 130 | "Cried the waiter: Don\'t shout", 131 | "And wave it about", 132 | "Or the rest will be wanting one too!" 133 | ] == encodedAndDecodedDocument.lines 134 | ) 135 | #expect("A Clever Man" == encodedAndDecodedDocument.author?.name) 136 | } 137 | 138 | } 139 | -------------------------------------------------------------------------------- /Tests/VersionedCodableTests/VersionedCodablePerformanceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VersionedCodablePerformanceTests.swift 3 | // 4 | // 5 | // Created by Jonathan Rothwell on 30/04/2023. 6 | // 7 | 8 | import XCTest 9 | 10 | final class VersionedCodablePerformanceTests: XCTestCase { 11 | 12 | func testDecodingPerformance() throws { 13 | let decoder = JSONDecoder() 14 | 15 | self.measure { 16 | try! PerformanceTestDocument 17 | .threeHundredLoremIpsumVersionOneDocuments 18 | .forEach { 19 | _ = try decoder.decode( 20 | versioned: PerformanceTestDocument.self, 21 | from: $0) 22 | } 23 | } 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /Tests/VersionedCodableTests/VersionedCodablePropertyListTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VersionedCodablePropertyListTests.swift 3 | // 4 | // 5 | // Created by Jonathan Rothwell on 18/04/2023. 6 | // 7 | 8 | import Testing 9 | import Foundation 10 | @testable import VersionedCodable 11 | 12 | @Suite("Property lists", .tags(.behaviour)) struct VersionedCodablePropertyListTests { 13 | 14 | @Test("A Codable encodes into a property list") 15 | func encoding() throws { 16 | let encoder = PropertyListEncoder() 17 | encoder.outputFormat = .xml 18 | // XML makes our life a bit easier because it's human-readable, 19 | // unlike binary property lists. 20 | 21 | let expected = try Data( 22 | contentsOf: Bundle.module.url(forResource: "expectedEncoded", 23 | withExtension: "plist")!) 24 | let data = try encoder.encode(versioned: poemForEncoding) 25 | #expect(String(data: expected, encoding: .utf8)! == 26 | String(data: data, encoding: .utf8)!) 27 | } 28 | 29 | @Test("current version decodes correctly") 30 | func decodingCurrentVersion() throws { 31 | let data = try Data( 32 | contentsOf: Bundle.module.url(forResource: "expectedEncoded", 33 | withExtension: "plist")!) 34 | let decoded = try PropertyListDecoder() 35 | .decode(versioned: Poem.self, from: data) 36 | #expect("William Topaz McGonagall" == decoded.author?.name) 37 | } 38 | 39 | @Test("older version decodes correctly") 40 | func decodingOldVersion() throws { 41 | let data = try Data( 42 | contentsOf: Bundle.module.url(forResource: "expectedOlder", 43 | withExtension: "plist")!) 44 | let decoded = try PropertyListDecoder() 45 | .decode(versioned: Poem.self, from: data) 46 | #expect("William Topaz McGonagall" == decoded.author?.name) 47 | #expect(examplePoem == decoded.lines) 48 | } 49 | 50 | @Test("throws when decoding unsupported version") 51 | func decodingUnsupportedVersion() throws { 52 | let data = try Data( 53 | contentsOf: Bundle.module.url(forResource: "expectedUnsupported", 54 | withExtension: "plist")!) 55 | #expect(throws: 56 | VersionedDecodingError.unsupportedVersion(tried: Poem.PoemPreV1.self)) { 57 | try PropertyListDecoder().decode(versioned: Poem.self, from: data) 58 | } 59 | } 60 | 61 | 62 | } 63 | --------------------------------------------------------------------------------