Skip to content

Commit

Permalink
Allow to load multiple modules of the same type (#110)
Browse files Browse the repository at this point in the history
# Allow to load multiple modules of the same type

## ♻️ Current situation & Problem
Currently, Spezi enforces that there is maximum one module of the same
type loaded at a time. By allowing the Module system to used much more
dynamically via #105, we found that certain types of Modules might exist
multiple times in the system (e.g., a Bluetooth device type modeled as a
Spezi Module might have two physical devices connected at the same
time).
This PR makes the necessary infrastructure changes to support loading
multiple modules of the same type. A check that the same module can only
be loaded once is still in place.
Restructuring the `@Dependency` to support multiple modules of the same
type is not trivial and will be addressed in a follow-up PR which is
tracked in #111.

## ⚙️ Release Notes 
* Allow to load multiple modules of the same type.


## 📚 Documentation
--


## ✅ Testing
Additional unit testing was added to verify behavior.

## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
Supereg authored Jul 23, 2024
1 parent afdf500 commit d87e3d8
Show file tree
Hide file tree
Showing 16 changed files with 345 additions and 133 deletions.
5 changes: 3 additions & 2 deletions Sources/Spezi/Dependencies/DependencyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,9 @@ public class DependencyManager: Sendable {
/// - optional: Flag indicating if it is a optional return.
/// - Returns: Returns the Module instance. Only optional, if `optional` is set to `true` and no Module was found.
func retrieve<M: Module>(module: M.Type = M.self, optional: Bool = false) -> M? {
guard let candidate = initializedModules.first(where: { type(of: $0) == M.self }) ?? existingModules.first(where: { type(of: $0) == M.self }),
let module = candidate as? M else {
guard let candidate = existingModules.first(where: { type(of: $0) == M.self })
?? initializedModules.first(where: { type(of: $0) == M.self }),
let module = candidate as? M else {
precondition(optional, "Could not located dependency of type \(M.self)!")
return nil
}
Expand Down
38 changes: 31 additions & 7 deletions Sources/Spezi/Dependencies/Property/DependencyCollection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,6 @@
public struct DependencyCollection: DependencyDeclaration {
let entries: [AnyDependencyContext]

var injectedDependencies: [any Module] {
entries.reduce(into: []) { result, dependencies in
result.append(contentsOf: dependencies.injectedDependencies)
}
}

init(_ entries: [AnyDependencyContext]) {
self.entries = entries
}
Expand All @@ -33,7 +27,7 @@ public struct DependencyCollection: DependencyDeclaration {
/// ### Usage
///
/// The `SomeCustomDependencyBuilder` enforces certain type constraints (e.g., `SomeTypeConstraint`, more specific than ``Module``) during aggregation of ``Module/Dependency``s (``Module``s) via a result builder.
/// The individual dependency expressions within the result builder conforming to `SomeTypeConstraint` are then transformed to a ``DependencyCollection`` via ``DependencyCollection/init(for:singleEntry:)``.
/// The individual dependency expressions within the result builder conforming to `SomeTypeConstraint` are then transformed to a ``DependencyCollection`` via ``DependencyCollection/init(for:singleEntry:)-6ihsh``.
///
/// ```swift
/// @resultBuilder
Expand All @@ -49,6 +43,30 @@ public struct DependencyCollection: DependencyDeclaration {
self.init(DependencyContext(for: type, defaultValue: singleEntry))
}

/// Creates a ``DependencyCollection`` from a closure resulting in a single generic type conforming to the Spezi ``Module``.
/// - Parameters:
/// - type: The generic type resulting from the passed closure, has to conform to ``Module``.
/// - singleEntry: Closure returning a dependency conforming to ``Module``, stored within the ``DependencyCollection``.
///
/// ### Usage
///
/// The `SomeCustomDependencyBuilder` enforces certain type constraints (e.g., `SomeTypeConstraint`, more specific than ``Module``) during aggregation of ``Module/Dependency``s (``Module``s) via a result builder.
/// The individual dependency expressions within the result builder conforming to `SomeTypeConstraint` are then transformed to a ``DependencyCollection`` via ``DependencyCollection/init(for:singleEntry:)-6nzui``.
///
/// ```swift
/// @resultBuilder
/// public enum SomeCustomDependencyBuilder: DependencyCollectionBuilder {
/// public static func buildExpression<T: SomeTypeConstraint>(_ expression: @escaping @autoclosure () -> T) -> DependencyCollection {
/// DependencyCollection(singleEntry: expression)
/// }
/// }
/// ```
///
/// See `_DependencyPropertyWrapper/init(using:)` for a continued example regarding the usage of the implemented result builder.
public init<Dependency: Module>(for type: Dependency.Type = Dependency.self, singleEntry: @escaping @autoclosure (() -> Dependency)) {
self.init(singleEntry: singleEntry)
}


func dependencyRelation(to module: DependencyReference) -> DependencyRelation {
let relations = entries.map { $0.dependencyRelation(to: module) }
Expand All @@ -75,6 +93,12 @@ public struct DependencyCollection: DependencyDeclaration {
}
}

func inject(spezi: Spezi) {
for entry in entries {
entry.inject(spezi: spezi)
}
}

func uninjectDependencies(notifying spezi: Spezi) {
for entry in entries {
entry.uninjectDependencies(notifying: spezi)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
/// static func buildExpression<M: Module>(_ expression: @escaping @autoclosure () -> M) -> DependencyCollection
/// ```
///
/// See ``DependencyCollection/init(for:singleEntry:)`` for an example conformance implementation of the ``DependencyCollectionBuilder``.
/// See ``DependencyCollection/init(for:singleEntry:)-6nzui`` for an example conformance implementation of the ``DependencyCollectionBuilder``.
public protocol DependencyCollectionBuilder {}


Expand Down
60 changes: 29 additions & 31 deletions Sources/Spezi/Dependencies/Property/DependencyContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,40 +17,34 @@ protocol AnyDependencyContext: DependencyDeclaration {


class DependencyContext<Dependency: Module>: AnyDependencyContext {
@MainActor
private enum StorageReference: Sendable {
case dependency(Dependency)
case weakDependency(WeaklyStoredModule<Dependency>)

nonisolated var value: Dependency? {
switch self {
case let .dependency(module):
return module
case let .weakDependency(reference):
return reference.module
}
}
}

let defaultValue: (() -> Dependency)?
private var injectedDependency: StorageReference?
private weak var spezi: Spezi?
private var injectedDependency: DynamicReference<Dependency>?


var isOptional: Bool {
defaultValue == nil
}

var injectedDependencies: [any Module] {
private var dependency: Dependency? {
guard let injectedDependency else {
return []
return nil
}

if let module = injectedDependency.element {
return module
}

guard let module = injectedDependency.value else {
self.injectedDependency = nil // clear the left over storage
return []
// Otherwise, we have a weakly injected module that was de-initialized.
// See, if there are multiple modules of the same type and inject the "next" one.
if let replacement = spezi?.retrieveDependencyReplacement(for: Dependency.self) {
self.injectedDependency = .weakElement(replacement) // update injected dependency
return replacement
}

return [module]
// clear the left over storage
self.injectedDependency = nil
return nil
}

init(for type: Dependency.Type = Dependency.self, defaultValue: (() -> Dependency)? = nil) {
Expand Down Expand Up @@ -80,14 +74,18 @@ class DependencyContext<Dependency: Module>: AnyDependencyContext {
}

if isOptional {
injectedDependency = .weakDependency(WeaklyStoredModule(dependency))
injectedDependency = .weakElement(dependency)
} else {
injectedDependency = .dependency(dependency)
injectedDependency = .element(dependency)
}
}

func inject(spezi: Spezi) {
self.spezi = spezi
}

func uninjectDependencies(notifying spezi: Spezi) {
let dependency = injectedDependency?.value
let dependency = injectedDependency?.element
injectedDependency = nil

if let dependency {
Expand All @@ -101,31 +99,31 @@ class DependencyContext<Dependency: Module>: AnyDependencyContext {

if let injectedDependency {
Task { @MainActor in
guard let dependency = injectedDependency.value else {
guard let dependency = injectedDependency.element else {
return
}
spezi.handleDependencyUninjection(of: dependency)
}
}
}

func retrieve<M>(dependency: M.Type) -> M {
guard let injectedDependency else {
func retrieve<M>(dependency dependencyType: M.Type) -> M {
guard let dependency else {
preconditionFailure(
"""
A `@Dependency` was accessed before the dependency was activated. \
Only access dependencies once the module has been configured and the Spezi initialization is complete.
"""
)
}
guard let dependency = injectedDependency.value as? M else {
guard let dependencyM = dependency as? M else {
preconditionFailure("A injected dependency of type \(type(of: injectedDependency)) didn't match the expected type \(M.self)!")
}
return dependency
return dependencyM
}

func retrieveOptional<M>(dependency: M.Type) -> M? {
guard let dependency = injectedDependency?.value as? M? else {
guard let dependency = self.dependency as? M? else {
preconditionFailure("A injected dependency of type \(type(of: injectedDependency)) didn't match the expected type \(M?.self)!")
}
return dependency
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,16 @@ enum DependencyRelation: Hashable {
///
/// This protocol allows to communicate dependency requirements of a ``Module`` to the ``DependencyManager``.
protocol DependencyDeclaration {
/// List of injected dependencies.
var injectedDependencies: [any Module] { get }

/// Request from the ``DependencyManager`` to collect all dependencies. Mark required by calling `DependencyManager/require(_:defaultValue:)`.
@MainActor
func collect(into dependencyManager: DependencyManager)
/// Inject the dependency instance from the ``DependencyManager``. Use `DependencyManager/retrieve(module:)`.
@MainActor
func inject(from dependencyManager: DependencyManager)

@MainActor
func inject(spezi: Spezi)

/// Remove all dependency injections.
@MainActor
func uninjectDependencies(notifying spezi: Spezi)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ public class _DependencyPropertyWrapper<Value> { // swiftlint:disable:this type_
extension _DependencyPropertyWrapper: SpeziPropertyWrapper {
func inject(spezi: Spezi) {
self.spezi = spezi
dependencies.inject(spezi: spezi)
}

func clear() {
Expand All @@ -82,11 +83,6 @@ extension _DependencyPropertyWrapper: SpeziPropertyWrapper {


extension _DependencyPropertyWrapper: DependencyDeclaration {
var injectedDependencies: [any Module] {
dependencies.injectedDependencies
}


func dependencyRelation(to module: DependencyReference) -> DependencyRelation {
dependencies.dependencyRelation(to: module)
}
Expand Down
4 changes: 1 addition & 3 deletions Sources/Spezi/Module/Module.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,10 @@
// SPDX-License-Identifier: MIT
//

import SpeziFoundation


// note: detailed documentation is provided as an article extension in the DocC bundle
/// A `Module` defines a software subsystem that can be configured as part of the ``SpeziAppDelegate/configuration``.
public protocol Module: AnyObject, KnowledgeSource<SpeziAnchor> {
public protocol Module: AnyObject {
/// Called on the initialization of the Spezi instance to perform a lightweight configuration of the module.
///
/// It is advised that longer setup tasks are done in an asynchronous task and started during the call of the configure method.
Expand Down
4 changes: 0 additions & 4 deletions Sources/Spezi/Spezi.docc/Module/Module.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,6 @@ SPDX-License-Identifier: MIT
-->

@Metadata {
@DocumentationExtension(mergeBehavior: append)
}

## Overview

A ``Module``'s initializer can be used to configure its behavior as a subsystem in Spezi-based software.
Expand Down
4 changes: 0 additions & 4 deletions Sources/Spezi/Spezi.docc/Standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,6 @@ SPDX-License-Identifier: MIT
-->

@Metadata {
@DocumentationExtension(mergeBehavior: append)
}

Modules can use the constraint mechanism to enforce a set of requirements to the ``Standard`` used in the Spezi-based software where the module is used.
This mechanism follows a two-step process detailed in the module documentation: ``Module``.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import SpeziFoundation


struct ImplicitlyCreatedModulesKey: DefaultProvidingKnowledgeSource {
typealias Value = Set<ModuleReference>
typealias Anchor = SpeziAnchor

static let defaultValue: Value = []
}
File renamed without changes.
16 changes: 16 additions & 0 deletions Sources/Spezi/Spezi/KnowledgeSources/SpeziStorage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import SpeziFoundation


/// A ``SharedRepository`` implementation that is anchored to ``SpeziAnchor``.
///
/// This represents the central ``Spezi/Spezi`` storage module.
@_documentation(visibility: internal)
public typealias SpeziStorage = ValueRepository<SpeziAnchor>
83 changes: 83 additions & 0 deletions Sources/Spezi/Spezi/KnowledgeSources/StoredModulesKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import OrderedCollections
import SpeziFoundation


protocol AnyStoredModules {
var anyModules: [any Module] { get }

func removeNilReferences<Repository: SharedRepository<SpeziAnchor>>(in storage: inout Repository)
}


struct StoredModulesKey<M: Module>: KnowledgeSource {
typealias Anchor = SpeziAnchor
typealias Value = Self

var modules: OrderedDictionary<ModuleReference, DynamicReference<M>>

var isEmpty: Bool {
modules.isEmpty
}

init(_ module: DynamicReference<M>, forKey key: ModuleReference) {
modules = [key: module]
}

func contains(_ key: ModuleReference) -> Bool {
modules[key] != nil
}

func retrieveFirstAvailable() -> M? {
for (_, value) in modules {
guard let element = value.element else {
continue
}
return element
}
return nil
}

@discardableResult
mutating func updateValue(_ module: DynamicReference<M>, forKey key: ModuleReference) -> DynamicReference<M>? {
modules.updateValue(module, forKey: key)
}

@discardableResult
mutating func removeValue(forKey key: ModuleReference) -> DynamicReference<M>? {
modules.removeValue(forKey: key)
}
}


extension StoredModulesKey: AnyStoredModules {
var anyModules: [any Module] {
modules.reduce(into: []) { partialResult, entry in
guard let element = entry.value.element else {
return
}
partialResult.append(element)
}
}

func removeNilReferences<Repository: SharedRepository<SpeziAnchor>>(in storage: inout Repository) {
guard modules.contains(where: { $0.value.element == nil }) else {
return // no weak references
}

var value = self

value.modules.removeAll { _, value in
value.element == nil
}

storage[Self.self] = value
}
}
Loading

0 comments on commit d87e3d8

Please sign in to comment.