From 186fe87a84a6fbd05a62594817cee6c9aa20b75f Mon Sep 17 00:00:00 2001 From: Mathew Gacy Date: Tue, 30 Jul 2024 08:40:44 -0700 Subject: [PATCH] Strict Concurrency Compatibility (#9) * Declare (conditional) Sendable conformance * DiskCache -> 2.1.0 * DiskCache -> 2.2.0 * Make MockCache Sendable * More descriptive error message * Fix test failure * Fix Swift 6 warnings * Fix tests * Fix CodableCaching tests --- Package.resolved | 4 +- Package.swift | 6 +-- Sources/CodableCache/CodableCache.swift | 41 ++++++++------- Sources/CodableCache/CodableCaching.swift | 44 +++++++++++++--- .../Extensions/JSONDecoder+Utils.swift | 17 +++++++ .../Extensions/JSONEncoder+Utils.swift | 25 +++++++++ .../CodableCache/Models/CacheWrapper.swift | 2 + Sources/CodableCache/Models/TTL.swift | 2 +- .../CodableCacheTests/CodableCacheTests.swift | 21 ++++---- .../CodableCachingTest.swift | 8 ++- Tests/CodableCacheTests/MockCache.swift | 51 +++++++++++++++---- 11 files changed, 163 insertions(+), 58 deletions(-) create mode 100644 Sources/CodableCache/Extensions/JSONDecoder+Utils.swift create mode 100644 Sources/CodableCache/Extensions/JSONEncoder+Utils.swift diff --git a/Package.resolved b/Package.resolved index 9bf94b8..6bc9a83 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/Mobelux/DiskCache.git", "state": { "branch": null, - "revision": "dc9d51b67abefb41669cee38534cf86ab92ea610", - "version": "2.0.0" + "revision": "1624e932c3cde0f221fc8c340fda263f72c87f69", + "version": "2.2.0" } }, { diff --git a/Package.swift b/Package.swift index 435fa3f..6098cea 100644 --- a/Package.swift +++ b/Package.swift @@ -12,19 +12,15 @@ let package = Package( .macOS(.v10_15) ], products: [ - // Products define the executables and libraries produced by a package, and make them visible to other packages. .library( name: "CodableCache", targets: ["CodableCache"]), ], dependencies: [ - // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/Mobelux/DiskCache.git", from: "2.0.0"), + .package(url: "https://github.com/Mobelux/DiskCache.git", from: "2.2.0"), .package(url: "https://github.com/apple/swift-crypto.git", from: "1.0.0") ], targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages which this package depends on. .target( name: "CodableCache", dependencies: [ diff --git a/Sources/CodableCache/CodableCache.swift b/Sources/CodableCache/CodableCache.swift index 0074bd4..55c902e 100644 --- a/Sources/CodableCache/CodableCache.swift +++ b/Sources/CodableCache/CodableCache.swift @@ -9,29 +9,28 @@ import DiskCache import Foundation /// A cache for `Codable` values. -public final class CodableCache { - internal static var encoder: JSONEncoder = { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - - return encoder - }() - - internal static var decoder: JSONDecoder = { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - return decoder - }() - - internal static var makeDate: () -> Date = { Date() } - +public final class CodableCache: Sendable { private let cache: Cache + private let decoder: JSONDecoder + private let encoder: JSONEncoder + private let makeDate: @Sendable () -> Date /// Initilizes an instance of `CodableCache`. /// - Parameter cache: A type conforming to the `Cache` protocol. - public init(_ cache: Cache) { + public convenience init(_ cache: Cache) { + self.init(cache: cache) + } + + internal init( + cache: any Cache, + decoder: JSONDecoder = .iso8601, + encoder: JSONEncoder = .iso8601, + makeDate: @escaping @Sendable () -> Date = { Date() } + ) { self.cache = cache + self.decoder = decoder + self.encoder = encoder + self.makeDate = makeDate } /// Asynchronously caches the given object. @@ -39,8 +38,8 @@ public final class CodableCache { /// - Parameter key: A unique key used to identify the cached object. /// - Parameter ttl: Defines the amount of time the cached object is valid. public func cache(object: T, key: Keyable, ttl: TTL = TTL.default) async throws { - let wrapper = CacheWrapper(ttl: ttl, created: Self.makeDate(), object: object) - try await cache.cache(Self.encoder.encode(wrapper), key: key.rawValue) + let wrapper = CacheWrapper(ttl: ttl, created: makeDate(), object: object) + try await cache.cache(encoder.encode(wrapper), key: key.rawValue) } /// Deletes the cached object associated with the given key. @@ -59,7 +58,7 @@ public final class CodableCache { public func object(key: Keyable) async -> T? { do { let data = try await self.cache.data(key.rawValue) - let wrapper = try Self.decoder.decode(CacheWrapper.self, from: data) + let wrapper = try decoder.decode(CacheWrapper.self, from: data) if wrapper.isObjectStale { try await delete(objectWith: key) return nil diff --git a/Sources/CodableCache/CodableCaching.swift b/Sources/CodableCache/CodableCaching.swift index 9398e3c..0981977 100644 --- a/Sources/CodableCache/CodableCaching.swift +++ b/Sources/CodableCache/CodableCaching.swift @@ -13,13 +13,13 @@ import Foundation public final class CodableCaching { private lazy var codableCache: CodableCache = { do { - return try CodableCache(cache()) + return try makeCodableCache() } catch { fatalError("Creating cache instance failed with error:\n\(error)") } }() - private let cache: () throws -> Cache + private let makeCodableCache: @Sendable () throws -> CodableCache private let key: Keyable private let ttl: TTL @@ -64,13 +64,43 @@ public final class CodableCaching { /// - key: A unique key used to identify the cached object. /// - cache: A function defining a type conforming to `Cache` to use as backing storage. /// - ttl: Defines the amount of time the cached object is valid. - public init(wrappedValue: Value? = nil, - key: Keyable, - cache: @escaping () throws -> Cache = { try DiskCache(storageType: .temporary(.custom("codable-cache"))) }, - ttl: TTL = .default) { + public convenience init( + wrappedValue: Value? = nil, + key: Keyable, + cache: @escaping @Sendable () throws -> any Cache = { try DiskCache(storageType: .temporary(.custom("codable-cache"))) }, + ttl: TTL = .default + ) { + self.init( + wrappedValue: wrappedValue, + key: key, + makeCodableCache: { try CodableCache(cache()) }, + ttl: ttl) + } + + internal convenience init( + wrappedValue: Value? = nil, + key: any Keyable, + cache: @escaping @Sendable () throws -> any Cache, + encoder: JSONEncoder, + makeDate: @escaping @Sendable () -> Date, + ttl: TTL = .default + ) { + self.init( + wrappedValue: wrappedValue, + key: key, + makeCodableCache: { try CodableCache(cache: cache(), encoder: encoder, makeDate: makeDate) }, + ttl: ttl) + } + + internal init( + wrappedValue: Value? = nil, + key: any Keyable, + makeCodableCache: @escaping @Sendable () throws -> CodableCache, + ttl: TTL = .default + ) { self.wrappedValue = wrappedValue self.key = key - self.cache = cache + self.makeCodableCache = makeCodableCache self.ttl = ttl } } diff --git a/Sources/CodableCache/Extensions/JSONDecoder+Utils.swift b/Sources/CodableCache/Extensions/JSONDecoder+Utils.swift new file mode 100644 index 0000000..999ffe9 --- /dev/null +++ b/Sources/CodableCache/Extensions/JSONDecoder+Utils.swift @@ -0,0 +1,17 @@ +// +// JSONDecoder+Utils.swift +// +// +// Created by Mathew Gacy on 7/8/24. +// + +import Foundation + +extension JSONDecoder { + static let iso8601: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + return decoder + }() +} diff --git a/Sources/CodableCache/Extensions/JSONEncoder+Utils.swift b/Sources/CodableCache/Extensions/JSONEncoder+Utils.swift new file mode 100644 index 0000000..9655722 --- /dev/null +++ b/Sources/CodableCache/Extensions/JSONEncoder+Utils.swift @@ -0,0 +1,25 @@ +// +// JSONEncoder+Utils.swift +// +// +// Created by Mathew Gacy on 7/8/24. +// + +import Foundation + +extension JSONEncoder { + static let iso8601: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + return encoder + }() + + static let sorted: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .sortedKeys + + return encoder + }() +} diff --git a/Sources/CodableCache/Models/CacheWrapper.swift b/Sources/CodableCache/Models/CacheWrapper.swift index 0454b60..4871a75 100644 --- a/Sources/CodableCache/Models/CacheWrapper.swift +++ b/Sources/CodableCache/Models/CacheWrapper.swift @@ -18,3 +18,5 @@ extension CacheWrapper { return abs(created.timeIntervalSinceNow) >= TimeInterval(ttl.value) } } + +extension CacheWrapper: Sendable where T: Sendable {} diff --git a/Sources/CodableCache/Models/TTL.swift b/Sources/CodableCache/Models/TTL.swift index bad8a49..6d45b31 100644 --- a/Sources/CodableCache/Models/TTL.swift +++ b/Sources/CodableCache/Models/TTL.swift @@ -8,7 +8,7 @@ import Foundation /// Specifies the time-to-live for a cached object. -public enum TTL: Codable { +public enum TTL: Codable, Sendable { private enum CodingKeys: String, CodingKey { case second, minute, hour, day, forever } diff --git a/Tests/CodableCacheTests/CodableCacheTests.swift b/Tests/CodableCacheTests/CodableCacheTests.swift index 1945743..447d3ed 100644 --- a/Tests/CodableCacheTests/CodableCacheTests.swift +++ b/Tests/CodableCacheTests/CodableCacheTests.swift @@ -14,20 +14,21 @@ final class CodableCacheTests: XCTestCase { } static let test = Test(value: "test-value") + let encoder = JSONEncoder.sorted + var date = Date(timeIntervalSince1970: 0) var wrapper: CacheWrapper { - CodableCacheTests.wrapper(for: CodableCacheTests.test, date: CodableCache.makeDate()) + CodableCacheTests.wrapper(for: CodableCacheTests.test, date: date) } override func setUp() { - let date = Date() - CodableCache.makeDate = { date } + date = Date() } func testCache() async throws { - let data = try CodableCache.encoder.encode(wrapper) + let data = try encoder.encode(wrapper) let mockCache = MockCache(instruction: .data(data)) - let codableCache = CodableCache(mockCache) + let codableCache = CodableCache(cache: mockCache, encoder: encoder, makeDate: { self.date }) do { try await codableCache.cache(object: Self.test, key: "test", ttl: .default) @@ -53,10 +54,10 @@ final class CodableCacheTests: XCTestCase { } func testData() async throws { - let testData = try CodableCache.encoder.encode(wrapper) + let testData = try encoder.encode(wrapper) let mockCache = MockCache(instruction: .data(testData)) - let codableCache = CodableCache(mockCache) + let codableCache = CodableCache(cache: mockCache, encoder: encoder, makeDate: { self.date }) let data: Test? = await codableCache.object(key: "test") XCTAssertEqual(data, Self.test) @@ -64,12 +65,12 @@ final class CodableCacheTests: XCTestCase { } func testStaleData() async throws { - CodableCache.makeDate = { Date(timeIntervalSinceNow: -Double(TTL.day(2).value)) } - let testData = try CodableCache.encoder.encode(wrapper) + date = Date(timeIntervalSinceNow: -Double(TTL.day(2).value)) + let testData = try encoder.encode(wrapper) let testError = "throwing-data" let mockCache = MockCache(instruction: .dataThrow(testData, testError)) - let codableCache = CodableCache(mockCache) + let codableCache = CodableCache(cache: mockCache, encoder: encoder, makeDate: { self.date }) let data: Test? = await codableCache.object(key: "test") XCTAssertEqual(data, nil) diff --git a/Tests/CodableCacheTests/CodableCachingTest.swift b/Tests/CodableCacheTests/CodableCachingTest.swift index c00a1df..f3ebaf5 100644 --- a/Tests/CodableCacheTests/CodableCachingTest.swift +++ b/Tests/CodableCacheTests/CodableCachingTest.swift @@ -9,15 +9,19 @@ import XCTest @testable import CodableCache class CodableCachingTest: XCTestCase { + static let date = Date() + @CodableCaching( key: "test", cache: { MockCache( instruction: .data( - try! CodableCache.encoder.encode(CacheWrapper( + try! JSONEncoder.sorted.encode(CacheWrapper( ttl: .default, - created: CodableCache.makeDate(), + created: date, object: "test-value")) )) }, + encoder: .sorted, + makeDate: { CodableCachingTest.date }, ttl: .default) var testValue: String? diff --git a/Tests/CodableCacheTests/MockCache.swift b/Tests/CodableCacheTests/MockCache.swift index 83d142a..ff9844b 100644 --- a/Tests/CodableCacheTests/MockCache.swift +++ b/Tests/CodableCacheTests/MockCache.swift @@ -8,7 +8,7 @@ import DiskCache import Foundation -class MockCache: Cache { +final class MockCache: Cache, @unchecked Sendable { enum Callable { case cache case data @@ -28,12 +28,13 @@ class MockCache: Cache { self.instruction = instruction } + private let lock = NSLock() var callable: Callable = .none let instruction: Instruction - func cache(_ data: Data, key: String) async throws { + func syncCache(_ data: Data, key: String) throws { defer { - self.callable = .cache + setCallable(.cache) } switch instruction { @@ -41,7 +42,11 @@ class MockCache: Cache { throw error case .data(let instructionData): guard instructionData == data else { - throw "mismatched data" + throw """ + mismatched data + E: \(String(decoding: instructionData, as: UTF8.self)) + A: \(String(decoding: data, as: UTF8.self)) + """ } case .dataThrow: fatalError("not callable") @@ -50,9 +55,9 @@ class MockCache: Cache { } } - func data(_ key: String) async throws -> Data { + func syncData(_ key: String) throws -> Data { defer { - self.callable = .data + setCallable(.data) } switch instruction { @@ -67,9 +72,9 @@ class MockCache: Cache { } } - func delete(_ key: String) async throws { + func syncDelete(_ key: String) throws { defer { - self.callable = .delete + setCallable(.delete) } switch instruction { @@ -83,9 +88,9 @@ class MockCache: Cache { } } - func deleteAll() async throws { + func syncDeleteAll() throws { defer { - self.callable = .deleteAll + setCallable(.deleteAll) } switch instruction { @@ -99,5 +104,31 @@ class MockCache: Cache { } } + // MARK: - Async support + + func cache(_ data: Data, key: String) async throws { + try syncCache(data, key: key) + } + + func data(_ key: String) async throws -> Data { + try syncData(key) + } + + func delete(_ key: String) async throws { + try syncDelete(key) + } + + func deleteAll() async throws { + try syncDeleteAll() + } + func fileURL(_ filename: String) -> URL { fatalError("not callable") } } + +private extension MockCache { + func setCallable(_ value: Callable) { + lock.lock() + self.callable = value + lock.unlock() + } +}