Skip to content

Commit

Permalink
Customize encoding and decoding for UserStorageKeys
Browse files Browse the repository at this point in the history
  • Loading branch information
Supereg committed Oct 30, 2024
1 parent 5d5fee9 commit c45d27c
Show file tree
Hide file tree
Showing 9 changed files with 202 additions and 7 deletions.
3 changes: 2 additions & 1 deletion Sources/SpeziScheduler/SpeziScheduler.docc/SpeziScheduler.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,15 @@ class MySchedulerModule: Module {
- ``Task/Category-swift.struct``
- ``Event``
- ``Outcome``
- ``Property()``
- ``Property(coding:)``
- ``AllowedCompletionPolicy``

### Notifications

- ``SchedulerNotifications``
- ``SchedulerNotificationsConstraint``
- ``NotificationTime``
- ``NotificationThread``

### Date Extensions

Expand Down
2 changes: 1 addition & 1 deletion Sources/SpeziScheduler/Task/Outcome.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import SwiftData
///
/// An outcome supports storing additional metadata information (e.g., the measurement value or medication).
///
/// - Tip: Refer to the ``Property()`` macro on how to create new data types that can be stored alongside an outcome.
/// - Tip: Refer to the ``Property(coding:)`` macro on how to create new data types that can be stored alongside an outcome.
///
/// You provide the additional outcome values upon completion of an event (see ``Event/complete(with:)``.
/// Below is a short code example that sets a custom `measurement` property to the weight measurement that was received
Expand Down
2 changes: 1 addition & 1 deletion Sources/SpeziScheduler/Task/Task.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import SwiftData
///
/// Tasks support storing additional metadata information.
///
/// - Tip: Refer to the ``Property()`` macro on how to create new data types that can be stored alongside a task.
/// - Tip: Refer to the ``Property(coding:)`` macro on how to create new data types that can be stored alongside a task.
///
/// You can set additional information by supplying an additional closure that modifies the ``Context`` when creating or updating a task.
/// The code example below assume that the `measurementType` exists to store the type of measurement the user should record to complete the task.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
// SPDX-License-Identifier: MIT
//

import SpeziFoundation


/// Add additional properties to an `Outcome` or `Task`.
///
Expand Down Expand Up @@ -34,6 +36,14 @@
/// @Property var measurement: WeightMeasurement?
/// }
/// ```
///
///
/// ## Topics
///
/// ### Customize Encoding and Decoding
/// - ``UserStorageCoding``
@attached(accessor, names: named(get), named(set))
@attached(peer, names: prefixed(__Key_))
public macro Property() = #externalMacro(module: "SpeziSchedulerMacros", type: "UserStorageEntryMacro")
public macro Property<Encoder: TopLevelEncoder, Decoder: TopLevelDecoder>(
coding: UserStorageCoding<Encoder, Decoder> = .propertyList
) = #externalMacro(module: "SpeziSchedulerMacros", type: "UserStorageEntryMacro")
6 changes: 6 additions & 0 deletions Sources/SpeziScheduler/UserInfo/UserInfoKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,21 @@
// SPDX-License-Identifier: MIT
//

import Foundation
import SpeziFoundation


/// A typed-key for entries in the user info store of scheduler components.
///
/// Refer to the documentation of ``TaskStorageKey`` and ``OutcomeStorageKey`` on how to create userInfo entries.
public protocol _UserInfoKey<Anchor>: KnowledgeSource where Value: Codable { // swiftlint:disable:this type_name
associatedtype Encoder: TopLevelEncoder, Sendable where Encoder.Output == Data
associatedtype Decoder: TopLevelDecoder, Sendable where Decoder.Input == Data

/// The persistent identifier of the user info key.
static var identifier: String { get }
/// The encoder and decoder used with the user storage.
static var coding: UserStorageCoding<Encoder, Decoder> { get }
}


Expand Down
5 changes: 3 additions & 2 deletions Sources/SpeziScheduler/UserInfo/UserInfoStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ extension UserInfoStorage {
}

do {
let decoder = PropertyListDecoder()
let decoder = source.coding.decoder
let value = try decoder.decode(SingleValueWrapper<Source.Value>.self, from: data)

cache.repository.set(source, value: value.value)
Expand All @@ -69,7 +69,8 @@ extension UserInfoStorage {

if let newValue {
do {
userInfo[source.identifier] = try PropertyListEncoder().encode(SingleValueWrapper(value: newValue))
let encoder = source.coding.encoder
userInfo[source.identifier] = try encoder.encode(SingleValueWrapper(value: newValue))
} catch {
logger.error("Failed to encode userInfo value \(String(describing: newValue)) for \(source): \(error)")
}
Expand Down
64 changes: 64 additions & 0 deletions Sources/SpeziScheduler/UserInfo/UserStorageCoding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// 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 Foundation
import SpeziFoundation


/// Defining the coding strategy for a user storage property.
///
/// This type defines the encoders and decoders for a user storage ``Property(coding:)``.
///
/// Below is a code example that specifies ``json`` encoding for the `measurementType` property.
///
/// ```swift
/// extension Task.Context {
/// @Property(coding: .json)
/// var measurementType: MeasurementType?
/// }
/// ```
///
/// ## Topics
///
/// ### Builtin Strategies
/// - ``json``
/// - ``propertyList``
///
/// ### Custom Strategies
/// - ``custom(encoder:decoder:)``
public struct UserStorageCoding<Encoder: TopLevelEncoder & Sendable, Decoder: TopLevelDecoder & Sendable>: Sendable
where Encoder.Output == Data, Decoder.Input == Data {
let encoder: Encoder
let decoder: Decoder

init(encoder: Encoder, decoder: Decoder) {
self.encoder = encoder
self.decoder = decoder
}

/// Create a custom user storage coding strategy.
/// - Parameters:
/// - encoder: The encoder.
/// - decoder: The decoder.
/// - Returns: The coding strategy.
public static func custom(encoder: Encoder, decoder: Decoder) -> UserStorageCoding<Encoder, Decoder> {
.init(encoder: encoder, decoder: decoder)
}
}


extension UserStorageCoding where Encoder == JSONEncoder, Decoder == JSONDecoder {
/// JSON encoder and decoder.
public static let json = UserStorageCoding(encoder: JSONEncoder(), decoder: JSONDecoder())
}


extension UserStorageCoding where Encoder == PropertyListEncoder, Decoder == PropertyListDecoder {
/// PropertyList encoder and decoder.
public static let propertyList = UserStorageCoding(encoder: PropertyListEncoder(), decoder: PropertyListDecoder())
}
19 changes: 18 additions & 1 deletion Sources/SpeziSchedulerMacros/UserStorageEntryMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ extension UserStorageEntryMacro: AccessorMacro {


extension UserStorageEntryMacro: PeerMacro {
public static func expansion( // swiftlint:disable:this function_body_length
public static func expansion( // swiftlint:disable:this function_body_length cyclomatic_complexity
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
Expand Down Expand Up @@ -96,6 +96,22 @@ extension UserStorageEntryMacro: PeerMacro {
)
}

let codingExpression: ExprSyntax
if case let .argumentList(argumentList) = node.arguments,
let coding = argumentList.first(where: { $0.label?.text == "coding" }) {
if var memberAccess = coding.expression.as(MemberAccessExprSyntax.self) {
memberAccess.base = ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier("UserStorageCoding")))
codingExpression = ExprSyntax(memberAccess)
} else {
codingExpression = coding.expression
}
} else {
codingExpression = ExprSyntax(MemberAccessExprSyntax(
base: ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier("UserStorageCoding"))),
declName: DeclReferenceExprSyntax(baseName: .identifier("propertyList"))
))
}

let keyProtocol: TokenSyntax

if let identifier = extensionDecl.extendedType.as(IdentifierTypeSyntax.self)?.name.identifier,
Expand Down Expand Up @@ -147,6 +163,7 @@ extension UserStorageEntryMacro: PeerMacro {

"""
static let identifier: String = "\(identifier)"
static let coding = \(codingExpression)
"""
}

Expand Down
96 changes: 96 additions & 0 deletions Tests/SpeziSchedulerMacrosTest/UserStorageEntryMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ final class UserStorageEntryMacroTests: XCTestCase { // swiftlint:disable:this t
typealias Value = String
static let identifier: String = "testMacro"
static let coding = UserStorageCoding.propertyList
}
}
""",
Expand Down Expand Up @@ -72,6 +73,7 @@ final class UserStorageEntryMacroTests: XCTestCase { // swiftlint:disable:this t
typealias Value = String
static let identifier: String = "testMacro"
static let coding = UserStorageCoding.propertyList
}
}
""",
Expand Down Expand Up @@ -102,6 +104,7 @@ final class UserStorageEntryMacroTests: XCTestCase { // swiftlint:disable:this t
typealias Value = String
static let identifier: String = "testMacro"
static let coding = UserStorageCoding.propertyList
}
}
""",
Expand Down Expand Up @@ -132,6 +135,7 @@ final class UserStorageEntryMacroTests: XCTestCase { // swiftlint:disable:this t
typealias Value = String
static let identifier: String = "testMacro"
static let coding = UserStorageCoding.propertyList
}
}
""",
Expand Down Expand Up @@ -162,6 +166,7 @@ final class UserStorageEntryMacroTests: XCTestCase { // swiftlint:disable:this t
typealias Value = String
static let identifier: String = "testMacro"
static let coding = UserStorageCoding.propertyList
}
}
""",
Expand Down Expand Up @@ -297,6 +302,97 @@ final class UserStorageEntryMacroTests: XCTestCase { // swiftlint:disable:this t
macros: testMacros
)
}

func testJsonCoding() { // swiftlint:disable:this function_body_length
assertMacroExpansion(
"""
extension Task.Context {
@Property(coding: .json) var testMacro: String?
}
""",
expandedSource:
"""
extension Task.Context {
var testMacro: String? {
get {
self[__Key_testMacro.self]
}
set {
self[__Key_testMacro.self] = newValue
}
}
private struct __Key_testMacro: TaskStorageKey {
typealias Value = String
static let identifier: String = "testMacro"
static let coding = UserStorageCoding.json
}
}
""",
macros: testMacros
)

assertMacroExpansion(
"""
extension Task.Context {
@Property(coding: UserInfoCoding.json) var testMacro: String?
}
""",
expandedSource:
"""
extension Task.Context {
var testMacro: String? {
get {
self[__Key_testMacro.self]
}
set {
self[__Key_testMacro.self] = newValue
}
}
private struct __Key_testMacro: TaskStorageKey {
typealias Value = String
static let identifier: String = "testMacro"
static let coding = UserStorageCoding.json
}
}
""",
macros: testMacros
)
}

func testCustomCoding() {
assertMacroExpansion(
"""
extension Task.Context {
@Property(coding: UserInfoCoding(encoder: TestEncoder(), decoder: TestDecoder())) var testMacro: String?
}
""",
expandedSource:
"""
extension Task.Context {
var testMacro: String? {
get {
self[__Key_testMacro.self]
}
set {
self[__Key_testMacro.self] = newValue
}
}
private struct __Key_testMacro: TaskStorageKey {
typealias Value = String
static let identifier: String = "testMacro"
static let coding = UserInfoCoding(encoder: TestEncoder(), decoder: TestDecoder())
}
}
""",
macros: testMacros
)
}
}

#endif

0 comments on commit c45d27c

Please sign in to comment.