From d55ebea960b4f4907855519995d86cb491a24b9f Mon Sep 17 00:00:00 2001 From: Rob Amos Date: Sat, 14 Dec 2024 23:13:55 +1100 Subject: [PATCH] Provide FlagValueSource's with a way to create FlagKeyPaths that are consistent with the FlagPole. --- Sources/Vexil/Configuration.swift | 6 ++ Sources/Vexil/KeyPath.swift | 95 +++++++++++++++---- Sources/Vexil/Pole.swift | 38 ++++++-- .../Snapshots/MutableFlagContainer.swift | 6 ++ .../Snapshots/Snapshot+FlagValueSource.swift | 2 +- Sources/Vexil/Snapshots/Snapshot.swift | 5 +- .../FlagValueDictionary+FlagValueSource.swift | 8 +- .../Vexil/Sources/FlagValueDictionary.swift | 8 +- Sources/Vexil/Sources/FlagValueSource.swift | 6 +- .../Sources/FlagValueSourceCoordinator.swift | 4 +- ...quitousKeyValueStore+FlagValueSource.swift | 2 +- .../Sources/NonSendableFlagValueSource.swift | 5 +- .../UserDefaults+FlagValueSource.swift | 8 +- Sources/Vexil/StreamManager.swift | 18 +++- .../Utilities/PatternBindingSyntax.swift | 14 +-- .../FlagValueCompilationTests.swift | 2 +- Tests/VexilTests/FlagValueSourceTests.swift | 4 +- Tests/VexilTests/PublisherTests.swift | 2 +- 18 files changed, 172 insertions(+), 61 deletions(-) diff --git a/Sources/Vexil/Configuration.swift b/Sources/Vexil/Configuration.swift index 0b965542..5ee99346 100644 --- a/Sources/Vexil/Configuration.swift +++ b/Sources/Vexil/Configuration.swift @@ -49,6 +49,12 @@ public struct VexilConfiguration: Sendable { public static var `default`: VexilConfiguration { VexilConfiguration() } + + func makeKeyPathMapper() -> @Sendable (String) -> FlagKeyPath { + { + FlagKeyPath($0, separator: separator, strategy: codingPathStrategy) + } + } } diff --git a/Sources/Vexil/KeyPath.swift b/Sources/Vexil/KeyPath.swift index fc10ccf7..ad09b740 100644 --- a/Sources/Vexil/KeyPath.swift +++ b/Sources/Vexil/KeyPath.swift @@ -13,7 +13,7 @@ public struct FlagKeyPath: Hashable, Sendable { - public enum Key: Hashable, Sendable { + public enum Key: Sendable { case root case automatic(String) case kebabcase(String) @@ -25,11 +25,26 @@ public struct FlagKeyPath: Hashable, Sendable { // MARK: - Properties let keyPath: [Key] + + public let key: String public let separator: String public let strategy: VexilConfiguration.CodingKeyStrategy // MARK: - Initialisation + /// Memberwise initialiser + init( + _ keyPath: [Key], + separator: String = ".", + strategy: VexilConfiguration.CodingKeyStrategy = .default, + key: String + ) { + self.keyPath = keyPath + self.separator = separator + self.strategy = strategy + self.key = key + } + public init( _ keyPath: [Key], separator: String = ".", @@ -38,6 +53,18 @@ public struct FlagKeyPath: Hashable, Sendable { self.keyPath = keyPath self.separator = separator self.strategy = strategy + + self.key = { + var toReturn = [String]() + for path in keyPath { + switch path.stringKeyMode(strategy: strategy) { + case let .append(key): toReturn.append(key) + case let .replace(key): return key + case .root: break + } + } + return toReturn.joined(separator: separator) + }() } public init(_ key: String, separator: String = ".", strategy: VexilConfiguration.CodingKeyStrategy = .default) { @@ -50,27 +77,22 @@ public struct FlagKeyPath: Hashable, Sendable { FlagKeyPath( keyPath + [ key ], separator: separator, - strategy: strategy + strategy: strategy, + key: { + switch key.stringKeyMode(strategy: strategy) { + case let .append(string) where self.key.isEmpty: + string + case let .append(string): + self.key + separator + string + case let .replace(string): + string + case .root: + self.key // mostly a noop + } + }() ) } - public var key: String { - var toReturn = [String]() - for path in keyPath { - switch (path, strategy) { - case let (.automatic(key), .default), let (.automatic(key), .kebabcase), let (.kebabcase(key), _), let (.customKey(key), _): - toReturn.append(key) - case let (.automatic(key), .snakecase), let (.snakecase(key), _): - toReturn.append(key.replacingOccurrences(of: "-", with: "_")) - case let (.customKeyPath(key), _): - return key - case (.root, _): - break - } - } - return toReturn.joined(separator: separator) - } - static func root(separator: String, strategy: VexilConfiguration.CodingKeyStrategy) -> FlagKeyPath { FlagKeyPath( [ .root ], @@ -79,4 +101,39 @@ public struct FlagKeyPath: Hashable, Sendable { ) } + // MARK: - Hashable + + // Equality for us is based on the output key, not how it was created. Otherwise + // keys coming back from external sources will never match an internally created one. + + public static func == (lhs: FlagKeyPath, rhs: FlagKeyPath) -> Bool { + lhs.key == rhs.key + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(key) + } + +} + + +private extension FlagKeyPath.Key { + enum Mode { + case append(String) + case replace(String) + case root + } + + func stringKeyMode(strategy: VexilConfiguration.CodingKeyStrategy) -> Mode { + switch (self, strategy) { + case let (.automatic(key), .default), let (.automatic(key), .kebabcase), let (.kebabcase(key), _), let (.customKey(key), _): + .append(key) + case let (.automatic(key), .snakecase), let (.snakecase(key), _): + .append(key.replacingOccurrences(of: "-", with: "_")) + case let (.customKeyPath(key), _): + .replace(key) + case (.root, _): + .root + } + } } diff --git a/Sources/Vexil/Pole.swift b/Sources/Vexil/Pole.swift index e6bc779d..bd0c7acb 100644 --- a/Sources/Vexil/Pole.swift +++ b/Sources/Vexil/Pole.swift @@ -418,14 +418,34 @@ public final class FlagPole: Sendable where RootGroup: FlagContainer extension FlagPole: CustomDebugStringConvertible { public var debugDescription: String { - "FlagPole<\(String(describing: RootGroup.self))>(" - + Mirror(reflecting: rootGroup).children - .map { _, value -> String in - (value as? CustomDebugStringConvertible)?.debugDescription - ?? (value as? CustomStringConvertible)?.description - ?? String(describing: value) - } - .joined(separator: "; ") - + ")" + let visitor = DebugDescriptionVisitor() + walk(visitor: visitor) + return "FlagPole<\(String(describing: RootGroup.self))>(\(visitor.valueDescriptions.joined(separator: "; ")))" } } + +private final class DebugDescriptionVisitor: FlagVisitor { + + var valueDescriptions = [String]() + + func visitFlag( + keyPath: FlagKeyPath, + value: () -> Value?, + defaultValue: Value, + wigwag: () -> FlagWigwag + ) where Value: FlagValue { + guard let value = value() else { + valueDescriptions.append("\(keyPath.key)=nil") + return + } + + if let debug = value as? CustomDebugStringConvertible { + valueDescriptions.append("\(keyPath.key)=\(debug)") + } else if let description = value as? CustomStringConvertible { + valueDescriptions.append("\(keyPath.key)=\(description)") + } else { + valueDescriptions.append("\(keyPath.key)=\(value)") + } + } + +} diff --git a/Sources/Vexil/Snapshots/MutableFlagContainer.swift b/Sources/Vexil/Snapshots/MutableFlagContainer.swift index 7b81359d..bf486d08 100644 --- a/Sources/Vexil/Snapshots/MutableFlagContainer.swift +++ b/Sources/Vexil/Snapshots/MutableFlagContainer.swift @@ -61,6 +61,12 @@ public class MutableFlagContainer where Container: FlagContainer { } } + /// A @dynamicMemberLookup implementation for all other properties (eg extensions). This is get-only. + @_disfavoredOverload + public subscript(dynamicMember dynamicMember: KeyPath) -> Value { + container[keyPath: dynamicMember] + } + /// Internal initialiser used to create MutableFlagGroups for a given subgroup and snapshot init(group: Container, source: any FlagValueSource) { self.container = group diff --git a/Sources/Vexil/Snapshots/Snapshot+FlagValueSource.swift b/Sources/Vexil/Snapshots/Snapshot+FlagValueSource.swift index 62b38a57..7b3dc5cd 100644 --- a/Sources/Vexil/Snapshots/Snapshot+FlagValueSource.swift +++ b/Sources/Vexil/Snapshots/Snapshot+FlagValueSource.swift @@ -27,7 +27,7 @@ extension Snapshot: FlagValueSource { set(value, key: key) } - public var flagValueChanges: FlagChangeStream { + public func flagValueChanges(keyPathMapper: (String) -> FlagKeyPath) -> FlagChangeStream { stream.stream } diff --git a/Sources/Vexil/Snapshots/Snapshot.swift b/Sources/Vexil/Snapshots/Snapshot.swift index ebc8d197..38f31189 100644 --- a/Sources/Vexil/Snapshots/Snapshot.swift +++ b/Sources/Vexil/Snapshots/Snapshot.swift @@ -83,7 +83,7 @@ public final class Snapshot: Sendable where RootGroup: FlagContainer RootGroup(_flagKeyPath: rootKeyPath, _flagLookup: self) } - let stream = StreamManager.Stream() + let stream: StreamManager.Stream // MARK: - Initialisation @@ -97,6 +97,7 @@ public final class Snapshot: Sendable where RootGroup: FlagContainer self.rootKeyPath = flagPole.rootKeyPath self.values = .init(initialState: [:]) self.displayName = displayName + self.stream = StreamManager.Stream(keyPathMapper: flagPole._configuration.makeKeyPathMapper()) if let source { populateValuesFrom(source, flagPole: flagPole, keys: keys) @@ -107,6 +108,7 @@ public final class Snapshot: Sendable where RootGroup: FlagContainer self.rootKeyPath = flagPole.rootKeyPath self.values = .init(initialState: [:]) self.displayName = displayName + self.stream = StreamManager.Stream(keyPathMapper: flagPole._configuration.makeKeyPathMapper()) if let source { switch change { @@ -122,6 +124,7 @@ public final class Snapshot: Sendable where RootGroup: FlagContainer self.rootKeyPath = flagPole.rootKeyPath self.values = snapshot.values self.displayName = displayName + self.stream = StreamManager.Stream(keyPathMapper: flagPole._configuration.makeKeyPathMapper()) } diff --git a/Sources/Vexil/Sources/FlagValueDictionary+FlagValueSource.swift b/Sources/Vexil/Sources/FlagValueDictionary+FlagValueSource.swift index 4bd01c04..cbfc676b 100644 --- a/Sources/Vexil/Sources/FlagValueDictionary+FlagValueSource.swift +++ b/Sources/Vexil/Sources/FlagValueDictionary+FlagValueSource.swift @@ -34,11 +34,13 @@ extension FlagValueDictionary: FlagValueSource { storage.removeValue(forKey: key) } } - stream.send(.some([ FlagKeyPath(key) ])) + continuation.yield(key) } - public var flagValueChanges: FlagChangeStream { - stream.stream + public func flagValueChanges(keyPathMapper: @Sendable @escaping (String) -> FlagKeyPath) -> AsyncMapSequence, FlagChange> { + stream.map { + FlagChange.some([ keyPathMapper($0) ]) + } } } diff --git a/Sources/Vexil/Sources/FlagValueDictionary.swift b/Sources/Vexil/Sources/FlagValueDictionary.swift index aa2357e2..ec9a2bbd 100644 --- a/Sources/Vexil/Sources/FlagValueDictionary.swift +++ b/Sources/Vexil/Sources/FlagValueDictionary.swift @@ -37,7 +37,8 @@ public final class FlagValueDictionary: Identifiable, ExpressibleByDictionaryLit let storage: Lock - let stream = StreamManager.Stream() + let stream: AsyncStream + let continuation: AsyncStream.Continuation // MARK: - Initialisation @@ -46,12 +47,14 @@ public final class FlagValueDictionary: Identifiable, ExpressibleByDictionaryLit init(id: String, storage: DictionaryType) { self.id = id self.storage = .init(initialState: storage) + (self.stream, self.continuation) = AsyncStream.makeStream() } /// Initialises an empty `FlagValueDictionary` public init() { self.id = UUID().uuidString self.storage = .init(initialState: [:]) + (self.stream, self.continuation) = AsyncStream.makeStream() } /// Initialises a `FlagValueDictionary` with the specified dictionary @@ -60,6 +63,7 @@ public final class FlagValueDictionary: Identifiable, ExpressibleByDictionaryLit self.storage = .init(initialState: sequence.reduce(into: [:]) { dict, pair in dict.updateValue(pair.value, forKey: pair.key) }) + (self.stream, self.continuation) = AsyncStream.makeStream() } /// Initialises a `FlagValueDictionary` using a dictionary literal @@ -68,6 +72,7 @@ public final class FlagValueDictionary: Identifiable, ExpressibleByDictionaryLit self.storage = .init(initialState: elements.reduce(into: [:]) { dict, pair in dict.updateValue(pair.1, forKey: pair.0) }) + (self.stream, self.continuation) = AsyncStream.makeStream() } // MARK: - Dictionary Access @@ -88,6 +93,7 @@ public final class FlagValueDictionary: Identifiable, ExpressibleByDictionaryLit let container = try decoder.container(keyedBy: CodingKeys.self) self.id = try container.decode(String.self, forKey: .id) self.storage = try .init(initialState: container.decode(DictionaryType.self, forKey: .storage)) + (self.stream, self.continuation) = AsyncStream.makeStream() } public func encode(to encoder: any Encoder) throws { diff --git a/Sources/Vexil/Sources/FlagValueSource.swift b/Sources/Vexil/Sources/FlagValueSource.swift index 184c5483..2e61cdc6 100644 --- a/Sources/Vexil/Sources/FlagValueSource.swift +++ b/Sources/Vexil/Sources/FlagValueSource.swift @@ -44,7 +44,11 @@ public protocol FlagValueSource: AnyObject & Sendable { /// Return an `AsyncSequence` that emits ``FlagChange`` values any time flag values have changed. /// If your implementation does not support real-time flag value monitoring you can return an ``EmptyFlagChangeStream``. - var flagValueChanges: ChangeStream { get } + /// + /// This method is called with an optional closure you can use to convert String-based key paths + /// back into FlagKeyPaths according to the configuration of the receiving FlagPole. + /// + func flagValueChanges(keyPathMapper: @Sendable @escaping (String) -> FlagKeyPath) -> ChangeStream } diff --git a/Sources/Vexil/Sources/FlagValueSourceCoordinator.swift b/Sources/Vexil/Sources/FlagValueSourceCoordinator.swift index 61e2c19e..bfc71ce6 100644 --- a/Sources/Vexil/Sources/FlagValueSourceCoordinator.swift +++ b/Sources/Vexil/Sources/FlagValueSourceCoordinator.swift @@ -72,9 +72,9 @@ extension FlagValueSourceCoordinator: FlagValueSource { } } - public var flagValueChanges: Source.ChangeStream { + public func flagValueChanges(keyPathMapper: @Sendable @escaping (String) -> FlagKeyPath) -> Source.ChangeStream { source.withLockUnchecked { - $0.flagValueChanges + $0.flagValueChanges(keyPathMapper: keyPathMapper) } } diff --git a/Sources/Vexil/Sources/NSUbiquitousKeyValueStore+FlagValueSource.swift b/Sources/Vexil/Sources/NSUbiquitousKeyValueStore+FlagValueSource.swift index 8169d571..8cebd6ee 100644 --- a/Sources/Vexil/Sources/NSUbiquitousKeyValueStore+FlagValueSource.swift +++ b/Sources/Vexil/Sources/NSUbiquitousKeyValueStore+FlagValueSource.swift @@ -67,7 +67,7 @@ extension NSUbiquitousKeyValueStore: NonSendableFlagValueSource { FlagChange > - public var flagValueChanges: ChangeStream { + public func flagValueChanges(keyPathMapper: @Sendable @escaping (String) -> FlagKeyPath) -> ChangeStream { let this = ObjectIdentifier(self) return chain( NotificationCenter.default diff --git a/Sources/Vexil/Sources/NonSendableFlagValueSource.swift b/Sources/Vexil/Sources/NonSendableFlagValueSource.swift index 57bb2fd3..70801870 100644 --- a/Sources/Vexil/Sources/NonSendableFlagValueSource.swift +++ b/Sources/Vexil/Sources/NonSendableFlagValueSource.swift @@ -55,7 +55,10 @@ public protocol NonSendableFlagValueSource { mutating func setFlagValue(_ value: (some FlagValue)?, key: String) throws /// Return an `AsyncSequence` that emits ``FlagChange`` values any time flag values have changed. - var flagValueChanges: ChangeStream { get } + /// + /// This method is called with an optional closure you can use to convert String-based key paths + /// back into FlagKeyPaths according to the configuration of the receiving FlagPole. + func flagValueChanges(keyPathMapper: @Sendable @escaping (String) -> FlagKeyPath) -> ChangeStream } diff --git a/Sources/Vexil/Sources/UserDefaults+FlagValueSource.swift b/Sources/Vexil/Sources/UserDefaults+FlagValueSource.swift index 802de377..279d4ed8 100644 --- a/Sources/Vexil/Sources/UserDefaults+FlagValueSource.swift +++ b/Sources/Vexil/Sources/UserDefaults+FlagValueSource.swift @@ -69,7 +69,7 @@ extension UserDefaults: NonSendableFlagValueSource { public typealias ChangeStream = AsyncMapSequence, FlagChange> - public var flagValueChanges: ChangeStream { + public func flagValueChanges(keyPathMapper: @escaping (String) -> FlagKeyPath) -> ChangeStream { let this = ObjectIdentifier(self) return NotificationCenter.default .notifications(named: UserDefaults.didChangeNotification) @@ -89,7 +89,7 @@ extension UserDefaults: NonSendableFlagValueSource { FlagChange > - public var flagValueChanges: ChangeStream { + public func flagValueChanges(keyPathMapper: @Sendable @escaping (String) -> FlagKeyPath) -> ChangeStream { let this = ObjectIdentifier(self) return chain( NotificationCenter.default @@ -114,7 +114,7 @@ extension UserDefaults: NonSendableFlagValueSource { FlagChange > - public var flagValueChanges: ChangeStream { + public func flagValueChanges(keyPathMapper: @escaping (String) -> FlagKeyPath) -> ChangeStream { let this = ObjectIdentifier(self) return chain( NotificationCenter.default @@ -132,7 +132,7 @@ extension UserDefaults: NonSendableFlagValueSource { #else /// No support for real-time flag publishing with `UserDefaults` on Linux - public var flagValueChanges: EmptyFlagChangeStream { + public func flagValueChanges(keyPathMapper: @escaping (String) -> FlagKeyPath) -> EmptyFlagChangeStream { .init() } diff --git a/Sources/Vexil/StreamManager.swift b/Sources/Vexil/StreamManager.swift index 3debcdfd..b830487e 100644 --- a/Sources/Vexil/StreamManager.swift +++ b/Sources/Vexil/StreamManager.swift @@ -55,7 +55,7 @@ extension FlagPole { } // Setup streaming - let stream = StreamManager.Stream() + let stream = StreamManager.Stream(keyPathMapper: _configuration.makeKeyPathMapper()) manager.stream = stream subscribeChannel(oldSources: [], newSources: manager.sources, on: &manager, isInitialSetup: true) return stream @@ -99,9 +99,9 @@ extension FlagPole { } private func makeSubscribeTask(for source: some FlagValueSource) -> Task { - .detached(priority: .low) { [manager] in + .detached { [manager, _configuration] in do { - for try await change in source.flagValueChanges { + for try await change in source.flagValueChanges(keyPathMapper: _configuration.makeKeyPathMapper()) { manager.withLock { $0.stream?.send(change) } @@ -136,11 +136,13 @@ extension StreamManager { struct Stream { var stream: AsyncStream var continuation: AsyncStream.Continuation + let keyPathMapper: @Sendable (String) -> FlagKeyPath - init() { + init(keyPathMapper: @Sendable @escaping (String) -> FlagKeyPath) { let (stream, continuation) = AsyncStream.makeStream() self.stream = stream self.continuation = continuation + self.keyPathMapper = keyPathMapper } func finish() { @@ -150,6 +152,14 @@ extension StreamManager { func send(_ change: FlagChange) { continuation.yield(change) } + + func send(keys: Set) { + if keys.isEmpty { + send(.all) + } else { + send(.some(Set(keys.map(keyPathMapper)))) + } + } } } diff --git a/Sources/VexilMacros/Utilities/PatternBindingSyntax.swift b/Sources/VexilMacros/Utilities/PatternBindingSyntax.swift index 9121863a..4b84370a 100644 --- a/Sources/VexilMacros/Utilities/PatternBindingSyntax.swift +++ b/Sources/VexilMacros/Utilities/PatternBindingSyntax.swift @@ -34,17 +34,11 @@ extension PatternBindingSyntax { } else if let function = initializer.value.as(FunctionCallExprSyntax.self) { if let identifier = function.calledExpression.as(DeclReferenceExprSyntax.self) { return TypeSyntax(IdentifierTypeSyntax(name: identifier.baseName)) - } else if - let memberAccess = function.calledExpression.as(MemberAccessExprSyntax.self), - let identifier = memberAccess.base?.as(DeclReferenceExprSyntax.self) - { - return TypeSyntax(IdentifierTypeSyntax(name: identifier.baseName)) + } else if let memberAccess = function.calledExpression.as(MemberAccessExprSyntax.self)?.asMemberTypeSyntax() { + return TypeSyntax(memberAccess.baseType) } - } else if - let memberAccess = initializer.value.as(MemberAccessExprSyntax.self), - let identifier = memberAccess.base?.as(DeclReferenceExprSyntax.self) - { - return TypeSyntax(IdentifierTypeSyntax(name: identifier.baseName)) + } else if let memberAccess = initializer.value.as(MemberAccessExprSyntax.self)?.asMemberTypeSyntax() { + return TypeSyntax(memberAccess.baseType) } } diff --git a/Tests/VexilTests/FlagValueCompilationTests.swift b/Tests/VexilTests/FlagValueCompilationTests.swift index aed51112..3ce78fdf 100644 --- a/Tests/VexilTests/FlagValueCompilationTests.swift +++ b/Tests/VexilTests/FlagValueCompilationTests.swift @@ -81,7 +81,7 @@ struct FlagValueCompilationTests { fatalError() } - var flagValueChanges: EmptyFlagChangeStream { + func flagValueChanges(keyPathMapper: @Sendable @escaping (String) -> FlagKeyPath) -> EmptyFlagChangeStream { .init() } } diff --git a/Tests/VexilTests/FlagValueSourceTests.swift b/Tests/VexilTests/FlagValueSourceTests.swift index 97449789..4915286d 100644 --- a/Tests/VexilTests/FlagValueSourceTests.swift +++ b/Tests/VexilTests/FlagValueSourceTests.swift @@ -155,7 +155,7 @@ private final class TestGetSource: FlagValueSource { func setFlagValue(_ value: (some FlagValue)?, key: String) throws {} - var flagValueChanges: EmptyFlagChangeStream { + func flagValueChanges(keyPathMapper: @Sendable @escaping (String) -> FlagKeyPath) -> EmptyFlagChangeStream { .init() } @@ -185,7 +185,7 @@ private final class TestSetSource: FlagValueSource { subject((key, value)) } - var flagValueChanges: EmptyFlagChangeStream { + func flagValueChanges(keyPathMapper: @Sendable @escaping (String) -> FlagKeyPath) -> EmptyFlagChangeStream { .init() } diff --git a/Tests/VexilTests/PublisherTests.swift b/Tests/VexilTests/PublisherTests.swift index ea6df2f3..c1fe0fb3 100644 --- a/Tests/VexilTests/PublisherTests.swift +++ b/Tests/VexilTests/PublisherTests.swift @@ -215,7 +215,7 @@ private final class TestSource: FlagValueSource { func setFlagValue(_ value: (some FlagValue)?, key: String) throws {} - var flagValueChanges: AsyncStream { + func flagValueChanges(keyPathMapper: @Sendable @escaping (String) -> FlagKeyPath) -> AsyncStream { stream }