Skip to content

Commit 011fc0f

Browse files
authored
Enable injecting optional properties that are only fulfilled when available (#168)
It can be really useful to share an `@Instantiable` across boundaries where only some of their properties exist
1 parent 116a878 commit 011fc0f

21 files changed

Lines changed: 1129 additions & 105 deletions

Documentation/Manual.md

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ Here we have an example of a `UserManager` type that is received as a `UserVendo
243243

244244
```swift
245245
public struct User {
246-
... // User information.
246+
... // User information.
247247
}
248248

249249
public protocol UserVendor {
@@ -274,8 +274,8 @@ public struct LoggedInView: View, Instantiable {
274274
public var body: some View {
275275
... // A logged in user experience
276276
}
277-
278-
@Forwarded private let userManager: UserManager
277+
278+
@Forwarded private let userManager: UserManager
279279

280280
@Instantiated private let profileViewBuilder: Instantiator<ProfileView>
281281
}
@@ -310,6 +310,62 @@ public struct EditProfileView: View, Instantiable {
310310
}
311311
```
312312

313+
#### Conditionally receiving dependencies
314+
315+
It is possible to receive an optional dependency only when that dependency has been `@Instantiated` or `@Forwarded` by an object higher up in the dependency tree with the `@Received(onlyIfAvailable: true)` macro. This functionality is particularly useful when `@Instantiable` types are created by multiple `@Instantiable` parents with different available dependencies.
316+
317+
Here’s an example of a feed view in a social app that optionally receives a `user` object:
318+
319+
```swift
320+
public struct User {
321+
... // User information.
322+
}
323+
324+
import SwiftUI
325+
326+
@Instantiable
327+
public struct LoggedOutView: View, Instantiable {
328+
public init(feedViewBuilder: Instantiator<FeedView>) {
329+
self.feedViewBuilder = feedViewBuilder
330+
}
331+
332+
public var body: some View {
333+
... // A logged out user experience that shows a feed
334+
}
335+
336+
@Instantiated private let feedViewBuilder: Instantiator<FeedView>
337+
}
338+
339+
@Instantiable
340+
public struct LoggedInView: View, Instantiable {
341+
public init(user: User, feedViewBuilder: Instantiator<FeedView>) {
342+
self.user = user
343+
self.feedViewBuilder = feedViewBuilder
344+
}
345+
346+
public var body: some View {
347+
... // A logged in user experience that shows a feed customized for this user
348+
}
349+
350+
@Forwarded private let user: User
351+
352+
@Instantiated private let feedViewBuilder: Instantiator<FeedView>
353+
}
354+
355+
@Instantiable
356+
public struct FeedView: View, Instantiable {
357+
public init(user: User?) {
358+
self.user = user
359+
}
360+
361+
public var body: some View {
362+
... // A feed experience that is customized when a user is present.
363+
}
364+
365+
@Received(onlyIfAvailable: true) private let user: User?
366+
}
367+
```
368+
313369
## Delayed instantiation
314370

315371
When you want to instantiate a dependency after `init(…)`, you need to declare an `Instantiator<Dependency>`-typed property as `@Instantiated` or `@Received`. Deferred instantiation is useful in situations where a dependency is expensive to create or only required under certain conditions (e.g., creating a detailed view for a selected item in a list).

Examples/ExampleCocoaPodsIntegration/safeditool.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
set -e
44

5-
VERSION='1.1.0'
5+
VERSION='1.2.2'
66
SAFEDI_LOCATION="$BUILD_DIR/SafeDITool-Release/$VERSION/safeditool"
77

88
# Download the tool from Github releases.

Plugins/Shared.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import PackagePlugin
2929
// As of Xcode 15.0, Xcode command plugins have no way to read the package manifest, therefore we must hardcode the version number.
3030
// It is okay for this number to be behind the most current release if the inputs and outputs to SafeDITool have not changed.
3131
// Unlike SPM plugins, Xcode plugins can not determine the current version number, so we must hardcode it.
32-
"1.2.3"
32+
"1.3.0"
3333
}
3434

3535
var safeDIOrigin: URL {

SafeDI.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Pod::Spec.new do |s|
22
s.name = 'SafeDI'
3-
s.version = '1.2.2'
3+
s.version = '1.3.0'
44
s.summary = 'Compile-time-safe dependency injection'
55
s.homepage = 'https://github.com/dfed/SafeDI'
66
s.license = 'MIT'

Sources/SafeDI/Decorators/Received.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,11 @@
2525
/// @Received private let dependency: DependencyType
2626
///
2727
/// Note that the access level of the dependency in the above example does not affect the dependency tree – a `private` dependency can still be `@Received` by `@Instantiable`-decorated types further down the dependency tree.
28+
///
29+
/// - Parameters:
30+
/// - onlyIfAvailable: Whether to allow the dependency to be received only when a parent has made it available.
2831
@attached(peer)
29-
public macro Received() = #externalMacro(module: "SafeDIMacros", type: "InjectableMacro")
32+
public macro Received(onlyIfAvailable: Bool = false) = #externalMacro(module: "SafeDIMacros", type: "InjectableMacro")
3033

3134
/// Marks a SafeDI dependency that is instantiated or forwarded by an `@Instantiable` instance higher up in the dependency tree whose name and/or type is being changed from the dependency‘s initial declaration.
3235
///
@@ -44,11 +47,13 @@ public macro Received() = #externalMacro(module: "SafeDIMacros", type: "Injectab
4447
/// - fulfilledByDependencyNamed: The name of the property belonging to an `@Instantiable` instance higher up in the dependency tree whose name and/or type is being changed from the dependency‘s initial declaration.
4548
/// - concreteType: The type of the property belonging to an `@Instantiable` instance higher up in the dependency tree whose name and/or type is being changed from the dependency‘s initial declaration.
4649
/// - erasedToConcreteExistential: Whether the concrete type is being erased to a concrete existential type – a type that encapsulates a value conforming to a protocol without revealing the underlying type. Set this parameter to `true` to encapsulate the renamed or retyped instance of `concreteType` in a no-label initializer of the property’s type (e.g. `AnyDependencyType(dependency)`).
50+
/// - onlyIfAvailable: Whether to allow the dependency to be received only when a parent has made it available.
4751
///
4852
/// - SeeAlso: [The Swift Programming Lanaguage’s explanation of existential types](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/opaquetypes/#Boxed-Protocol-Types)
4953
@attached(peer)
5054
public macro Received<T>(
5155
fulfilledByDependencyNamed: StaticString,
5256
ofType concreteType: T.Type,
53-
erasedToConcreteExistential: Bool = false
57+
erasedToConcreteExistential: Bool = false,
58+
onlyIfAvailable: Bool = false
5459
) = #externalMacro(module: "SafeDIMacros", type: "InjectableMacro")

Sources/SafeDICore/Errors/FixableInjectableError.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,14 @@ import SwiftDiagnostics
2222

2323
public enum FixableInjectableError: DiagnosticError {
2424
case unexpectedMutable
25+
case onlyIfAvailableNotOptionalSpelledWithQuestionMark
2526

2627
public var description: String {
2728
switch self {
2829
case .unexpectedMutable:
2930
"Dependency can not be mutable unless it is decorated with a property wrapper. Mutations to a dependency are not propagated through the dependency tree."
31+
case .onlyIfAvailableNotOptionalSpelledWithQuestionMark:
32+
"The type of a dependency decorated with `onlyIfAvailable: true` must be marked as optional utilizing the `?` spelling"
3033
}
3134
}
3235

@@ -44,7 +47,8 @@ public enum FixableInjectableError: DiagnosticError {
4447
init(error: FixableInjectableError) {
4548
diagnosticID = MessageID(domain: "\(Self.self)", id: error.description)
4649
severity = switch error {
47-
case .unexpectedMutable:
50+
case .unexpectedMutable,
51+
.onlyIfAvailableNotOptionalSpelledWithQuestionMark:
4852
.error
4953
}
5054
message = error.description
@@ -62,6 +66,8 @@ public enum FixableInjectableError: DiagnosticError {
6266
message = switch error {
6367
case .unexpectedMutable:
6468
"Replace `var` with `let`"
69+
case .onlyIfAvailableNotOptionalSpelledWithQuestionMark:
70+
"Mark the type as optional using `?`"
6571
}
6672
fixItID = MessageID(domain: "\(Self.self)", id: error.description)
6773
}

Sources/SafeDICore/Extensions/AttributeSyntaxExtensions.swift

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,19 @@ extension AttributeSyntax {
123123
return erasedToConcreteExistentialLabeledExpression.expression
124124
}
125125

126+
public var onlyIfAvailable: ExprSyntax? {
127+
guard let arguments,
128+
let labeledExpressionList = LabeledExprListSyntax(arguments),
129+
let erasedToConcreteExistentialLabeledExpression = labeledExpressionList.reversed().first(where: {
130+
$0.label?.text == "onlyIfAvailable"
131+
})
132+
else {
133+
return nil
134+
}
135+
136+
return erasedToConcreteExistentialLabeledExpression.expression
137+
}
138+
126139
public var fulfillingTypeDescription: TypeDescription? {
127140
if let expression = fulfilledByType,
128141
let stringLiteral = StringLiteralExprSyntax(expression),
@@ -134,7 +147,7 @@ extension AttributeSyntax {
134147
}
135148
}
136149

137-
public var erasedToConcreteExistentialType: Bool {
150+
public var erasedToConcreteExistentialValue: Bool {
138151
guard let erasedToConcreteExistential,
139152
let erasedToConcreteExistentialType = BooleanLiteralExprSyntax(erasedToConcreteExistential)
140153
else {
@@ -143,4 +156,14 @@ extension AttributeSyntax {
143156
}
144157
return erasedToConcreteExistentialType.literal.text == "true"
145158
}
159+
160+
public var onlyIfAvailableValue: Bool {
161+
guard let onlyIfAvailable,
162+
let onlyIfAvailable = BooleanLiteralExprSyntax(onlyIfAvailable)
163+
else {
164+
// Default value for the `onlyIfAvailable` parameter is `false`.
165+
return false
166+
}
167+
return onlyIfAvailable.literal.text == "true"
168+
}
146169
}

Sources/SafeDICore/Generators/DependencyTreeGenerator.swift

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,26 @@ public actor DependencyTreeGenerator {
9999
case let .unfulfillableProperties(unfulfillableProperties):
100100
"""
101101
\(unfulfillableProperties.map {
102-
"""
103-
@\(Dependency.Source.receivedRawValue) property `\($0.property.asSource)` is not @\(Dependency.Source.instantiatedRawValue) or @\(Dependency.Source.forwardedRawValue) in chain:\n\t\(([$0.instantiable.concreteInstantiable] + $0.parentStack)
104-
.reversed()
105-
.map(\.asSource)
106-
.joined(separator: " -> "))\($0.suggestedAlternatives.isEmpty ? "" : "\n\nDid you mean one of the following available properties?\n\($0.suggestedAlternatives.map { "\t`\($0.asSource)`" }.joined(separator: "\n"))")
107-
"""
102+
if $0.property.typeDescription.isOptional,
103+
let nonOptionalAlternative = $0.suggestedAlternatives.first(where: { [unfulfilledProperty = $0.property] alternative in
104+
alternative.label == unfulfilledProperty.label
105+
&& alternative.typeDescription == unfulfilledProperty.typeDescription.unwrapped
106+
})
107+
{
108+
"""
109+
@\(Dependency.Source.receivedRawValue) property `\($0.property.asSource)` is not @\(Dependency.Source.instantiatedRawValue) or @\(Dependency.Source.forwardedRawValue) in chain:\n\t\(([$0.instantiable.concreteInstantiable] + $0.parentStack)
110+
.reversed()
111+
.map(\.asSource)
112+
.joined(separator: " -> "))\($0.suggestedAlternatives.isEmpty ? "" : "\n\nThe non-optional `\(nonOptionalAlternative.asSource)` is available in chain. Did you mean to decorate this property with `@\(Dependency.Source.receivedRawValue)(onlyIfAvailable: true)`?")
113+
"""
114+
} else {
115+
"""
116+
@\(Dependency.Source.receivedRawValue) property `\($0.property.asSource)` is not @\(Dependency.Source.instantiatedRawValue) or @\(Dependency.Source.forwardedRawValue) in chain:\n\t\(([$0.instantiable.concreteInstantiable] + $0.parentStack)
117+
.reversed()
118+
.map(\.asSource)
119+
.joined(separator: " -> "))\($0.suggestedAlternatives.isEmpty ? "" : "\n\nDid you mean one of the following available properties?\n\($0.suggestedAlternatives.map { "\t`\($0.asSource)`" }.joined(separator: "\n"))")
120+
"""
121+
}
108122
}
109123
.sorted()
110124
.joined(separator: "\n\n"))
@@ -163,6 +177,7 @@ public actor DependencyTreeGenerator {
163177
try typeDescriptionToScopeMap[$0]?.createScopeGenerator(
164178
for: nil,
165179
propertyStack: [],
180+
receivableProperties: [],
166181
erasedToConcreteExistential: false
167182
)
168183
}
@@ -279,11 +294,12 @@ public actor DependencyTreeGenerator {
279294
erasedToConcreteExistential: erasedToConcreteExistential
280295
))
281296
}
282-
case let .aliased(fulfillingProperty, erasedToConcreteExistential):
297+
case let .aliased(fulfillingProperty, erasedToConcreteExistential, onlyIfAvailable):
283298
scope.propertiesToGenerate.append(.aliased(
284299
dependency.property,
285300
fulfilledBy: fulfillingProperty,
286-
erasedToConcreteExistential: erasedToConcreteExistential
301+
erasedToConcreteExistential: erasedToConcreteExistential,
302+
onlyIfAvailable: onlyIfAvailable
287303
))
288304
case .forwarded, .received:
289305
continue
@@ -302,30 +318,12 @@ public actor DependencyTreeGenerator {
302318
propertyStack: OrderedSet<Property>,
303319
root: TypeDescription
304320
) throws {
305-
let createdProperties = Set(
306-
scope
307-
.instantiable
308-
.dependencies
309-
.filter {
310-
switch $0.source {
311-
case .instantiated, .forwarded:
312-
// The source is being injected into the dependency tree.
313-
true
314-
case .aliased:
315-
// This property is being re-injected into the dependency tree under a new alias.
316-
true
317-
case .received:
318-
false
319-
}
320-
}
321-
.map(\.property)
322-
)
323321
if let property {
324322
func validateNoCycleInReceivedProperties(
325323
scope: Scope,
326324
receivedPropertyStack: OrderedSet<Property>
327325
) throws {
328-
for childProperty in scope.receivedProperties {
326+
for childProperty in scope.requiredReceivedProperties {
329327
guard childProperty != property else {
330328
throw DependencyTreeGeneratorError.receivedConstantCycleDetected(
331329
instantiated: property,
@@ -352,9 +350,9 @@ public actor DependencyTreeGenerator {
352350
)
353351
}
354352

355-
for receivedProperty in scope.receivedProperties {
353+
for receivedProperty in scope.requiredReceivedProperties {
356354
let parentContainsProperty = receivableProperties.contains(receivedProperty)
357-
let propertyIsCreatedAtThisScope = createdProperties.contains(receivedProperty)
355+
let propertyIsCreatedAtThisScope = scope.createdProperties.contains(receivedProperty)
358356
if !parentContainsProperty, !propertyIsCreatedAtThisScope {
359357
if property != nil {
360358
// This property is in a dependency tree and is unfulfillable. Record the problem.
@@ -484,9 +482,21 @@ extension TypeDescription {
484482
switch self {
485483
case let .nested(name, _, generics):
486484
.simple(name: name, generics: generics)
487-
case let .any(typeDescription), let .implicitlyUnwrappedOptional(typeDescription), let .optional(typeDescription), let .some(typeDescription):
485+
case let .any(typeDescription),
486+
let .implicitlyUnwrappedOptional(typeDescription),
487+
let .optional(typeDescription),
488+
let .some(typeDescription):
488489
typeDescription.leastQualifiedTypeDescription
489-
case .array, .attributed, .closure, .composition, .dictionary, .metatype, .simple, .tuple, .unknown, .void:
490+
case .array,
491+
.attributed,
492+
.closure,
493+
.composition,
494+
.dictionary,
495+
.metatype,
496+
.simple,
497+
.tuple,
498+
.unknown,
499+
.void:
490500
self
491501
}
492502
}

0 commit comments

Comments
 (0)