diff --git a/Sources/Spezi/Capabilities/Communication/CollectPropertyWrapper.swift b/Sources/Spezi/Capabilities/Communication/CollectPropertyWrapper.swift index 4f33cf5e..97bd2b1a 100644 --- a/Sources/Spezi/Capabilities/Communication/CollectPropertyWrapper.swift +++ b/Sources/Spezi/Capabilities/Communication/CollectPropertyWrapper.swift @@ -67,6 +67,6 @@ extension Module { extension _CollectPropertyWrapper: StorageValueCollector { public func retrieve>(from repository: Repository) { - injectedValues = repository[CollectedModuleValue.self] ?? [] + injectedValues = repository[CollectedModuleValues.self]?.map { $0.value } ?? [] } } diff --git a/Sources/Spezi/Capabilities/Communication/CollectedModuleValue.swift b/Sources/Spezi/Capabilities/Communication/CollectedModuleValue.swift deleted file mode 100644 index 0f6c5a66..00000000 --- a/Sources/Spezi/Capabilities/Communication/CollectedModuleValue.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SpeziFoundation - - -/// Provides the ``KnowledgeSource`` for any value we store in the ``SpeziStorage`` that is -/// provided or request from am ``Module``. -/// -/// For more information, look at the ``Module/Provide`` or ``Module/Collect`` property wrappers. -struct CollectedModuleValue: DefaultProvidingKnowledgeSource { - typealias Anchor = SpeziAnchor - - typealias Value = [ModuleValue] - - - static var defaultValue: [ModuleValue] { - [] - } - - - static func reduce(value: inout [ModuleValue], nextValue: [ModuleValue]) { - value.append(contentsOf: nextValue) - } -} diff --git a/Sources/Spezi/Capabilities/Communication/CollectedModuleValues.swift b/Sources/Spezi/Capabilities/Communication/CollectedModuleValues.swift new file mode 100644 index 00000000..93cdbb2f --- /dev/null +++ b/Sources/Spezi/Capabilities/Communication/CollectedModuleValues.swift @@ -0,0 +1,79 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziFoundation + +protocol AnyCollectModuleValue { + associatedtype Value + + var moduleReference: ModuleReference { get } +} + +protocol AnyCollectModuleValues { + associatedtype Value + + var values: [any AnyCollectModuleValue] { get } + + mutating func removeValues(from module: any Module) -> Bool + + func store(into storage: inout SpeziStorage) +} + + +struct CollectModuleValue: AnyCollectModuleValue { + let value: Value + let moduleReference: ModuleReference + + init(_ value: Value) { + self.value = value + + guard let module = Spezi.moduleInitContext else { + preconditionFailure("Tried to initialize CollectModuleValue with unknown module init context.") + } + self.moduleReference = ModuleReference(module) + } +} + +/// Provides the ``KnowledgeSource`` for any value we store in the ``SpeziStorage`` that is +/// provided or request from am ``Module``. +/// +/// For more information, look at the ``Module/Provide`` or ``Module/Collect`` property wrappers. +struct CollectedModuleValues: DefaultProvidingKnowledgeSource { + typealias Anchor = SpeziAnchor + + typealias Value = [CollectModuleValue] + + + static var defaultValue: Value { + [] + } +} + + +extension Array: AnyCollectModuleValues where Element: AnyCollectModuleValue { + typealias Value = Element.Value + + var values: [any AnyCollectModuleValue] { + self + } + + mutating func removeValues(from module: any Module) -> Bool { + let previousCount = count + removeAll { entry in + entry.moduleReference == ModuleReference(module) + } + return previousCount != count + } + + func store(into storage: inout SpeziStorage) { + guard let values = self as? [CollectModuleValue] else { + preconditionFailure("Unexpected array type: \(type(of: self))") + } + storage[CollectedModuleValues.self] = values + } +} diff --git a/Sources/Spezi/Capabilities/Communication/ProvidePropertyWrapper.swift b/Sources/Spezi/Capabilities/Communication/ProvidePropertyWrapper.swift index 1704399b..b6ad4f52 100644 --- a/Sources/Spezi/Capabilities/Communication/ProvidePropertyWrapper.swift +++ b/Sources/Spezi/Capabilities/Communication/ProvidePropertyWrapper.swift @@ -116,8 +116,7 @@ extension _ProvidePropertyWrapper: StorageValueProvider { } else if let wrapperWithArray = self as? CollectionBasedProvideProperty { wrapperWithArray.collectArrayElements(into: &repository) } else { - // concatenation is handled by the `CollectedModuleValue/reduce` implementation. - repository[CollectedModuleValue.self] = [storedValue] + repository.appendValues([CollectModuleValue(storedValue)]) } collected = true @@ -127,8 +126,7 @@ extension _ProvidePropertyWrapper: StorageValueProvider { extension _ProvidePropertyWrapper: CollectionBasedProvideProperty where Value: AnyArray { func collectArrayElements>(into repository: inout Repository) { - // concatenation is handled by the `CollectedModuleValue/reduce` implementation. - repository[CollectedModuleValue.self] = storedValue.unwrappedArray + repository.appendValues(storedValue.unwrappedArray.map { CollectModuleValue($0) }) } } @@ -136,7 +134,16 @@ extension _ProvidePropertyWrapper: CollectionBasedProvideProperty where Value: A extension _ProvidePropertyWrapper: OptionalBasedProvideProperty where Value: AnyOptional { func collectOptional>(into repository: inout Repository) { if let storedValue = storedValue.unwrappedOptional { - repository[CollectedModuleValue.self] = [storedValue] + repository.appendValues([CollectModuleValue(storedValue)]) } } } + + +extension SharedRepository where Anchor == SpeziAnchor { + fileprivate mutating func appendValues(_ values: [CollectModuleValue]) { + var current = self[CollectedModuleValues.self] + current.append(contentsOf: values) + self[CollectedModuleValues.self] = current + } +} diff --git a/Sources/Spezi/Dependencies/Property/DependencyPropertyWrapper.swift b/Sources/Spezi/Dependencies/Property/DependencyPropertyWrapper.swift index 8da6815e..4c0afc8c 100644 --- a/Sources/Spezi/Dependencies/Property/DependencyPropertyWrapper.swift +++ b/Sources/Spezi/Dependencies/Property/DependencyPropertyWrapper.swift @@ -33,6 +33,7 @@ private protocol ModuleArrayDependency { public class _DependencyPropertyWrapper { // swiftlint:disable:this type_name private let dependencies: DependencyCollection + /// The dependency value. public var wrappedValue: Value { if let singleModule = self as? SingleModuleDependency { return singleModule.wrappedValue(as: Value.self) diff --git a/Sources/Spezi/Spezi/Spezi.swift b/Sources/Spezi/Spezi/Spezi.swift index e096640e..d330930c 100644 --- a/Sources/Spezi/Spezi/Spezi.swift +++ b/Sources/Spezi/Spezi/Spezi.swift @@ -68,11 +68,30 @@ private struct ImplicitlyCreatedModulesKey: DefaultProvidingKnowledgeSource { /// The ``Module`` documentation provides more information about the structure of modules. /// Refer to the ``Configuration`` documentation to learn more about the Spezi configuration. /// +/// ### Dynamically Loading Modules +/// +/// While the above examples demonstrated how Modules are configured within your ``SpeziAppDelegate``, they can also be loaded and unloaded dynamically based on demand. +/// To do so you, you need to access the global `Spezi` instance from within your Module. +/// +/// Below is a short code example: +/// ```swift +/// class ExampleModule: Module { +/// @Application(\.spezi) +/// var spezi +/// +/// func userAuthenticated() { +/// spezi.loadModule(AccountManagement()) +/// // ... +/// } +/// } +/// ``` +/// /// ## Topics /// /// ### Properties /// - ``logger`` /// - ``launchOptions`` +/// - ``spezi`` /// /// ### Actions /// - ``registerRemoteNotifications`` @@ -182,6 +201,7 @@ public class Spezi { } private func loadModules(_ modules: [any Module]) { + precondition(Self.moduleInitContext == nil, "Modules cannot be loaded within the `configure()` method.") let existingModules = self.modules let dependencyManager = DependencyManager(modules, existing: existingModules) @@ -189,9 +209,11 @@ public class Spezi { implicitlyCreatedModules.formUnion(dependencyManager.implicitlyCreatedModules) + // we pass through the whole list of modules once to collect all @Provide values for module in dependencyManager.initializedModules { - // we pass through the whole list of modules once to collect all @Provide values - module.collectModuleValues(into: &storage) + Self.$moduleInitContext.withValue(module) { + module.collectModuleValues(into: &storage) + } } for module in dependencyManager.initializedModules { @@ -201,8 +223,6 @@ public class Spezi { // Newly loaded modules might have @Provide values that need to be updated in @Collect properties in existing modules. for existingModule in existingModules { - // TODO: do we really want to support that?, that gets super chaotic with unload modules??? - // TODO: create an issue to have e.g. update functionality (rework that whole thing?), remove that system altogether? existingModule.injectModuleValues(from: storage) } } @@ -217,6 +237,8 @@ public class Spezi { /// /// - Parameter module: The Module to unload. public func unloadModule(_ module: any Module) { + precondition(Self.moduleInitContext == nil, "Modules cannot be unloaded within the `configure()` method.") + guard module.isLoaded(in: self) else { return // module is not loaded } @@ -228,7 +250,7 @@ public class Spezi { implicitlyCreatedModules.remove(ModuleReference(module)) - // TODO: remove @Collect values that were previously provided by this Module + removeCollectValues(for: module) // re-injecting all dependencies ensures that the unloaded module is cleared from optional Dependencies from // pre-existing Modules. @@ -256,6 +278,29 @@ public class Spezi { } } + private func removeCollectValues(for module: any Module) { + let valueContainers = storage.collect(allOf: (any AnyCollectModuleValues).self) + + var changed = false + for var container in valueContainers { + let didChange = container.removeValues(from: module) + guard didChange else { + continue + } + + changed = true + container.store(into: &storage) + } + + guard changed else { + return + } + + for module in modules { + module.injectModuleValues(from: storage) + } + } + /// Initialize a Module. /// /// Call this method to initialize a Module, injecting necessary information into Spezi property wrappers. diff --git a/Tests/SpeziTests/DependenciesTests/DependencyTests.swift b/Tests/SpeziTests/DependenciesTests/DependencyTests.swift index 3e757a8d..c0d6f3ed 100644 --- a/Tests/SpeziTests/DependenciesTests/DependencyTests.swift +++ b/Tests/SpeziTests/DependenciesTests/DependencyTests.swift @@ -15,17 +15,23 @@ import XCTRuntimeAssertions private final class TestModule1: Module { @Dependency var testModule2 = TestModule2() @Dependency var testModule3: TestModule3 + + @Provide var num: Int = 1 } private final class TestModule2: Module { @Dependency var testModule4 = TestModule4() @Dependency var testModule5 = TestModule5() @Dependency var testModule3: TestModule3 + + @Provide var num: Int = 2 } private final class TestModule3: Module, DefaultInitializable { let state: Int + @Provide var num: Int = 3 + convenience init() { self.init(state: 0) } @@ -37,9 +43,13 @@ private final class TestModule3: Module, DefaultInitializable { private final class TestModule4: Module { @Dependency var testModule5 = TestModule5() + + @Provide var num: Int = 4 } -private final class TestModule5: Module {} +private final class TestModule5: Module { + @Provide var num: Int = 5 +} private final class TestModule6: Module {} @@ -62,6 +72,8 @@ private final class TestModuleItself: Module { private final class OptionalModuleDependency: Module { @Dependency var testModule3: TestModule3? + + @Collect var nums: [Int] } private final class OptionalDependencyWithRuntimeDefault: Module { @@ -122,9 +134,6 @@ final class DependencyTests: XCTestCase { XCTAssert(testModule1.testModule2.testModule3 === testModule3) XCTAssert(testModule1.testModule2.testModule4.testModule5 === testModule5) XCTAssert(optionalModuleDependency.testModule3 === testModule3) - - // TODO: test updated view modifiers - // TODO: test updated @Collect properties! } func testUnloadingDependencies() throws { @@ -134,15 +143,21 @@ final class DependencyTests: XCTestCase { let spezi = Spezi(standard: DefaultStandard(), modules: [optionalModule]) + // test loading and unloading of @Collect/@Provide property values + XCTAssertEqual(optionalModule.nums, []) spezi.loadModule(module3) + XCTAssertEqual(optionalModule.nums, [3]) + spezi.loadModule(module1) + XCTAssertEqual(optionalModule.nums, [3, 5, 4, 2, 1]) try XCTRuntimePrecondition { spezi.unloadModule(module3) // cannot unload module that other modules still depend on } spezi.unloadModule(module1) + XCTAssertEqual(optionalModule.nums, [3]) var modules = spezi.modules func getModule(_ module: M.Type = M.self) throws -> M { @@ -160,6 +175,7 @@ final class DependencyTests: XCTestCase { XCTAssert(optionalModuleLoaded.testModule3 === module3Loaded) spezi.unloadModule(module3) + XCTAssertEqual(optionalModule.nums, []) modules = spezi.modules