├── .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  [](https://swiftpackageindex.com/jrothwell/VersionedCodable) [](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 |
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 | 
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 |
--------------------------------------------------------------------------------