diff --git a/README.md b/README.md index bb0a032..8ccaa1e 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ - One-line `@Codable`, `@Encodable`, and `@Decodable` synthesis - Default-aware decoding, nested keys, and graceful fallbacks - Raw-string transcoding, lossy collections, and transformer pipelines +- Derived properties computed from already-decoded fields via `@DerivedKey` - Explicit lifecycle hooks with deterministic generated code CodableKit is a Swift macro package built for the JSON you actually receive: nested payloads, string-encoded objects, partially invalid arrays, and schemas that drift over time. It keeps configuration close to each property, surfaces mistakes with compile-time diagnostics, and avoids runtime reflection or hidden magic. @@ -58,7 +59,8 @@ That single model gets generated `CodingKeys`, `init(from:)`, and `encode(to:)` | Raw-string transcoding | `@CodableKey(options: .safeTranscodeRawString)` | Decode string-encoded JSON into strongly typed models | | Lossy collections | `@CodableKey(options: .lossy)` | Drop invalid array, set, or dictionary entries during decode | | Explicit hooks | `@CodableHook(.didDecode)` | Run validation, normalization, or derived-value logic at clear lifecycle stages | -| Transformer pipelines | `@CodableKey(transformer: MyTransformer())` | Compose reusable decode and encode transformations | +| Transformer pipelines | `@CodableKey(transformer: MyTransformer())` | Compose reusable decode and encode transformations; `DictionaryLookupTransformer` pulls a value out of a decoded dictionary, `liftOptional()` lifts a pipeline to optional input/output, and `onFailure(_:)` observes pipeline errors for logging | +| Derived properties | `@DerivedKey(from: "slots", transformer: MyTransformer())` | Compute typed properties from an already-decoded sibling at the end of `init(from:)` — no coding key, never encoded | ## Targets @@ -116,6 +118,30 @@ struct Feed { } ``` +### Derived properties + +```swift +@Codable +struct UserConfigInfo { + var userConfigValue: [String: String]? + + @DerivedKey( + from: "userConfigValue", + transformer: DictionaryLookupTransformer(key: "avatar_frame") + .chained(RawStringDecodingTransformer().liftOptional()) + ) + private(set) var avatarFrame: AvatarFrame? +} +``` + +A derived property has no `CodingKeys` case and is never encoded. Its value is computed at the +end of `init(from:)` by feeding the already-decoded `from:` property through the transformer +pipeline, before any `@CodableHook(.didDecode)` hook runs. The `from:` source must itself be a +decoded stored property of the same type — `.ignored` properties are rejected at compile time. +Optional or defaulted derived properties fall back to `nil`/the default when the pipeline fails; +non-optional ones without a default rethrow from `init(from:)`. Add `.onFailure(_:)` to the +pipeline to log failures that the fallback path would otherwise swallow. + ### Dynamic JSON values ```swift @@ -158,7 +184,7 @@ swift build -v swift test -v ``` -Tests cover macro expansion, diagnostics, hooks, inheritance, lossy coding, nested keys, and transformer behavior. +Tests cover macro expansion, diagnostics, hooks, inheritance, lossy coding, nested keys, derived properties, and transformer behavior. ## Docs diff --git a/Sources/CodableKit/CodableKit.swift b/Sources/CodableKit/CodableKit.swift index ef3a575..18793c2 100644 --- a/Sources/CodableKit/CodableKit.swift +++ b/Sources/CodableKit/CodableKit.swift @@ -590,6 +590,62 @@ public macro CodableKey( transformer: any BidirectionalCodingTransformer = NothingToTransform() ) = #externalMacro(module: "CodableKitMacros", type: "CodableKeyMacro") +/// A macro that marks a stored property as *derived*: its value is not decoded from its own +/// coding key, but computed at the end of the generated `init(from:)` by a transformer pipeline +/// whose input is an already-decoded sibling property. +/// +/// ## Overview +/// +/// Use `@DerivedKey` when a typed property should be materialized from another property that is +/// decoded normally — for example, slot-bag dictionaries with embedded JSON strings: +/// +/// ```swift +/// @Codable +/// public struct UserCommonConfigInfo: Sendable, Hashable { +/// public var userConfigValue: [String: UserConfigValue]? +/// +/// @DerivedKey(from: "userConfigValue", transformer: AvatarFrameSlotTransformer()) +/// public private(set) var avatarFrame: AvatarFrame? +/// } +/// ``` +/// +/// ## Semantics +/// +/// - **No coding key:** derived properties get no `CodingKeys` case and are never read from the +/// decoder. +/// - **Never encoded:** `encode(to:)` skips derived properties entirely. +/// - **Decode ordering:** assignments are emitted at the end of `init(from:)` — after all coded +/// properties are assigned and before any `didDecode` hook runs, so hooks observe derived +/// values. Multiple derived properties are assigned in declaration order. +/// - **Failure policy:** if the property is optional or has a default initializer value, a +/// pipeline failure falls back to `nil`/the default. A non-optional property without a default +/// propagates the error from `init(from:)`. Because the optional/default fallback path swallows +/// pipeline errors via `try?`, attach `.onFailure(_:)` to the transformer pipeline when you +/// need to observe or log those failures. +/// - **Dependencies:** the `from:` property must be a stored property of the same type that is +/// actually decoded — properties marked `.ignored` and inherited superclass properties are not +/// supported as `from:` sources. Deriving from another derived property is not supported and +/// is diagnosed. +/// - **Decode-only:** `@DerivedKey` works under `@Codable` and `@Decodable`; it cannot be used in +/// an `@Encodable`-only type, and it cannot be combined with `@CodableKey`, `@DecodableKey`, or +/// `@EncodableKey` on the same property. +/// - **Hashable/Equatable:** no special handling — derived stored properties participate in +/// whatever synthesis the type already gets. +/// +/// The transformer's `Input` must match the source property's type and its `Output` must match +/// the derived property's type; mismatches surface as compile errors in the generated code. +/// +/// - Parameters: +/// - property: The name of the sibling stored property whose decoded value feeds the pipeline. +/// Must be a string literal. +/// - transformer: The transformer pipeline applied to the source value. Only the forward +/// direction is used; encoding never needs the reverse direction. +@attached(peer) +public macro DerivedKey( + from property: String, + transformer: any CodingTransformer +) = #externalMacro(module: "CodableKitMacros", type: "DerivedKeyMacro") + // MARK: - DecodeKey / EncodeKey @attached(peer, names: arbitrary) diff --git a/Sources/CodableKit/CodingTransformerRuntime.swift b/Sources/CodableKit/CodingTransformerRuntime.swift index f618e57..58c5791 100644 --- a/Sources/CodableKit/CodingTransformerRuntime.swift +++ b/Sources/CodableKit/CodingTransformerRuntime.swift @@ -75,6 +75,21 @@ public func __ckEncodeTransformedIfPresent( } } +// MARK: - Derived property support + +/// Runtime helper for `@DerivedKey`: feeds an already-decoded sibling value through a +/// one-directional transformer pipeline and returns its output. +/// +/// Unlike the key-based helpers above, the input is not read from a decoding container — it is +/// the value of a sibling property that has already been assigned earlier in `init(from:)`. +@inline(__always) +public func __ckDecodeDerived( + transformer: T, + from input: T.Input +) throws -> T.Output where T: CodingTransformer { + try transformer.transform(.success(input)).get() +} + // MARK: - One-way transformer support @inline(__always) diff --git a/Sources/CodableKit/Transformers/BuiltInTransformers.swift b/Sources/CodableKit/Transformers/BuiltInTransformers.swift index 1bba75f..01211b4 100644 --- a/Sources/CodableKit/Transformers/BuiltInTransformers.swift +++ b/Sources/CodableKit/Transformers/BuiltInTransformers.swift @@ -201,3 +201,22 @@ public struct KeyPathTransformer: CodingTransformer { input.map { $0[keyPath: keyPath] } } } + +/// Looks up a value by key in an optional dictionary. +/// +/// A nil dictionary or an absent key produces `nil` rather than an error, so +/// chains can treat missing slots as missing values and continue. +public struct DictionaryLookupTransformer: CodingTransformer { + public typealias Input = [Key: Value]? + public typealias Output = Value? + + public let key: Key + + public init(key: Key) { + self.key = key + } + + public func transform(_ input: Result<[Key: Value]?, any Error>) -> Result { + input.map { $0?[key] } + } +} diff --git a/Sources/CodableKit/Transformers/CodingTransformer.swift b/Sources/CodableKit/Transformers/CodingTransformer.swift index 8a3b4b0..7f3f46b 100644 --- a/Sources/CodableKit/Transformers/CodingTransformer.swift +++ b/Sources/CodableKit/Transformers/CodingTransformer.swift @@ -47,6 +47,27 @@ extension CodingTransformer { public func optional() -> some CodingTransformer { self.chained(Optional()) } + + /// Lifts this transformer to operate on optional values. + /// + /// A nil input produces a nil output without invoking the base transformer. + /// An upstream failure is forwarded into the base transformer, so + /// failure-recovering transformers (such as `DefaultOnFailureTransformer`) + /// recover when lifted, producing `.some(recovered)`; non-recovering + /// transformers propagate the failure unchanged. + public func liftOptional() -> some CodingTransformer { + OptionalLifted(transformer: self) + } + + /// Taps failures for observability without altering the result. + /// + /// The handler is invoked whenever the transformed result is a failure; the + /// result is passed through unchanged in all cases. + public func onFailure( + _ handler: @escaping (any Error) -> Void + ) -> some CodingTransformer { + OnFailure(transformer: self, handler: handler) + } } public protocol BidirectionalCodingTransformer: CodingTransformer { @@ -63,6 +84,29 @@ extension BidirectionalCodingTransformer { public var reversed: some BidirectionalCodingTransformer { Reversed(transformer: self) } + + /// Lifts this transformer to operate on optional values in both directions. + /// + /// Forward and reverse share the same semantics: a nil input produces a nil + /// output without invoking the base transformer, and an upstream failure is + /// forwarded into the base transformer, so failure-recovering transformers + /// (such as `DefaultOnFailureTransformer`) recover when lifted, producing + /// `.some(recovered)`; non-recovering transformers propagate the failure + /// unchanged. + public func liftOptional() -> some BidirectionalCodingTransformer { + OptionalLifted(transformer: self) + } + + /// Taps failures for observability without altering the result. + /// + /// The handler is invoked whenever the transformed result is a failure in + /// either direction — both `transform(_:)` and `reverseTransform(_:)` report + /// their failures — and the result is passed through unchanged in all cases. + public func onFailure( + _ handler: @escaping (any Error) -> Void + ) -> some BidirectionalCodingTransformer { + OnFailure(transformer: self, handler: handler) + } } extension BidirectionalCodingTransformer where Input == Output { diff --git a/Sources/CodableKit/Transformers/CodingTransformerComposition.swift b/Sources/CodableKit/Transformers/CodingTransformerComposition.swift index 1e17e4e..3550dae 100644 --- a/Sources/CodableKit/Transformers/CodingTransformerComposition.swift +++ b/Sources/CodableKit/Transformers/CodingTransformerComposition.swift @@ -162,3 +162,92 @@ struct Optional: CodingTransformer { } } } + +/// Lifts a transformer over optional values. +/// +/// A nil input passes through as a nil output; a non-nil input runs through +/// the base transformer and its output is wrapped. Failures are forwarded to +/// the base transformer so failure-recovering transformers keep working when +/// lifted. +struct OptionalLifted: CodingTransformer +where + T: CodingTransformer +{ + typealias Input = T.Input? + typealias Output = T.Output? + + let transformer: T + + init(transformer: T) { + self.transformer = transformer + } + + func transform(_ input: Result) -> Result { + switch input { + case .success(.some(let value)): + transformer.transform(.success(value)).map { $0 as T.Output? } + case .success(.none): + .success(nil) + case .failure(let error): + transformer.transform(.failure(error)).map { $0 as T.Output? } + } + } +} + +extension OptionalLifted: BidirectionalCodingTransformer +where + T: BidirectionalCodingTransformer +{ + func reverseTransform(_ input: Result) -> Result { + switch input { + case .success(.some(let value)): + transformer.reverseTransform(.success(value)).map { $0 as T.Input? } + case .success(.none): + .success(nil) + case .failure(let error): + transformer.reverseTransform(.failure(error)).map { $0 as T.Input? } + } + } +} + +/// Passes results through unchanged while reporting failures to a handler. +/// +/// The handler is invoked with the error whenever the transformed result is a +/// failure; it cannot alter the result. Useful for logging malformed payloads +/// that would otherwise be silently swallowed downstream. +struct OnFailure: CodingTransformer +where + T: CodingTransformer +{ + typealias Input = T.Input + typealias Output = T.Output + + let transformer: T + let handler: (any Error) -> Void + + init(transformer: T, handler: @escaping (any Error) -> Void) { + self.transformer = transformer + self.handler = handler + } + + func transform(_ input: Result) -> Result { + let output = transformer.transform(input) + if case .failure(let error) = output { + handler(error) + } + return output + } +} + +extension OnFailure: BidirectionalCodingTransformer +where + T: BidirectionalCodingTransformer +{ + func reverseTransform(_ input: Result) -> Result { + let output = transformer.reverseTransform(input) + if case .failure(let error) = output { + handler(error) + } + return output + } +} diff --git a/Sources/CodableKitMacros/CodableMacro.swift b/Sources/CodableKitMacros/CodableMacro.swift index 503ed4b..097bc17 100644 --- a/Sources/CodableKitMacros/CodableMacro.swift +++ b/Sources/CodableKitMacros/CodableMacro.swift @@ -69,18 +69,21 @@ public struct CodableMacro: ExtensionMacro { // If there are no properties, return an empty array. guard !properties.isEmpty else { return [] } - let needsSeparateKeys = properties.contains { $0.containsDifferentKeyPaths(for: codableType) } + // Derived properties have no coding key and never participate in container decode/encode. + let codedProperties = properties.filter { !$0.isDerived } + + let needsSeparateKeys = codedProperties.contains { $0.containsDifferentKeyPaths(for: codableType) } let codingKeyDecls: [EnumDeclSyntax] let usingTree: NamespaceNode if needsSeparateKeys { - let decodeTree = NamespaceNode.buildTree(.decodable, from: properties) - let encodeTree = NamespaceNode.buildTree(.encodable, from: properties) + let decodeTree = NamespaceNode.buildTree(.decodable, from: codedProperties) + let encodeTree = NamespaceNode.buildTree(.encodable, from: codedProperties) usingTree = decodeTree codingKeyDecls = decodeTree.allCodingKeysEnums + encodeTree.allCodingKeysEnums } else { - let sharedTree = NamespaceNode.buildTree(.codable, from: properties) + let sharedTree = NamespaceNode.buildTree(.codable, from: codedProperties) usingTree = sharedTree codingKeyDecls = sharedTree.allCodingKeysEnums } @@ -147,6 +150,9 @@ public struct CodableMacro: ExtensionMacro { } ] } + } catch is DiagnosticAlreadyEmitted { + // The member macro path has already attached the diagnostic to the offending node. + return [] } catch is SimpleDiagnosticMessage { // Swallow known diagnostics here to avoid emitting duplicates across macro roles. // The member macro will surface the diagnostic once. @@ -168,9 +174,14 @@ extension CodableMacro: MemberMacro { ) throws -> [DeclSyntax] { let core = CodeGenCore() // Member macro path: allow advisory diagnostics to be emitted once here - try core.prepareCodeGeneration( - of: node, for: declaration, in: context, conformingTo: protocols, emitAdvisories: true - ) + do { + try core.prepareCodeGeneration( + of: node, for: declaration, in: context, conformingTo: protocols, emitAdvisories: true + ) + } catch is DiagnosticAlreadyEmitted { + // The diagnostic is already attached to the offending node; abort generation silently. + return [] + } let properties = try core.properties(for: declaration, in: context) let accessModifier = try core.accessModifier(for: declaration, in: context) @@ -182,16 +193,19 @@ extension CodableMacro: MemberMacro { // If there are no properties, return an empty array. guard !properties.isEmpty else { return [] } - let needsSeparateKeys = properties.contains { $0.containsDifferentKeyPaths(for: codableType) } + // Derived properties have no coding key and never participate in container decode/encode. + let codedProperties = properties.filter { !$0.isDerived } + + let needsSeparateKeys = codedProperties.contains { $0.containsDifferentKeyPaths(for: codableType) } let decodeTree: NamespaceNode let encodeTree: NamespaceNode if needsSeparateKeys { - decodeTree = NamespaceNode.buildTree(.decodable, from: properties) - encodeTree = NamespaceNode.buildTree(.encodable, from: properties) + decodeTree = NamespaceNode.buildTree(.decodable, from: codedProperties) + encodeTree = NamespaceNode.buildTree(.encodable, from: codedProperties) } else { - let sharedTree = NamespaceNode.buildTree(.codable, from: properties) + let sharedTree = NamespaceNode.buildTree(.codable, from: codedProperties) decodeTree = sharedTree encodeTree = sharedTree } @@ -297,6 +311,12 @@ extension CodableMacro { containerDecl } + // Derived properties: computed from already-decoded sibling values, in declaration order. + // Emitted after all coded assignments and before the didDecode hooks so hooks observe them. + for item in derivedPropertyAssignments(from: properties) { + item + } + if hasSuper { if codableOptions.contains(.skipSuperCoding) { "super.init()" @@ -316,6 +336,38 @@ extension CodableMacro { } } + /// Generate the tail assignments for `@DerivedKey` properties in `init(from:)`. + /// + /// Each derived property is assigned by feeding the already-decoded source property through the + /// transformer pipeline via the `__ckDecodeDerived` runtime helper, in declaration order. + /// Failure policy mirrors `.useDefaultOnFailure` conventions: optional properties and + /// properties with a default initializer value fall back to `nil`/the default on pipeline + /// failure; non-optional properties without a default propagate the error. + fileprivate static func derivedPropertyAssignments(from properties: [Property]) -> [CodeBlockItemSyntax] { + properties + .filter(\.isDerived) + .compactMap { property in + guard + let transformerExpr = property.derivedTransformerExpr, + let sourceName = property.derivedFromPropertyName + else { return nil } + + // A `let` with an initializer can never be re-assigned in `init(from:)`. The peer macro + // already diagnoses this; skip the tail assignment so the diagnostic is not followed by + // confusing secondary compile errors in generated code. + guard !(property.isConstant && property.defaultValue != nil) else { return nil } + + let callExpr: ExprSyntax = + "__ckDecodeDerived(transformer: \(transformerExpr), from: \(raw: sourceName))" + + if property.isOptional || property.defaultValue != nil { + return "\(property.name) = (try? \(callExpr)) ?? \(property.defaultValue ?? "nil")" + } else { + return "\(property.name) = try \(callExpr)" + } + } + } + /// Generate the `func encode(to encoder: Encoder)` method of the `Codable` protocol. fileprivate static func genEncodeFuncDecl( from properties: [Property], diff --git a/Sources/CodableKitMacros/CodableProperty+Derived.swift b/Sources/CodableKitMacros/CodableProperty+Derived.swift new file mode 100644 index 0000000..1e003e2 --- /dev/null +++ b/Sources/CodableKitMacros/CodableProperty+Derived.swift @@ -0,0 +1,45 @@ +// +// CodableProperty+Derived.swift +// CodableKit +// +// Created by Wendell Wang on 2026/6/11. +// + +import SwiftSyntax + +// MARK: - Derived property helpers (`@DerivedKey`) +extension CodableProperty { + /// The `@DerivedKey` attribute attached to the property, if any. + var derivedKeyAttribute: AttributeSyntax? { + attributes.first { $0.macroName == "DerivedKey" } + } + + /// Indicates if the property is derived: it has no coding key of its own and is computed at + /// the end of `init(from:)` from an already-decoded sibling property. + var isDerived: Bool { derivedKeyAttribute != nil } + + /// The name of the source property in `@DerivedKey(from:)`, when written as a plain string + /// literal. `nil` when the argument is missing, empty, or not a simple string literal. + var derivedFromPropertyName: String? { + guard + let expr = derivedKeyAttribute?.arguments? + .as(LabeledExprListSyntax.self)? + .getExpr(label: "from")? + .expression, + let stringLiteral = expr.as(StringLiteralExprSyntax.self), + stringLiteral.segments.count == 1, + let segment = stringLiteral.segments.first?.as(StringSegmentSyntax.self) + else { return nil } + + let text = segment.content.text + return text.isEmpty ? nil : text + } + + /// The transformer expression provided via `@DerivedKey(transformer:)`. + var derivedTransformerExpr: ExprSyntax? { + derivedKeyAttribute?.arguments? + .as(LabeledExprListSyntax.self)? + .getExpr(label: "transformer")? + .expression + } +} diff --git a/Sources/CodableKitMacros/CodableProperty.swift b/Sources/CodableKitMacros/CodableProperty.swift index fdf0125..0775cdb 100644 --- a/Sources/CodableKitMacros/CodableProperty.swift +++ b/Sources/CodableKitMacros/CodableProperty.swift @@ -21,6 +21,8 @@ struct CodableProperty { private(set) var attributes: [AttributeSyntax] /// The declaration modifiers of the property let declModifiers: [DeclModifierSyntax] + /// Whether the property is declared with the `let` binding specifier + let isConstant: Bool /// The name of the property let name: PatternSyntax /// The type of the property @@ -33,12 +35,14 @@ struct CodableProperty { /// - Parameters: /// - attributes: The attributes associated with the macro. /// - declModifiers: The declaration modifiers associated of the property. + /// - isConstant: Whether the property is declared with the `let` binding specifier. /// - binding: The pattern binding syntax. /// - type: The default type syntax. Variable Decl might not have a type annotation like in /// `let a, b: String`, so we need to pass the default type. private init( attributes: [AttributeSyntax], declModifiers: [DeclModifierSyntax], + isConstant: Bool, binding: PatternBindingSyntax, defaultType type: TypeSyntax ) { @@ -46,6 +50,7 @@ struct CodableProperty { $0.macroName < $1.macroName } self.declModifiers = declModifiers + self.isConstant = isConstant self.name = binding.pattern.trimmed self.type = binding.typeAnnotation?.type.trimmed ?? type.trimmed self.defaultValue = binding.initializer?.value @@ -66,6 +71,7 @@ struct CodableProperty { $0.macroName < $1.macroName } self.declModifiers = declModifiers + self.isConstant = false self.name = PatternSyntax(IdentifierPatternSyntax(identifier: caseElement.name.trimmed)) self.type = "Never" self.defaultValue = nil @@ -388,6 +394,7 @@ extension CodableProperty { // Ignore static properties guard !modifiers.contains(where: \.name.isTypePropertyKeyword) else { return [] } + let isConstant = variable.bindingSpecifier.tokenKind == .keyword(.let) let globalDefaultType = variable.bindings.last?.typeAnnotation?.type var properties: [Self] = [] @@ -401,7 +408,8 @@ extension CodableProperty { if let fallbackType { let property = Self( - attributes: attributes, declModifiers: modifiers, binding: binding, defaultType: fallbackType + attributes: attributes, declModifiers: modifiers, isConstant: isConstant, binding: binding, + defaultType: fallbackType ) try property.validated() properties.append(property) @@ -409,7 +417,8 @@ extension CodableProperty { } // If we cannot determine a type and the property is ignored, skip it silently. - let tmpProperty = Self(attributes: attributes, declModifiers: [], binding: binding, defaultType: "Any") + let tmpProperty = Self( + attributes: attributes, declModifiers: [], isConstant: isConstant, binding: binding, defaultType: "Any") if tmpProperty.ignored { continue } diff --git a/Sources/CodableKitMacros/CodeGenCore.swift b/Sources/CodableKitMacros/CodeGenCore.swift index 62d199f..58d736b 100644 --- a/Sources/CodableKitMacros/CodeGenCore.swift +++ b/Sources/CodableKitMacros/CodeGenCore.swift @@ -405,8 +405,9 @@ extension CodeGenCore { throw error("No properties found") } - // Check if there are key conflicts - let notIgnoredProperties = extractedProperties.filter({ !$0.ignored }) + // Check if there are key conflicts. Derived properties have no coding key of their own, + // so they cannot conflict with coded keys. + let notIgnoredProperties = extractedProperties.filter({ !$0.ignored && !$0.isDerived }) var propertiesKeySet = Set(notIgnoredProperties.map(\.normalizedName)) if propertiesKeySet.count != notIgnoredProperties.count { for property in notIgnoredProperties { @@ -441,6 +442,61 @@ extension CodeGenCore { } } + // Validate @DerivedKey usage. Derived properties are decode-only: they have no coding key, + // are never encoded, and are computed at the end of `init(from:)` from a decoded sibling. + // Failures are attached to the offending @DerivedKey attribute and only emitted on the + // advisory (member macro) path, so each diagnostic surfaces exactly once; generation is + // then aborted via `DiagnosticAlreadyEmitted`. + for property in extractedProperties where property.isDerived { + guard let derivedAttribute = property.derivedKeyAttribute else { continue } + + func derivedError(_ message: String) -> any Error { + if emitAdvisories { + context.diagnose(makeDiagnostic(node: derivedAttribute, message: message, severity: .error)) + } + return DiagnosticAlreadyEmitted() + } + + if !property.attachedKeyMacros.isEmpty { + throw derivedError( + "@DerivedKey cannot be combined with @CodableKey, @DecodableKey, or @EncodableKey on the same property") + } + + if !macroCodableType.contains(.decodable) { + throw derivedError( + "@DerivedKey is decode-only and cannot be used in an @Encodable-only type; use @Codable or @Decodable") + } + + guard let derivedFromName = property.derivedFromPropertyName else { + throw derivedError( + "@DerivedKey requires a 'from:' argument that is a non-empty string literal naming a sibling stored property" + ) + } + + guard let sourceProperty = extractedProperties.first(where: { $0.name.trimmedDescription == derivedFromName }) + else { + throw derivedError( + "@DerivedKey source property '\(derivedFromName)' does not exist as a stored property of this type; inherited properties are not supported as 'from:' sources" + ) + } + + if sourceProperty.isDerived { + throw derivedError( + "@DerivedKey source property '\(derivedFromName)' is itself derived; derived properties may only depend on coded properties" + ) + } + + if sourceProperty.ignored { + throw derivedError( + "@DerivedKey source property '\(derivedFromName)' is excluded from decoding (.ignored); derived properties may only depend on decoded properties" + ) + } + + if property.derivedTransformerExpr == nil { + throw derivedError("@DerivedKey requires a 'transformer:' argument") + } + } + properties[id] = extractedProperties } // Detect lifecycle hook methods on the declaration (only once per declaration) diff --git a/Sources/CodableKitMacros/DerivedKeyMacro.swift b/Sources/CodableKitMacros/DerivedKeyMacro.swift new file mode 100644 index 0000000..d39fd22 --- /dev/null +++ b/Sources/CodableKitMacros/DerivedKeyMacro.swift @@ -0,0 +1,44 @@ +// +// DerivedKeyMacro.swift +// CodableKit +// +// Created by Wendell Wang on 2026/6/11. +// + +import SwiftSyntax +import SwiftSyntaxMacros + +/// The peer macro behind `@DerivedKey`. +/// +/// `@DerivedKey` is primarily a marker consumed by the `@Codable`/`@Decodable` code generation: +/// the container macro skips the property in `CodingKeys`, decode, and encode, and instead emits +/// a tail assignment in `init(from:)` that feeds the already-decoded source property through the +/// transformer pipeline. This peer macro therefore generates no declarations; it only validates +/// the constraints that are visible at the property declaration itself. +public struct DerivedKeyMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + // Reuse the shared preparation path for structural validation (variable declarations only, + // no accessor block, non-static). + _ = try CodeGenCore().prepareCodeGeneration(for: declaration, in: context, with: node) + + guard let variable = VariableDeclSyntax(declaration) else { return [] } + + // The generated tail assignment writes the property at the end of `init(from:)`. A `let` + // that already has an initializer can never be assigned again, so reject it up front. + if variable.bindingSpecifier.tokenKind == .keyword(.let), + variable.bindings.contains(where: { $0.initializer != nil }) + { + throw SimpleDiagnosticMessage( + message: + "@DerivedKey cannot be applied to a 'let' property with an initializer; use 'var' or remove the initializer", + severity: .error + ) + } + + return [] + } +} diff --git a/Sources/CodableKitMacros/Diagnostic.swift b/Sources/CodableKitMacros/Diagnostic.swift index 377ccc9..49f152a 100644 --- a/Sources/CodableKitMacros/Diagnostic.swift +++ b/Sources/CodableKitMacros/Diagnostic.swift @@ -34,6 +34,12 @@ struct SimpleFixItMessage: FixItMessage { } } +/// An error thrown after a diagnostic has already been emitted via `context.diagnose`. +/// +/// Expansion entry points catch it to abort code generation without emitting a duplicate +/// diagnostic at the container macro attribute. +struct DiagnosticAlreadyEmitted: Error {} + enum CustomError: Error, CustomStringConvertible { case message(String) diff --git a/Sources/CodableKitMacros/Plugin.swift b/Sources/CodableKitMacros/Plugin.swift index 580fadc..c8cd707 100644 --- a/Sources/CodableKitMacros/Plugin.swift +++ b/Sources/CodableKitMacros/Plugin.swift @@ -15,6 +15,7 @@ struct CodableKitPlugin: CompilerPlugin { CodableKeyMacro.self, DecodableKeyMacro.self, EncodableKeyMacro.self, + DerivedKeyMacro.self, CodingHookMacro.self, ] } diff --git a/Tests/CodableKitTests/CodableMacroTests+derived.runtime.swift b/Tests/CodableKitTests/CodableMacroTests+derived.runtime.swift new file mode 100644 index 0000000..a8c99c6 --- /dev/null +++ b/Tests/CodableKitTests/CodableMacroTests+derived.runtime.swift @@ -0,0 +1,220 @@ +// +// CodableMacroTests+derived.runtime.swift +// CodableKitTests +// +// Runtime behavior tests for @DerivedKey +// + +import CodableKit +import Foundation +import Testing + +// Top-level test models (local types cannot have attached macros) + +struct DerivedFrame: Codable, Equatable { + let id: Int +} + +/// Pulls the `frame` slot out of a decoded slot-bag and decodes its embedded JSON payload. +private struct DerivedFrameSlotTransformer: CodingTransformer { + func transform(_ input: Result<[String: String]?, any Error>) -> Result { + input.flatMap { bag in + guard let rawString = bag?["frame"] else { return .success(nil) } + return Result { + guard let data = rawString.data(using: .utf8) else { + throw DecodingError.dataCorrupted( + .init(codingPath: [], debugDescription: "Invalid UTF-8 in frame slot") + ) + } + return try JSONDecoder().decode(DerivedFrame.self, from: data) + } + } + } +} + +private struct DerivedTagCountTransformer: CodingTransformer { + func transform(_ input: Result<[String], any Error>) -> Result { + input.map(\.count) + } +} + +/// Fails the pipeline when the tag list is empty; used to verify error propagation for +/// non-optional, no-default derived properties. +private struct DerivedStrictCountTransformer: CodingTransformer { + func transform(_ input: Result<[String], any Error>) -> Result { + input.flatMap { tags in + guard !tags.isEmpty else { + return .failure( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "tags must not be empty")) + ) + } + return .success(tags.count) + } + } +} + +@Codable +struct DerivedConfigModel: Equatable { + var userConfigValue: [String: String]? + @DerivedKey(from: "userConfigValue", transformer: DerivedFrameSlotTransformer()) + private(set) var avatarFrame: DerivedFrame? +} + +@Codable +struct DerivedCountModel: Equatable { + var tags: [String] = [] + @DerivedKey(from: "tags", transformer: DerivedTagCountTransformer()) + private(set) var tagCount: Int = -1 +} + +@Codable +struct DerivedStrictModel { + var tags: [String] + @DerivedKey(from: "tags", transformer: DerivedStrictCountTransformer()) + private(set) var tagCount: Int +} + +@Codable +struct DerivedHookModel { + var tags: [String] = [] + @DerivedKey(from: "tags", transformer: DerivedTagCountTransformer()) + private(set) var tagCount: Int = -1 + @CodableKey(options: .ignored) + var observedCount: Int = -2 + + @CodableHook(.didDecode) + mutating func captureDerived() { + observedCount = tagCount + } +} + +@Codable +class DerivedConfigClass { + var userConfigValue: [String: String]? + @DerivedKey(from: "userConfigValue", transformer: DerivedFrameSlotTransformer()) + private(set) var avatarFrame: DerivedFrame? +} + +@Codable +class DerivedHookClass { + var tags: [String] = [] + @DerivedKey(from: "tags", transformer: DerivedTagCountTransformer()) + private(set) var tagCount: Int = -1 + @CodableKey(options: .ignored) + var observedCount: Int = -2 + + @CodableHook(.didDecode) + func captureDerived() { + observedCount = tagCount + } +} + +@Codable +class DerivedBaseClass { + var baseName: String = "" +} + +// The child inherits Codable from the base, so the compiler hands the macro an empty +// conformance list; `.skipProtocolConformance` opts back into member generation (the +// established pattern for subclasses of already-Codable bases). +@Codable(options: .skipProtocolConformance) +class DerivedChildClass: DerivedBaseClass { + var tags: [String] = [] + @DerivedKey(from: "tags", transformer: DerivedTagCountTransformer()) + private(set) var tagCount: Int = -1 +} + +@Suite struct DerivedKeyRuntimeTests { + @Test func derivedValue_materializesFromSiblingSlotBag() throws { + let json = #"{"userConfigValue":{"frame":"{\"id\":7}","other":"x"}}"# + let data = json.data(using: .utf8)! + let decoded = try JSONDecoder().decode(DerivedConfigModel.self, from: data) + #expect(decoded.userConfigValue?["other"] == "x") + #expect(decoded.avatarFrame == DerivedFrame(id: 7)) + } + + @Test func derivedValue_malformedPayload_fallsBackToNil() throws { + let json = #"{"userConfigValue":{"frame":"not json"}}"# + let data = json.data(using: .utf8)! + let decoded = try JSONDecoder().decode(DerivedConfigModel.self, from: data) + #expect(decoded.avatarFrame == nil) + } + + @Test func derivedValue_missingSlot_isNil() throws { + let json = #"{"userConfigValue":{"other":"x"}}"# + let data = json.data(using: .utf8)! + let decoded = try JSONDecoder().decode(DerivedConfigModel.self, from: data) + #expect(decoded.avatarFrame == nil) + } + + @Test func derivedValue_roundTripEncode_omitsDerivedProperty() throws { + let json = #"{"userConfigValue":{"frame":"{\"id\":7}"}}"# + let data = json.data(using: .utf8)! + let decoded = try JSONDecoder().decode(DerivedConfigModel.self, from: data) + + let encodedData = try JSONEncoder().encode(decoded) + let encodedString = String(data: encodedData, encoding: .utf8)! + #expect(!encodedString.contains("avatarFrame")) + + // Re-decoding the encoded payload re-derives the same value. + let redecoded = try JSONDecoder().decode(DerivedConfigModel.self, from: encodedData) + #expect(redecoded == decoded) + } + + @Test func derivedValue_nonOptionalWithDefault_derivesFromCodedSibling() throws { + let json = #"{"tags":["a","b","c"]}"# + let data = json.data(using: .utf8)! + let decoded = try JSONDecoder().decode(DerivedCountModel.self, from: data) + #expect(decoded.tagCount == 3) + } + + @Test func derivedValue_nonOptionalNoDefault_pipelineFailure_throwsFromDecode() throws { + let json = #"{"tags":[]}"# + let data = json.data(using: .utf8)! + #expect(throws: (any Error).self) { + try JSONDecoder().decode(DerivedStrictModel.self, from: data) + } + } + + @Test func derivedValue_nonOptionalNoDefault_pipelineSuccess_decodes() throws { + let json = #"{"tags":["a","b"]}"# + let data = json.data(using: .utf8)! + let decoded = try JSONDecoder().decode(DerivedStrictModel.self, from: data) + #expect(decoded.tagCount == 2) + } + + @Test func derivedValue_structDidDecodeHook_observesDerivedValue() throws { + let json = #"{"tags":["a","b","c"]}"# + let data = json.data(using: .utf8)! + let decoded = try JSONDecoder().decode(DerivedHookModel.self, from: data) + #expect(decoded.tagCount == 3) + #expect(decoded.observedCount == 3) + } +} + +@Suite struct DerivedKeyClassRuntimeTests { + @Test func derivedValue_classWithoutSuperclass_materializes() throws { + let json = #"{"userConfigValue":{"frame":"{\"id\":11}"}}"# + let data = json.data(using: .utf8)! + let decoded = try JSONDecoder().decode(DerivedConfigClass.self, from: data) + #expect(decoded.avatarFrame == DerivedFrame(id: 11)) + } + + @Test func derivedValue_classDidDecodeHook_observesDerivedValue() throws { + let json = #"{"tags":["a","b"]}"# + let data = json.data(using: .utf8)! + let decoded = try JSONDecoder().decode(DerivedHookClass.self, from: data) + #expect(decoded.tagCount == 2) + #expect(decoded.observedCount == 2) + } + + @Test func derivedValue_subclass_derivesBeforeSuperInit() throws { + // The generated init must assign the derived property before `try super.init(from:)`, + // otherwise this would not compile; decoding proves both halves run. + let json = #"{"baseName":"root","tags":["a","b"]}"# + let data = json.data(using: .utf8)! + let decoded = try JSONDecoder().decode(DerivedChildClass.self, from: data) + #expect(decoded.baseName == "root") + #expect(decoded.tagCount == 2) + } +} diff --git a/Tests/CodableKitTests/CodableMacroTests+derived.swift b/Tests/CodableKitTests/CodableMacroTests+derived.swift new file mode 100644 index 0000000..16c8a8c --- /dev/null +++ b/Tests/CodableKitTests/CodableMacroTests+derived.swift @@ -0,0 +1,620 @@ +// +// CodableMacroTests+derived.swift +// CodableKitTests +// +// Expansion and diagnostics tests for @DerivedKey +// + +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import Testing + +@Suite struct DerivedKeyExpansionTests { + @Test func basicDerivedProperty_noCodingKey_noEncode_tailAssignment() throws { + assertMacro( + """ + @Codable + public struct UserCommonConfigInfo { + public var userConfigValue: [String: String]? + @DerivedKey(from: "userConfigValue", transformer: FrameTransformer()) + public private(set) var avatarFrame: AvatarFrame? + } + """, + expandedSource: """ + public struct UserCommonConfigInfo { + public var userConfigValue: [String: String]? + public private(set) var avatarFrame: AvatarFrame? + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(userConfigValue, forKey: .userConfigValue) + } + } + + extension UserCommonConfigInfo: Codable { + enum CodingKeys: String, CodingKey { + case userConfigValue + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + userConfigValue = try container.decodeIfPresent([String: String]?.self, forKey: .userConfigValue) ?? nil + avatarFrame = (try? __ckDecodeDerived(transformer: FrameTransformer(), from: userConfigValue)) ?? nil + } + } + """ + ) + } + + @Test func derivedProperty_nonOptionalWithDefault_fallsBackToDefault() throws { + assertMacro( + """ + @Codable + public struct TagBox { + var tags: [String] = [] + @DerivedKey(from: "tags", transformer: CountTransformer()) + var tagCount: Int = 0 + } + """, + expandedSource: """ + public struct TagBox { + var tags: [String] = [] + var tagCount: Int = 0 + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(tags, forKey: .tags) + } + } + + extension TagBox: Codable { + enum CodingKeys: String, CodingKey { + case tags + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] + tagCount = (try? __ckDecodeDerived(transformer: CountTransformer(), from: tags)) ?? 0 + } + } + """ + ) + } + + @Test func derivedProperty_nonOptionalWithoutDefault_propagatesErrors() throws { + assertMacro( + """ + @Codable + public struct TagBox { + var tags: [String] = [] + @DerivedKey(from: "tags", transformer: CountTransformer()) + var tagCount: Int + } + """, + expandedSource: """ + public struct TagBox { + var tags: [String] = [] + var tagCount: Int + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(tags, forKey: .tags) + } + } + + extension TagBox: Codable { + enum CodingKeys: String, CodingKey { + case tags + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] + tagCount = try __ckDecodeDerived(transformer: CountTransformer(), from: tags) + } + } + """ + ) + } + + @Test func multipleDerivedProperties_assignedInDeclarationOrder_beforeDidDecodeHook() throws { + assertMacro( + """ + @Codable + public struct UserCommonConfigInfo { + public var userConfigValue: [String: String]? + @DerivedKey(from: "userConfigValue", transformer: FrameTransformer()) + public private(set) var avatarFrame: AvatarFrame? + @DerivedKey(from: "userConfigValue", transformer: BadgeTransformer()) + public private(set) var badge: Badge? + + @CodableHook(.didDecode) + mutating func didDecode() throws {} + } + """, + expandedSource: """ + public struct UserCommonConfigInfo { + public var userConfigValue: [String: String]? + public private(set) var avatarFrame: AvatarFrame? + public private(set) var badge: Badge? + + @CodableHook(.didDecode) + mutating func didDecode() throws {} + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(userConfigValue, forKey: .userConfigValue) + } + } + + extension UserCommonConfigInfo: Codable { + enum CodingKeys: String, CodingKey { + case userConfigValue + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + userConfigValue = try container.decodeIfPresent([String: String]?.self, forKey: .userConfigValue) ?? nil + avatarFrame = (try? __ckDecodeDerived(transformer: FrameTransformer(), from: userConfigValue)) ?? nil + badge = (try? __ckDecodeDerived(transformer: BadgeTransformer(), from: userConfigValue)) ?? nil + try didDecode() + } + } + """ + ) + } + + @Test func derivedProperty_inClassWithoutSuperclass() throws { + assertMacro( + """ + @Codable + public class ConfigClass { + public var userConfigValue: [String: String]? + @DerivedKey(from: "userConfigValue", transformer: FrameTransformer()) + public private(set) var avatarFrame: AvatarFrame? + } + """, + expandedSource: """ + public class ConfigClass { + public var userConfigValue: [String: String]? + public private(set) var avatarFrame: AvatarFrame? + + public required init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + userConfigValue = try container.decodeIfPresent([String: String]?.self, forKey: .userConfigValue) ?? nil + avatarFrame = (try? __ckDecodeDerived(transformer: FrameTransformer(), from: userConfigValue)) ?? nil + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(userConfigValue, forKey: .userConfigValue) + } + } + + extension ConfigClass: Codable { + enum CodingKeys: String, CodingKey { + case userConfigValue + } + } + """ + ) + } + + @Test func derivedProperty_inSubclass_assignedBeforeSuperInit() throws { + assertMacro( + """ + @Codable + public class ChildConfig: BaseConfig { + public var userConfigValue: [String: String]? + @DerivedKey(from: "userConfigValue", transformer: FrameTransformer()) + public private(set) var avatarFrame: AvatarFrame? + } + """, + expandedSource: """ + public class ChildConfig: BaseConfig { + public var userConfigValue: [String: String]? + public private(set) var avatarFrame: AvatarFrame? + + public required init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + userConfigValue = try container.decodeIfPresent([String: String]?.self, forKey: .userConfigValue) ?? nil + avatarFrame = (try? __ckDecodeDerived(transformer: FrameTransformer(), from: userConfigValue)) ?? nil + try super.init(from: decoder) + } + + public override func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(userConfigValue, forKey: .userConfigValue) + try super.encode(to: encoder) + } + } + + extension ChildConfig { + enum CodingKeys: String, CodingKey { + case userConfigValue + } + } + """ + ) + } +} + +@Suite struct DerivedKeyDiagnosticsTests { + @Test func derivedKeyCombinedWithCodableKeyIsAnError() throws { + assertMacro( + """ + @Codable + public struct User { + let id: Int + @DerivedKey(from: "id", transformer: StringifyTransformer()) + @CodableKey("display") + var display: String? + } + """, + expandedSource: """ + public struct User { + let id: Int + var display: String? + } + """, + diagnostics: [ + .init( + message: + "@DerivedKey cannot be combined with @CodableKey, @DecodableKey, or @EncodableKey on the same property", + line: 4, + column: 3 + ) + ] + ) + } + + @Test func derivedKeyCombinedWithDecodableKeyIsAnError() throws { + assertMacro( + """ + @Codable + public struct User { + let id: Int + @DerivedKey(from: "id", transformer: StringifyTransformer()) + @DecodableKey("display") + var display: String? + } + """, + expandedSource: """ + public struct User { + let id: Int + var display: String? + } + """, + diagnostics: [ + .init( + message: + "@DerivedKey cannot be combined with @CodableKey, @DecodableKey, or @EncodableKey on the same property", + line: 4, + column: 3 + ) + ] + ) + } + + @Test func derivedKeyCombinedWithEncodableKeyIsAnError() throws { + assertMacro( + """ + @Codable + public struct User { + let id: Int + @DerivedKey(from: "id", transformer: StringifyTransformer()) + @EncodableKey("display") + var display: String? + } + """, + expandedSource: """ + public struct User { + let id: Int + var display: String? + } + """, + diagnostics: [ + .init( + message: + "@DerivedKey cannot be combined with @CodableKey, @DecodableKey, or @EncodableKey on the same property", + line: 4, + column: 3 + ) + ] + ) + } + + @Test func derivedKeySourcePropertyMustExist() throws { + assertMacro( + """ + @Codable + public struct User { + let id: Int + @DerivedKey(from: "missing", transformer: StringifyTransformer()) + var display: String? + } + """, + expandedSource: """ + public struct User { + let id: Int + var display: String? + } + """, + diagnostics: [ + .init( + message: + "@DerivedKey source property 'missing' does not exist as a stored property of this type; inherited properties are not supported as 'from:' sources", + line: 4, + column: 3 + ) + ] + ) + } + + @Test func derivedKeyNamingASuperclassPropertyIsAnError() throws { + assertMacro( + """ + @Codable + public class ChildConfig: BaseConfig { + var tags: [String] = [] + @DerivedKey(from: "base", transformer: FrameTransformer()) + var derivedValue: String? + } + """, + expandedSource: """ + public class ChildConfig: BaseConfig { + var tags: [String] = [] + var derivedValue: String? + } + """, + diagnostics: [ + .init( + message: + "@DerivedKey source property 'base' does not exist as a stored property of this type; inherited properties are not supported as 'from:' sources", + line: 4, + column: 3 + ) + ] + ) + } + + @Test func derivedKeyWithNonStringLiteralFromIsAnError() throws { + assertMacro( + """ + @Codable + public struct User { + let id: Int + @DerivedKey(from: sourceName, transformer: StringifyTransformer()) + var display: String? + } + """, + expandedSource: """ + public struct User { + let id: Int + var display: String? + } + """, + diagnostics: [ + .init( + message: + "@DerivedKey requires a 'from:' argument that is a non-empty string literal naming a sibling stored property", + line: 4, + column: 3 + ) + ] + ) + } + + @Test func derivedKeyOnLetWithInitializerIsAnError() throws { + assertMacro( + """ + @Codable + public struct User { + let id: Int + @DerivedKey(from: "id", transformer: StringifyTransformer()) + let display: String = "none" + } + """, + expandedSource: """ + public struct User { + let id: Int + let display: String = "none" + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + } + } + + extension User: Codable { + enum CodingKeys: String, CodingKey { + case id + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int.self, forKey: .id) + } + } + """, + diagnostics: [ + .init( + message: + "@DerivedKey cannot be applied to a 'let' property with an initializer; use 'var' or remove the initializer", + line: 4, + column: 3 + ) + ] + ) + } + + @Test func derivedKeyOnStaticPropertyIsAnError() throws { + assertMacro( + """ + @Codable + public struct User { + let id: Int + @DerivedKey(from: "id", transformer: StringifyTransformer()) + static var display: String? = nil + } + """, + expandedSource: """ + public struct User { + let id: Int + static var display: String? = nil + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + } + } + + extension User: Codable { + enum CodingKeys: String, CodingKey { + case id + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int.self, forKey: .id) + } + } + """, + diagnostics: [ + .init(message: "Only non-static variable declarations are supported", line: 4, column: 3) + ] + ) + } + + @Test func derivedKeyOnComputedPropertyIsAnError() throws { + assertMacro( + """ + @Codable + public struct User { + let id: Int + @DerivedKey(from: "id", transformer: StringifyTransformer()) + var display: String { + "user-\\(id)" + } + } + """, + expandedSource: """ + public struct User { + let id: Int + var display: String { + "user-\\(id)" + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + } + } + + extension User: Codable { + enum CodingKeys: String, CodingKey { + case id + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int.self, forKey: .id) + } + } + """, + diagnostics: [ + .init(message: "Only variable declarations with no accessor block are supported", line: 4, column: 3) + ] + ) + } + + @Test func derivedKeyInEncodableOnlyTypeIsAnError() throws { + assertMacro( + """ + @Encodable + public struct User { + let id: Int + @DerivedKey(from: "id", transformer: StringifyTransformer()) + var display: String? + } + """, + expandedSource: """ + public struct User { + let id: Int + var display: String? + } + """, + diagnostics: [ + .init( + message: + "@DerivedKey is decode-only and cannot be used in an @Encodable-only type; use @Codable or @Decodable", + line: 4, + column: 3 + ) + ] + ) + } + + @Test func derivedKeyFromAnotherDerivedPropertyIsAnError() throws { + assertMacro( + """ + @Codable + public struct User { + let id: Int + @DerivedKey(from: "id", transformer: StringifyTransformer()) + var display: String? + @DerivedKey(from: "display", transformer: CountTransformer()) + var displayLength: Int? + } + """, + expandedSource: """ + public struct User { + let id: Int + var display: String? + var displayLength: Int? + } + """, + diagnostics: [ + .init( + message: + "@DerivedKey source property 'display' is itself derived; derived properties may only depend on coded properties", + line: 6, + column: 3 + ) + ] + ) + } + + @Test func derivedKeyFromIgnoredPropertyIsAnError() throws { + assertMacro( + """ + @Codable + public struct User { + let id: Int + @CodableKey(options: .ignored) + var cache: String = "" + @DerivedKey(from: "cache", transformer: CountTransformer()) + var cacheLength: Int? + } + """, + expandedSource: """ + public struct User { + let id: Int + var cache: String = "" + var cacheLength: Int? + } + """, + diagnostics: [ + .init( + message: + "@DerivedKey source property 'cache' is excluded from decoding (.ignored); derived properties may only depend on decoded properties", + line: 6, + column: 3 + ) + ] + ) + } +} diff --git a/Tests/CodableKitTests/Defines.swift b/Tests/CodableKitTests/Defines.swift index 69ce94d..c21ab3c 100644 --- a/Tests/CodableKitTests/Defines.swift +++ b/Tests/CodableKitTests/Defines.swift @@ -19,13 +19,16 @@ let macros: [String: any Macro.Type] = [ "CodableKey": CodableKeyMacro.self, "DecodableKey": DecodableKeyMacro.self, "EncodableKey": EncodableKeyMacro.self, + "DerivedKey": DerivedKeyMacro.self, ] let macroSpecs: [String: MacroSpec] = [ "Codable": MacroSpec(type: CodableMacro.self, conformances: ["Codable"]), + "Encodable": MacroSpec(type: CodableMacro.self, conformances: ["Encodable"]), "CodableKey": MacroSpec(type: CodableKeyMacro.self), "DecodableKey": MacroSpec(type: DecodableKeyMacro.self), "EncodableKey": MacroSpec(type: EncodableKeyMacro.self), + "DerivedKey": MacroSpec(type: DerivedKeyMacro.self), ] func assertMacro( diff --git a/Tests/DecodableKitTests/CodableMacroTests+derived.swift b/Tests/DecodableKitTests/CodableMacroTests+derived.swift new file mode 100644 index 0000000..a88a796 --- /dev/null +++ b/Tests/DecodableKitTests/CodableMacroTests+derived.swift @@ -0,0 +1,198 @@ +// +// CodableMacroTests+derived.swift +// DecodableKitTests +// +// Expansion tests for @DerivedKey under @Decodable +// + +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import Testing + +@Suite struct DecodableDerivedKeyExpansionTests { + @Test func basicDerivedProperty_noCodingKey_tailAssignment() throws { + assertMacro( + """ + @Decodable + public struct UserCommonConfigInfo { + public var userConfigValue: [String: String]? + @DerivedKey(from: "userConfigValue", transformer: FrameTransformer()) + public private(set) var avatarFrame: AvatarFrame? + } + """, + expandedSource: """ + public struct UserCommonConfigInfo { + public var userConfigValue: [String: String]? + public private(set) var avatarFrame: AvatarFrame? + } + + extension UserCommonConfigInfo: Decodable { + enum CodingKeys: String, CodingKey { + case userConfigValue + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + userConfigValue = try container.decodeIfPresent([String: String]?.self, forKey: .userConfigValue) ?? nil + avatarFrame = (try? __ckDecodeDerived(transformer: FrameTransformer(), from: userConfigValue)) ?? nil + } + } + """ + ) + } + + @Test func derivedProperty_nonOptionalWithoutDefault_propagatesErrors() throws { + assertMacro( + """ + @Decodable + public struct TagBox { + var tags: [String] = [] + @DerivedKey(from: "tags", transformer: CountTransformer()) + var tagCount: Int + } + """, + expandedSource: """ + public struct TagBox { + var tags: [String] = [] + var tagCount: Int + } + + extension TagBox: Decodable { + enum CodingKeys: String, CodingKey { + case tags + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] + tagCount = try __ckDecodeDerived(transformer: CountTransformer(), from: tags) + } + } + """ + ) + } + + @Test func multipleDerivedProperties_assignedInDeclarationOrder() throws { + assertMacro( + """ + @Decodable + public struct UserCommonConfigInfo { + public var userConfigValue: [String: String]? + @DerivedKey(from: "userConfigValue", transformer: FrameTransformer()) + public private(set) var avatarFrame: AvatarFrame? + @DerivedKey(from: "userConfigValue", transformer: BadgeTransformer()) + public private(set) var badge: Badge? + } + """, + expandedSource: """ + public struct UserCommonConfigInfo { + public var userConfigValue: [String: String]? + public private(set) var avatarFrame: AvatarFrame? + public private(set) var badge: Badge? + } + + extension UserCommonConfigInfo: Decodable { + enum CodingKeys: String, CodingKey { + case userConfigValue + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + userConfigValue = try container.decodeIfPresent([String: String]?.self, forKey: .userConfigValue) ?? nil + avatarFrame = (try? __ckDecodeDerived(transformer: FrameTransformer(), from: userConfigValue)) ?? nil + badge = (try? __ckDecodeDerived(transformer: BadgeTransformer(), from: userConfigValue)) ?? nil + } + } + """ + ) + } +} + +@Suite struct DecodableDerivedKeyDiagnosticsTests { + @Test func derivedKeySourcePropertyMustExist() throws { + assertMacro( + """ + @Decodable + public struct User { + let id: Int + @DerivedKey(from: "missing", transformer: StringifyTransformer()) + var display: String? + } + """, + expandedSource: """ + public struct User { + let id: Int + var display: String? + } + """, + diagnostics: [ + .init( + message: + "@DerivedKey source property 'missing' does not exist as a stored property of this type; inherited properties are not supported as 'from:' sources", + line: 4, + column: 3 + ) + ] + ) + } + + @Test func derivedKeyCombinedWithDecodableKeyIsAnError() throws { + assertMacro( + """ + @Decodable + public struct User { + let id: Int + @DerivedKey(from: "id", transformer: StringifyTransformer()) + @DecodableKey("display") + var display: String? + } + """, + expandedSource: """ + public struct User { + let id: Int + var display: String? + } + """, + diagnostics: [ + .init( + message: + "@DerivedKey cannot be combined with @CodableKey, @DecodableKey, or @EncodableKey on the same property", + line: 4, + column: 3 + ) + ] + ) + } + + @Test func derivedKeyFromIgnoredPropertyIsAnError() throws { + assertMacro( + """ + @Decodable + public struct User { + let id: Int + @DecodableKey(options: .ignored) + var cache: String = "" + @DerivedKey(from: "cache", transformer: CountTransformer()) + var cacheLength: Int? + } + """, + expandedSource: """ + public struct User { + let id: Int + var cache: String = "" + var cacheLength: Int? + } + """, + diagnostics: [ + .init( + message: + "@DerivedKey source property 'cache' is excluded from decoding (.ignored); derived properties may only depend on decoded properties", + line: 6, + column: 3 + ) + ] + ) + } +} diff --git a/Tests/DecodableKitTests/Defines.swift b/Tests/DecodableKitTests/Defines.swift index 05aa11b..285f726 100644 --- a/Tests/DecodableKitTests/Defines.swift +++ b/Tests/DecodableKitTests/Defines.swift @@ -20,6 +20,7 @@ let macros: [String: any Macro.Type] = [ "CodableKey": CodableKeyMacro.self, "DecodableKey": DecodableKeyMacro.self, "EncodableKey": EncodableKeyMacro.self, + "DerivedKey": DerivedKeyMacro.self, ] let macroSpecs: [String: MacroSpec] = [ @@ -28,6 +29,7 @@ let macroSpecs: [String: MacroSpec] = [ "CodableKey": MacroSpec(type: CodableKeyMacro.self), "DecodableKey": MacroSpec(type: DecodableKeyMacro.self), "EncodableKey": MacroSpec(type: EncodableKeyMacro.self), + "DerivedKey": MacroSpec(type: DerivedKeyMacro.self), ] func assertMacro( diff --git a/Tests/TransformerTests/DerivedValueTransformerTests.swift b/Tests/TransformerTests/DerivedValueTransformerTests.swift new file mode 100644 index 0000000..db2ebe2 --- /dev/null +++ b/Tests/TransformerTests/DerivedValueTransformerTests.swift @@ -0,0 +1,236 @@ +// +// DerivedValueTransformerTests.swift +// CodableKit +// +// Created by Wendell Wang on 2026/6/11. +// + +import CodableKit +import Foundation +import Testing + +@Suite("Derived Value Transformer Tests") +struct DerivedValueTransformerTests { + enum TestError: Error { case boom } + + // MARK: - DictionaryLookupTransformer + + @Test func dictionaryLookup_hit_returns_value() async throws { + let t = DictionaryLookupTransformer(key: "a") + #expect(try t.transform(.success(["a": 1, "b": 2])).get() == 1) + } + + @Test func dictionaryLookup_miss_returns_nil_without_error() async throws { + let t = DictionaryLookupTransformer(key: "missing") + #expect(try t.transform(.success(["a": 1])).get() == nil) + } + + @Test func dictionaryLookup_nilDictionary_returns_nil_without_error() async throws { + let t = DictionaryLookupTransformer(key: "a") + #expect(try t.transform(.success(nil)).get() == nil) + } + + @Test func dictionaryLookup_propagates_upstream_failure() async throws { + let t = DictionaryLookupTransformer(key: "a") + var threw = false + do { _ = try t.transform(.failure(TestError.boom)).get() } catch { threw = true } + #expect(threw) + } + + @Test func dictionaryLookup_accepts_nonOptional_dictionary_source() async throws { + // A non-optional dictionary promotes implicitly into the `[K: V]?` input. + let dict: [String: Int] = ["a": 1] + let lookup = DictionaryLookupTransformer(key: "a") + #expect(try lookup.transform(.success(dict)).get() == 1) + + // And a chain whose source is non-optional composes via `optional()`. + let chained = IdentityTransformer<[String: Int]>() + .optional() + .chained(DictionaryLookupTransformer(key: "a")) + #expect(try chained.transform(.success(dict)).get() == 1) + } + + // MARK: - liftOptional + + @Test func liftOptional_nil_input_passes_through_as_nil() async throws { + let t = IntegerToBooleanTransformer().liftOptional() + #expect(try t.transform(.success(nil)).get() == nil) + } + + @Test func liftOptional_nonNil_input_runs_base_transformer() async throws { + let t = IntegerToBooleanTransformer().liftOptional() + #expect(try t.transform(.success(1)).get() == true) + #expect(try t.transform(.success(0)).get() == false) + } + + @Test func liftOptional_propagates_upstream_failure() async throws { + let t = IntegerToBooleanTransformer().liftOptional() + var threw = false + do { _ = try t.transform(.failure(TestError.boom)).get() } catch { threw = true } + #expect(threw) + } + + @Test func liftOptional_recovering_base_recovers_upstream_failure() async throws { + let t = DefaultOnFailureTransformer(defaultValue: 9).liftOptional() + + // Upstream failure is forwarded into the base, which recovers to .some(default). + #expect(try t.transform(.failure(TestError.boom)).get() == 9) + + // Upstream nil stays nil; the base transformer is never invoked. + #expect(try t.transform(.success(nil)).get() == nil) + } + + struct DRoom: Codable, Equatable { + let id: Int + let name: String + } + + @Test func liftOptional_propagates_base_transformer_failure() async throws { + let t = RawStringDecodingTransformer().liftOptional() + var threw = false + do { _ = try t.transform(.success("not json")).get() } catch { threw = true } + #expect(threw) + } + + @Test func liftOptional_bidirectional_reverse_maps_nil_and_value() async throws { + let t = IntegerToBooleanTransformer().liftOptional() + #expect(try t.reverseTransform(.success(nil)).get() == nil) + #expect(try t.reverseTransform(.success(true)).get() == 1) + #expect(try t.reverseTransform(.success(false)).get() == 0) + } + + // MARK: - onFailure + + @Test func onFailure_invoked_on_failure_and_result_unchanged() async throws { + var observed: [any Error] = [] + let t = IdentityTransformer().onFailure { observed.append($0) } + let result = t.transform(.failure(TestError.boom)) + + #expect(observed.count == 1) + #expect(observed.first is TestError) + + var caught: (any Error)? + do { _ = try result.get() } catch { caught = error } + #expect(caught is TestError) + } + + @Test func onFailure_not_invoked_on_success_and_result_unchanged() async throws { + var observed: [any Error] = [] + let t = IdentityTransformer().onFailure { observed.append($0) } + #expect(try t.transform(.success(7)).get() == 7) + #expect(observed.isEmpty) + } + + @Test func onFailure_observes_failure_produced_by_base_transformer() async throws { + var observed: [any Error] = [] + let t = RawStringDecodingTransformer().onFailure { observed.append($0) } + var threw = false + do { _ = try t.transform(.success("not json")).get() } catch { threw = true } + #expect(threw) + #expect(observed.count == 1) + } + + /// Identity forward; reverse always fails, to exercise the reverse tap. + struct FailingReverse: BidirectionalCodingTransformer { + func transform(_ input: Result) -> Result { + input + } + + func reverseTransform(_ input: Result) -> Result { + input.flatMap { _ in .failure(TestError.boom) } + } + } + + @Test func onFailure_reverse_invoked_on_failure_and_result_unchanged() async throws { + var observed: [any Error] = [] + let t = FailingReverse().onFailure { observed.append($0) } + let result = t.reverseTransform(.success(1)) + + #expect(observed.count == 1) + #expect(observed.first is TestError) + + var caught: (any Error)? + do { _ = try result.get() } catch { caught = error } + #expect(caught is TestError) + } + + @Test func onFailure_reverse_not_invoked_on_success_and_result_unchanged() async throws { + var observed: [any Error] = [] + let t = IntegerToBooleanTransformer().onFailure { observed.append($0) } + #expect(try t.reverseTransform(.success(true)).get() == 1) + #expect(try t.transform(.success(0)).get() == false) + #expect(observed.isEmpty) + } + + /// Adds one on decode; subtracts one on encode. Forward/reverse companion + /// for building a bidirectional chain. + struct Increment: BidirectionalCodingTransformer { + func transform(_ input: Result) -> Result { + input.map { $0 + 1 } + } + + func reverseTransform(_ input: Result) -> Result { + input.map { $0 - 1 } + } + } + + @Test func onFailure_bidirectional_chain_remains_bidirectional() async throws { + // The tapped chain still erases to `any BidirectionalCodingTransformer`, + // as `@CodableKey(transformer:)` pipelines require. + let pipeline: any BidirectionalCodingTransformer = + Increment() + .chained(IntegerToBooleanTransformer()) + .onFailure { _ in } + + #expect(try pipeline.transform(.success(0)).get() == true) + #expect(try pipeline.reverseTransform(.success(true)).get() == 0) + } + + // MARK: - End-to-end: derived value from a slot bag + + /// Mirrors the motivating payload shape: an optional dictionary of slot bags + /// keyed by config id, where each slot holds an embedded JSON string. + struct Slot: Decodable { + var str1: String? + } + + struct FeatureConfig: Decodable, Equatable { + let enabled: Bool + let limit: Int + } + + /// Dictionary lookup by key, key-path projection to the slot string, then + /// JSON-decode of the embedded string — all from CodableKit primitives. + static func makeDerivedValuePipeline() -> some CodingTransformer<[String: Slot]?, FeatureConfig?> { + DictionaryLookupTransformer(key: "10010") + .chained(KeyPathTransformer(keyPath: \Slot?.?.str1)) + .chained(RawStringDecodingTransformer().liftOptional()) + } + + @Test func endToEnd_present_valid_json_decodes_typed_value() async throws { + let slots: [String: Slot]? = ["10010": Slot(str1: #"{"enabled":true,"limit":3}"#)] + let result = Self.makeDerivedValuePipeline().transform(.success(slots)) + #expect(try result.get() == FeatureConfig(enabled: true, limit: 3)) + } + + @Test func endToEnd_missing_key_yields_nil_without_error() async throws { + let slots: [String: Slot]? = ["99999": Slot(str1: "{}")] + #expect(try Self.makeDerivedValuePipeline().transform(.success(slots)).get() == nil) + + // A nil dictionary and an empty slot are also nil, not errors. + #expect(try Self.makeDerivedValuePipeline().transform(.success(nil)).get() == nil) + #expect(try Self.makeDerivedValuePipeline().transform(.success(["10010": Slot(str1: nil)])).get() == nil) + } + + @Test func endToEnd_malformed_json_fails_and_onFailure_observes() async throws { + var observed: [any Error] = [] + let pipeline = Self.makeDerivedValuePipeline().onFailure { observed.append($0) } + let slots: [String: Slot]? = ["10010": Slot(str1: "not json")] + + var threw = false + do { _ = try pipeline.transform(.success(slots)).get() } catch { threw = true } + #expect(threw) + #expect(observed.count == 1) + #expect(observed.first is DecodingError) + } +}