Skip to content

Commit

Permalink
Merge pull request #135 from unsignedapps/flagkeypath-mapping
Browse files Browse the repository at this point in the history
Provide FlagValueSource's with a way to create FlagKeyPaths
  • Loading branch information
bok- authored Dec 14, 2024
2 parents 21bd870 + d55ebea commit 8ed4724
Show file tree
Hide file tree
Showing 18 changed files with 172 additions and 61 deletions.
6 changes: 6 additions & 0 deletions Sources/Vexil/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}


Expand Down
95 changes: 76 additions & 19 deletions Sources/Vexil/KeyPath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 = ".",
Expand All @@ -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) {
Expand All @@ -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 ],
Expand All @@ -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
}
}
}
38 changes: 29 additions & 9 deletions Sources/Vexil/Pole.swift
Original file line number Diff line number Diff line change
Expand Up @@ -418,14 +418,34 @@ public final class FlagPole<RootGroup>: 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<Value>(
keyPath: FlagKeyPath,
value: () -> Value?,
defaultValue: Value,
wigwag: () -> FlagWigwag<Value>
) 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)")
}
}

}
6 changes: 6 additions & 0 deletions Sources/Vexil/Snapshots/MutableFlagContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ public class MutableFlagContainer<Container> where Container: FlagContainer {
}
}

/// A @dynamicMemberLookup implementation for all other properties (eg extensions). This is get-only.
@_disfavoredOverload
public subscript<Value>(dynamicMember dynamicMember: KeyPath<Container, Value>) -> 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
Expand Down
2 changes: 1 addition & 1 deletion Sources/Vexil/Snapshots/Snapshot+FlagValueSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ extension Snapshot: FlagValueSource {
set(value, key: key)
}

public var flagValueChanges: FlagChangeStream {
public func flagValueChanges(keyPathMapper: (String) -> FlagKeyPath) -> FlagChangeStream {
stream.stream
}

Expand Down
5 changes: 4 additions & 1 deletion Sources/Vexil/Snapshots/Snapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ public final class Snapshot<RootGroup>: Sendable where RootGroup: FlagContainer
RootGroup(_flagKeyPath: rootKeyPath, _flagLookup: self)
}

let stream = StreamManager.Stream()
let stream: StreamManager.Stream


// MARK: - Initialisation
Expand All @@ -97,6 +97,7 @@ public final class Snapshot<RootGroup>: 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)
Expand All @@ -107,6 +108,7 @@ public final class Snapshot<RootGroup>: 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 {
Expand All @@ -122,6 +124,7 @@ public final class Snapshot<RootGroup>: Sendable where RootGroup: FlagContainer
self.rootKeyPath = flagPole.rootKeyPath
self.values = snapshot.values
self.displayName = displayName
self.stream = StreamManager.Stream(keyPathMapper: flagPole._configuration.makeKeyPathMapper())
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AsyncStream<String>, FlagChange> {
stream.map {
FlagChange.some([ keyPathMapper($0) ])
}
}

}
8 changes: 7 additions & 1 deletion Sources/Vexil/Sources/FlagValueDictionary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ public final class FlagValueDictionary: Identifiable, ExpressibleByDictionaryLit

let storage: Lock<DictionaryType>

let stream = StreamManager.Stream()
let stream: AsyncStream<String>
let continuation: AsyncStream<String>.Continuation


// MARK: - Initialisation
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion Sources/Vexil/Sources/FlagValueSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

}

Expand Down
4 changes: 2 additions & 2 deletions Sources/Vexil/Sources/FlagValueSourceCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion Sources/Vexil/Sources/NonSendableFlagValueSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

}

Expand Down
8 changes: 4 additions & 4 deletions Sources/Vexil/Sources/UserDefaults+FlagValueSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ extension UserDefaults: NonSendableFlagValueSource {

public typealias ChangeStream = AsyncMapSequence<AsyncFilterSequence<NotificationCenter.Notifications>, FlagChange>

public var flagValueChanges: ChangeStream {
public func flagValueChanges(keyPathMapper: @escaping (String) -> FlagKeyPath) -> ChangeStream {
let this = ObjectIdentifier(self)
return NotificationCenter.default
.notifications(named: UserDefaults.didChangeNotification)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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()
}

Expand Down
Loading

0 comments on commit 8ed4724

Please sign in to comment.