Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 19 additions & 11 deletions Sources/GroveFoundation/LocalPreferences/LocalPreferenceKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,12 @@ extension LocalPreferenceKeys {
/// - ``app``
/// - ``bundle(_:)``
/// - ``custom(_:)``
///
/// ### Instance Methods
/// - ``nested(_:)``
public struct Namespace: Equatable, Sendable {
@usableFromInline let value: String
/// The namespace's path. Empty if this is the global namespace.
@usableFromInline let path: [String]

/// Checks if this is the global namespace.
///
Expand All @@ -354,17 +358,13 @@ extension LocalPreferenceKeys {
}

@inlinable
init(value: String) {
self.value = value
init(path: [String]) {
self.path = path
}

@inlinable
func format(keyName: String, applyKVOCompatibilityFixes: Bool) -> String {
let fullKey = if isGlobal {
keyName
} else {
"\(value):\(keyName)"
}
let fullKey = (path + [keyName]).joined(separator: ":")
return if applyKVOCompatibilityFixes {
// We want to be able to observe these entries via KVO, which doesn't work if they appear to be keyPaths,
// therefore we replace all '.' with '_'.
Expand All @@ -373,6 +373,14 @@ extension LocalPreferenceKeys {
fullKey
}
}

/// Creates a nested namespace, by appending an inner namespace to the current one.
///
/// - Note: Bulk-removing a namespace (via ``LocalPreferencesStore/removeAllEntries(in:)``
/// will also remove all entries belonging to nested namespaces within the outer one.
Comment on lines +379 to +380

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be a bit clearer?

Suggested change
/// - Note: Bulk-removing a namespace (via ``LocalPreferencesStore/removeAllEntries(in:)``
/// will also remove all entries belonging to nested namespaces within the outer one.
/// - Note: Bulk-removing a outer namespace (via ``LocalPreferencesStore/removeAllEntries(in:)``
/// will also remove all entries belonging to nested namespaces within the parent namespace.

public func nested(_ innerName: String) -> Namespace {
Namespace(path: path + [innerName])
}
}
}

Expand All @@ -385,7 +393,7 @@ extension LocalPreferenceKeys.Namespace {

/// A special namespace that causes local preference values be written at the global scope.
@inlinable public static var none: Self {
.custom("")
.init(path: [])
}

/// Creates a namespace that scopes keys based on a bundle id.
Expand All @@ -396,12 +404,12 @@ extension LocalPreferenceKeys.Namespace {
guard let bundleId = bundle.bundleIdentifier else {
return .none
}
return Self(value: bundleId)
return Self(path: [bundleId])
}

/// Creates a namespace that scopes keys based on a custom string.
@inlinable
public static func custom(_ value: String) -> Self {
Self(value: value)
Self(path: [value])
}
}
69 changes: 64 additions & 5 deletions Tests/GroveFoundationTests/LocalPreferenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ import Testing

@Suite(.serialized)
final class LocalPreferenceTests {
let suiteName = "org.grovealliance.GroveFoundation.unitTests"
let suite: UserDefaults
let store: LocalPreferencesStore
private typealias Namespace = LocalPreferenceKeys.Namespace

private let suiteName = "org.grovealliance.GroveFoundation.unitTests"
private let suite: UserDefaults
private let store: LocalPreferencesStore

init() throws {
suite = try #require(UserDefaults(suiteName: suiteName))
Expand Down Expand Up @@ -343,12 +345,12 @@ final class LocalPreferenceTests {

@Test
func namespaceRemoval() {
func countEntries(in namespace: LocalPreferenceKeys.Namespace) -> Int {
func countEntries(in namespace: Namespace) -> Int {
store.defaults.dictionaryRepresentation().keys.count { key in
key.starts(with: namespace.format(keyName: "", applyKVOCompatibilityFixes: true))
}
}
func makeKey(_ key: String, in namespace: LocalPreferenceKeys.Namespace) -> LocalPreferenceKey<String> {
func makeKey(_ key: String, in namespace: Namespace) -> LocalPreferenceKey<String> {
.init(LocalPreferenceKey<String>.Key(key, in: namespace), default: "")
}

Expand Down Expand Up @@ -398,6 +400,63 @@ final class LocalPreferenceTests {
}


@Test
func nestedNamespaces() {
let outerNS: Namespace = .custom("outer")
let innerNS = outerNS.nested("inner")
#expect(outerNS.format(keyName: "key", applyKVOCompatibilityFixes: false) == "outer:key")
#expect(innerNS.format(keyName: "key", applyKVOCompatibilityFixes: false) == "outer:inner:key")

let outerKey = LocalPreferenceKey<String?>(.init("key", in: outerNS))
#expect(outerKey.key.value == "outer:key")
let innerKey = LocalPreferenceKey<String?>(.init("key", in: innerNS))
#expect(innerKey.key.value == "outer:inner:key")

#expect(!store.hasEntry(for: outerKey))
#expect(!store.hasEntry(for: innerKey))
#expect(!store.hasEntry(in: outerNS))
#expect(!store.hasEntry(in: innerNS))

store[outerKey] = "Hey!"
#expect(store.hasEntry(for: outerKey))
#expect(!store.hasEntry(for: innerKey))
#expect(store.hasEntry(in: outerNS))
#expect(!store.hasEntry(in: innerNS))
#expect(store[outerKey] == "Hey!")

store[innerKey] = "Hey :)"
#expect(store.hasEntry(for: outerKey))
#expect(store.hasEntry(for: innerKey))
#expect(store.hasEntry(in: outerNS))
#expect(store.hasEntry(in: innerNS))
#expect(store[outerKey] == "Hey!")
#expect(store[innerKey] == "Hey :)")

// if we clear the inner namespace, its content get removed but those of the outer one remain
store.removeAllEntries(in: innerNS)
#expect(store.hasEntry(for: outerKey))
#expect(!store.hasEntry(for: innerKey))
#expect(store.hasEntry(in: outerNS))
#expect(!store.hasEntry(in: innerNS))
#expect(store[outerKey] == "Hey!")
#expect(store[innerKey] == nil)

// but if we clear the outer ns, the inner one implicitly also gets cleared
store[innerKey] = "Hey :)"
#expect(store.hasEntry(for: outerKey))
#expect(store.hasEntry(for: innerKey))
#expect(store.hasEntry(in: outerNS))
#expect(store.hasEntry(in: innerNS))
#expect(store[outerKey] == "Hey!")
#expect(store[innerKey] == "Hey :)")
store.removeAllEntries(in: outerNS)
#expect(!store.hasEntry(for: outerKey))
#expect(!store.hasEntry(for: innerKey))
#expect(!store.hasEntry(in: outerNS))
#expect(!store.hasEntry(in: innerNS))
}


// MARK: Migration Testing

private func withTemporarySuiteForMigration(
Expand Down
Loading