From aa4b15770beecc66a6354d0d7255907b2e3b016e Mon Sep 17 00:00:00 2001 From: atacan Date: Mon, 24 Apr 2023 08:52:30 +0200 Subject: [PATCH] Stream of UserDefaults values (#53) Co-authored-by: Thomas Grapperon <35562418+tgrapperon@users.noreply.github.com> --- .../UserDefaults+Specializations.swift | 113 ++++- .../UserDefaultsDependency.swift | 36 +- .../UserDefaultsDependencyTests.swift | 399 ++++++++++++++++++ 3 files changed, 539 insertions(+), 9 deletions(-) diff --git a/Sources/UserDefaultsDependency/UserDefaults+Specializations.swift b/Sources/UserDefaultsDependency/UserDefaults+Specializations.swift index 7252b4c..27e5c73 100644 --- a/Sources/UserDefaultsDependency/UserDefaults+Specializations.swift +++ b/Sources/UserDefaultsDependency/UserDefaults+Specializations.swift @@ -103,6 +103,7 @@ extension UserDefaults.Dependency { public func set(_ value: URL?, forKey key: String) { self._set(value, key) } + #else /// Returns the URL value associated with the specified key. /// - Parameter key: A key in the current user defaults store. @@ -142,7 +143,7 @@ extension UserDefaults.Dependency { /// is no value associated to `key`, or if `R` cannot be built from the associated value. public func rawRepresentable(forKey key: String) -> R? where R.RawValue == String { - self.string(forKey: key).flatMap(R.init(rawValue:)) + self.string(forKey: key).flatMap(R.init) } /// Sets the value of the specified default key to the specified RawRepresentable `R` value. @@ -160,7 +161,7 @@ extension UserDefaults.Dependency { /// is no value associated to `key`, or if `R` cannot be built from the associated value. public func rawRepresentable(forKey key: String) -> R? where R.RawValue == Int { - self.integer(forKey: key).flatMap(R.init(rawValue:)) + self.integer(forKey: key).flatMap(R.init) } /// Sets the value of the specified default key to the specified RawRepresentable `R` value. @@ -173,6 +174,106 @@ extension UserDefaults.Dependency { } } +extension UserDefaults.Dependency { + /// An `AsyncStream` of Boolean values for a given `key` as they change. The stream produces `nil` + /// if no value exists for the given key. + /// - Parameter key: A key in the current user defaults store. + /// - Returns: An `AsyncStream` of `Bool?` values, including the initial value. + public func boolValues(forKey key: String) -> AsyncStream { + self._values(key, Bool.self).map { $0 as! Bool? }.eraseToStream() + } + + /// An `AsyncStream` of Data values for a given `key` as they change. The stream produces `nil` if + /// no value exists for the given key. + /// - Parameter key: A key in the current user defaults store. + /// - Returns: An `AsyncStream` of `Data?` values, including the initial value. + public func dataValues(forKey key: String) -> AsyncStream { + self._values(key, Data.self).map { $0 as! Data? }.eraseToStream() + } + + /// An `AsyncStream` of Double values for a given `key` as they change. The stream produces `nil` + /// if no value exists for the given key. + /// - Parameter key: A key in the current user defaults store. + /// - Returns: An `AsyncStream` of `Double?` values, including the initial value. + public func doubleValues(forKey key: String) -> AsyncStream { + self._values(key, Double.self).map { $0 as! Double? }.eraseToStream() + } + + /// An `AsyncStream` of Integer values for a given `key` as they change. The stream produces `nil` + /// if no value exists for the given key. + /// - Parameter key: A key in the current user defaults store. + /// - Returns: An `AsyncStream` of `Int?` values, including the initial value. + public func integerValues(forKey key: String) -> AsyncStream { + self._values(key, Int.self).map { $0 as! Int? }.eraseToStream() + } + + /// An `AsyncStream` of String values for a given `key` as they change. The stream produces `nil` + /// if no value exists for the given key. + /// - Parameter key: A key in the current user defaults store. + /// - Returns: An `AsyncStream` of `String?` values, including the initial value. + public func stringValues(forKey key: String) -> AsyncStream { + self._values(key, String.self).map { $0 as! String? }.eraseToStream() + } + + #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) + /// An `AsyncStream` of URL values for a given `key` as they change. The stream produces `nil` + /// if no value exists for the given key. + /// - Parameter key: A key in the current user defaults store. + /// - Returns: An `AsyncStream` of `URL?` values, including the initial value. + public func urlValues(forKey key: String) -> AsyncStream { + self._values(key, URL.self).map { $0 as! URL? }.eraseToStream() + } + #else + /// An `AsyncStream` of URL values for a given `key` as they change. The stream produces `nil` + /// if no value exists for the given key. + /// - Parameter key: A key in the current user defaults store. + /// - Returns: An `AsyncStream` of `URL?` values, including the initial value. + @available( + *, unavailable, message: "Observing URLs from UserDefaults is not supported on this platform." + ) + public func urlValues(forKey key: String) -> AsyncStream { + self._values(key, URL.self).map { $0 as! URL? }.eraseToStream() + } + #endif + + /// An `AsyncStream` of `RawRepresentable` values for a given `key` as they change. The + /// stream produces `nil` if no value exists for the given key. + /// - Parameters: + /// - valueType: The type of `RawRepresentable` values that is produced. You can use this + /// argument if the type system is unable ot infer it from the context. + /// - key: A key in the current user defaults store. + /// - Returns: An `AsyncStream` of `RawRepresentable?` values, including the initial + /// value. + public func rawRepresentableValues( + _ valueType: R.Type = R.self, + forKey key: String + ) + -> AsyncStream where R.RawValue == String + { + self._values(key, String.self) + .map { ($0 as! String?).flatMap(R.init) } + .eraseToStream() + } + + /// An `AsyncStream` of `RawRepresentable` values for a given `key` as they change. The + /// stream produces `nil` if no value exists for the given key. + /// - Parameters: + /// - valueType: The type of `RawRepresentable` values that is produced. You can use this + /// argument if the type system is unable ot infer it from the context. + /// - key: A key in the current user defaults store. + /// - Returns: An `AsyncStream` of `RawRepresentable?` values, including the initial value. + public func rawRepresentableValues( + _ valueType: R.Type = R.self, + forKey key: String + ) + -> AsyncStream where R.RawValue == Int + { + self._values(key, Int.self) + .map { ($0 as! Int?).flatMap(R.init) } + .eraseToStream() + } +} + // NS Extensions extension UserDefaults.Dependency { /// Returns the Date value associated with the specified key. @@ -191,4 +292,12 @@ extension UserDefaults.Dependency { public func set(_ value: Date?, forKey key: String) { self._set(value, key) } + + /// An `AsyncStream` of Date values for a given `key` as they change. The stream produces `nil` if + /// if no value exists for the given key. + /// - Parameter key: A key in the current user defaults store. + /// - Returns: An `AsyncStream` of `Date?` values, including the initial value. + public func dateValues(forKey key: String) -> AsyncStream { + self._values(key, Date.self).map { $0 as! Date? }.eraseToStream() + } } diff --git a/Sources/UserDefaultsDependency/UserDefaultsDependency.swift b/Sources/UserDefaultsDependency/UserDefaultsDependency.swift index a979578..135d1bb 100644 --- a/Sources/UserDefaultsDependency/UserDefaultsDependency.swift +++ b/Sources/UserDefaultsDependency/UserDefaultsDependency.swift @@ -270,25 +270,47 @@ extension UserDefaults.Dependency: TestDependencyKey { return UserDefaults.Dependency { key, _ in storage.value[key] } set: { value, key in + var valueDidChange = false storage.withValue { + valueDidChange = !_isEqual($0[key], value) $0[key] = value } - for continuation in continuations.value[key]?.values ?? [:].values { - continuation.yield(value) + if valueDidChange { + for continuation in continuations.value[key]?.values ?? [:].values { + continuation.yield(value) + } } } values: { key, _ in let id = UUID() - let stream = AsyncStream((any Sendable)?.self) { streamContinuation in - continuations.withValue { - $0[key, default: [:]][id] = streamContinuation - } + let (stream, continuation) = AsyncStream.streamWithContinuation( + (any Sendable)?.self, + bufferingPolicy: .bufferingNewest(1) + ) + continuations.withValue { + $0[key, default: [:]][id] = continuation } - defer { continuations.value[key]?[id]?.yield(storage.value[key]) } + continuation.yield(storage.value[key]) return stream } } } +fileprivate func _isEqual(_ lhs: (any Sendable)?, _ rhs: (any Sendable)?) -> Bool { + switch (lhs, rhs) { + case let (.some(lhs), .some(rhs)): + return (lhs as! any Equatable).isEqual(other: rhs) + case (.none, .none): + return type(of: lhs) == type(of: rhs) + case (.some, .none), (.none, .some): return false + } +} + +extension Equatable { + fileprivate func isEqual(other: Any) -> Bool { + self == other as? Self + } +} + #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) extension UserDefaults.Dependency { /// An iCloud-based container of key-value pairs you use to share data among diff --git a/Tests/UserDefaultsDependencyTests/UserDefaultsDependencyTests.swift b/Tests/UserDefaultsDependencyTests/UserDefaultsDependencyTests.swift index a2ca267..2ba07fb 100644 --- a/Tests/UserDefaultsDependencyTests/UserDefaultsDependencyTests.swift +++ b/Tests/UserDefaultsDependencyTests/UserDefaultsDependencyTests.swift @@ -1,4 +1,5 @@ import Dependencies +@_spi(Internals) import DependenciesAdditionsBasics import UserDefaultsDependency import XCTest @@ -114,6 +115,7 @@ final class UserDefaultsDependencyTests: XCTestCase { } UserDefaults.standard.removeObject(forKey: "string") } + #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) func testLiveUserDefaultsURL() { let url = URL(string: "https://github.com/tgrapperon/swift-dependencies-additions") @@ -147,6 +149,7 @@ final class UserDefaultsDependencyTests: XCTestCase { UserDefaults.standard.removeObject(forKey: "url") } #endif + func testLiveUserDefaultsStringRawRepresentable() { enum Value: String { case one @@ -187,4 +190,400 @@ final class UserDefaultsDependencyTests: XCTestCase { UserDefaults.standard.removeObject(forKey: "raw") } + func testLiveUserDefaultsBoolValues() async throws { + UserDefaults.standard.removeObject(forKey: "bool") + await withDependencies { + $0.userDefaults = .liveValue + } operation: { + await withTimeout { group in + group.addTask { + var expectations: ArraySlice = [nil, true, nil, false][...] + for await bool in self.userDefaults.boolValues(forKey: "bool") { + XCTAssertEqual(bool, expectations.first) + expectations = expectations.dropFirst() + if expectations.isEmpty { break } + } + } + group.addTask { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(true, forKey: "bool") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(Bool?.none, forKey: "bool") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(false, forKey: "bool") + } + } + } + UserDefaults.standard.removeObject(forKey: "bool") + } + + func testLiveUserDefaultsDataValues() async throws { + UserDefaults.standard.removeObject(forKey: "data") + let d1 = "1".data(using: .utf8)! + let d2 = "2".data(using: .utf8)! + let d3 = "3".data(using: .utf8)! + await withDependencies { + $0.userDefaults = .liveValue + } operation: { + await withTimeout { group in + group.addTask { + var expectations: ArraySlice = [nil, d1, d2, d3][...] + for await data in self.userDefaults.dataValues(forKey: "data") { + XCTAssertEqual(data, expectations.first) + expectations = expectations.dropFirst() + if expectations.isEmpty { break } + } + } + group.addTask { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(d1, forKey: "data") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(d2, forKey: "data") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(d3, forKey: "data") + } + } + } + UserDefaults.standard.removeObject(forKey: "data") + } + + func testLiveUserDefaultsDoubleValues() async throws { + UserDefaults.standard.removeObject(forKey: "double") + await withDependencies { + $0.userDefaults = .liveValue + } operation: { + await withTimeout { group in + group.addTask { + var expectations: ArraySlice = [nil, 1.0, 4.0, 7.0][...] + for await double in self.userDefaults.doubleValues(forKey: "double") { + XCTAssertEqual(double, expectations.first) + expectations = expectations.dropFirst() + if expectations.isEmpty { break } + } + } + group.addTask { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(1.0, forKey: "double") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(4.0, forKey: "double") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(7.0, forKey: "double") + } + } + } + UserDefaults.standard.removeObject(forKey: "double") + } + + func testLiveUserDefaultsStringValues() async throws { + UserDefaults.standard.removeObject(forKey: "string") + await withDependencies { + $0.userDefaults = .liveValue + } operation: { + await withTimeout { group in + group.addTask { + var expectations: ArraySlice = [nil, "a", "b", "c"][...] + for await string in self.userDefaults.stringValues(forKey: "string") { + XCTAssertEqual(string, expectations.first) + expectations = expectations.dropFirst() + if expectations.isEmpty { break } + } + } + group.addTask { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set("a", forKey: "string") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set("b", forKey: "string") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set("c", forKey: "string") + } + } + } + UserDefaults.standard.removeObject(forKey: "string") + } + + #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) + func testLiveUserDefaultsURLValues() async throws { + UserDefaults.standard.removeObject(forKey: "url") + let url1 = URL(string: "www.github.com")! + let url2 = URL(string: "www.apple.com")! + let url3 = URL(string: "www.pointfree.co")! + await withDependencies { + $0.userDefaults = .liveValue + } operation: { + await withTimeout { group in + group.addTask { + var expectations: ArraySlice = [nil, url1, url2, url3][...] + for await url in self.userDefaults.urlValues(forKey: "url") { + XCTAssertEqual(url, expectations.first) + expectations = expectations.dropFirst() + if expectations.isEmpty { break } + } + } + group.addTask { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(url1, forKey: "url") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(url2, forKey: "url") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(url3, forKey: "url") + } + } + } + UserDefaults.standard.removeObject(forKey: "url") + } + #endif + + func testLiveUserDefaultsStringRawRepresentableValues() async throws { + enum Value: String { + case one + case two + } + UserDefaults.standard.removeObject(forKey: "raw") + await withDependencies { + $0.userDefaults = .liveValue + } operation: { + await withTimeout { group in + group.addTask { + var expectations: ArraySlice = [nil, .one, .two, .one][...] + for await value in self.userDefaults.rawRepresentableValues(Value.self, forKey: "raw") + { + XCTAssertEqual(value, expectations.first) + expectations = expectations.dropFirst() + if expectations.isEmpty { break } + } + } + group.addTask { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(Value.one, forKey: "raw") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(Value.two, forKey: "raw") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(Value.one, forKey: "raw") + } + } + } + UserDefaults.standard.removeObject(forKey: "raw") + } + + func testLiveUserDefaultsIntRawRepresentableValues() async throws { + enum Value: Int { + case one + case two + } + UserDefaults.standard.removeObject(forKey: "raw") + await withDependencies { + $0.userDefaults = .liveValue + } operation: { + await withTimeout { group in + group.addTask { + var expectations: ArraySlice = [nil, .one, .two, .one][...] + for await value in self.userDefaults.rawRepresentableValues(Value.self, forKey: "raw") + { + XCTAssertEqual(value, expectations.first) + expectations = expectations.dropFirst() + if expectations.isEmpty { break } + } + } + group.addTask { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(Value.one, forKey: "raw") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(Value.two, forKey: "raw") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(Value.one, forKey: "raw") + } + } + } + UserDefaults.standard.removeObject(forKey: "raw") + } + + func testLiveUserDefaultsDateValues() async throws { + UserDefaults.standard.removeObject(forKey: "date") + let date1 = Date(timeIntervalSince1970: 0) + let date2 = Date(timeIntervalSince1970: 1) + let date3 = Date(timeIntervalSince1970: 7) + await withDependencies { + $0.userDefaults = .liveValue + } operation: { + await withTimeout { group in + group.addTask { + var expectations: ArraySlice = [nil, date1, date2, date3][...] + for await url in self.userDefaults.dateValues(forKey: "date") { + XCTAssertEqual(url, expectations.first) + expectations = expectations.dropFirst() + if expectations.isEmpty { break } + } + } + group.addTask { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(date1, forKey: "date") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(date2, forKey: "date") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(date3, forKey: "date") + } + } + } + UserDefaults.standard.removeObject(forKey: "date") + } + + func testLiveUserDefaultsIntValues() async throws { + UserDefaults.standard.removeObject(forKey: "int") + await withDependencies { + $0.userDefaults = .liveValue + } operation: { + await withTimeout { group in + group.addTask { + var expectations: ArraySlice = [nil, 1, 4, 7][...] + for await int in self.userDefaults.integerValues(forKey: "int") { + XCTAssertEqual(int, expectations.first) + expectations = expectations.dropFirst() + if expectations.isEmpty { break } + } + } + group.addTask { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(1, forKey: "int") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(4, forKey: "int") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(7, forKey: "int") + } + } + } + UserDefaults.standard.removeObject(forKey: "int") + } + + func testLiveUserDefaultsIntValuesWithValue() async throws { + UserDefaults.standard.removeObject(forKey: "int") + await withDependencies { + $0.userDefaults = .liveValue + } operation: { + self.userDefaults.set(42, forKey: "int") + await withTimeout { group in + group.addTask { + var expectations: ArraySlice = [42, 1, 4, 7][...] + for await int in self.userDefaults.integerValues(forKey: "int") { + XCTAssertEqual(int, expectations.first) + expectations = expectations.dropFirst() + if expectations.isEmpty { break } + } + } + group.addTask { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(1, forKey: "int") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(4, forKey: "int") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(7, forKey: "int") + } + } + } + UserDefaults.standard.removeObject(forKey: "int") + } + + func testLiveUserDefaultsIntValuesWithValueDeduplicated() async throws { + UserDefaults.standard.removeObject(forKey: "int") + await withDependencies { + $0.userDefaults = .liveValue + } operation: { + self.userDefaults.set(1, forKey: "int") + await withTimeout { group in + group.addTask { + var expectations: ArraySlice = [1, 4, 7][...] + for await int in self.userDefaults.integerValues(forKey: "int") { + XCTAssertEqual(int, expectations.first) + expectations = expectations.dropFirst() + if expectations.isEmpty { break } + } + } + group.addTask { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(1, forKey: "int") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(4, forKey: "int") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(7, forKey: "int") + } + } + } + UserDefaults.standard.removeObject(forKey: "int") + } + + func testEphemeralUserDefaultsIntValues() async throws { + await withDependencies { + $0.userDefaults = .ephemeral() + } operation: { + await withTimeout { group in + group.addTask { + var expectations: ArraySlice = [nil, 1, 4, 7][...] + for await int in self.userDefaults.integerValues(forKey: "int") { + XCTAssertEqual(int, expectations.first) + expectations = expectations.dropFirst() + if expectations.isEmpty { break } + } + } + group.addTask { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(1, forKey: "int") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(4, forKey: "int") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(7, forKey: "int") + } + } + } + } + + func testEphemeralUserDefaultsIntValuesWithValue() async throws { + await withDependencies { + $0.userDefaults = .ephemeral() + } operation: { + self.userDefaults.set(42, forKey: "int") + await withTimeout { group in + group.addTask { + var expectations: ArraySlice = [42, 1, 4, 7][...] + for await int in self.userDefaults.integerValues(forKey: "int") { + XCTAssertEqual(int, expectations.first) + expectations = expectations.dropFirst() + if expectations.isEmpty { break } + } + } + group.addTask { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(1, forKey: "int") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(4, forKey: "int") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(7, forKey: "int") + } + } + } + } + + func testEphemeralUserDefaultsIntValuesWithValueDeduplicated() async throws { + await withDependencies { + $0.userDefaults = .ephemeral() + } operation: { + self.userDefaults.set(1, forKey: "int") + await withTimeout { group in + group.addTask { + var expectations: ArraySlice = [1, 4, 7][...] + for await int in self.userDefaults.integerValues(forKey: "int") { + XCTAssertEqual(int, expectations.first) + expectations = expectations.dropFirst() + if expectations.isEmpty { break } + } + } + group.addTask { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(1, forKey: "int") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(4, forKey: "int") + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 10) + self.userDefaults.set(7, forKey: "int") + } + } + } + } }