Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ That single model gets generated `CodingKeys`, `init(from:)`, and `encode(to:)`
| 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; `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 |
| Derived properties | `@Codable(derivedFrom: "slots")` + `@DerivedKey(transformer: MyTransformer())` | Compute typed properties from an already-decoded sibling at the end of `init(from:)` — no coding key, never encoded |

Comment on lines 61 to 64
## Targets

Expand Down Expand Up @@ -121,12 +121,11 @@ struct Feed {
### Derived properties

```swift
@Codable
@Codable(derivedFrom: "userConfigValue")
struct UserConfigInfo {
var userConfigValue: [String: String]?

@DerivedKey(
from: "userConfigValue",
transformer: DictionaryLookupTransformer(key: "avatar_frame")
.chained(RawStringDecodingTransformer<AvatarFrame>().liftOptional())
)
Expand All @@ -135,13 +134,34 @@ struct UserConfigInfo {
```

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
end of `init(from:)` by feeding the already-decoded source 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.

Use `@Codable(derivedFrom:)` or `@Decodable(derivedFrom:)` when several derived properties read
from the same decoded source. A property-level `from:` overrides the type-level default:

```swift
@Codable(derivedFrom: "userConfigValue")
struct UserConfigInfo {
var userConfigValue: [String: String]?
var fallbackConfigValue: [String: String]?

@DerivedKey(transformer: AvatarFrameTransformer())
private(set) var avatarFrame: AvatarFrame?

@DerivedKey(from: "fallbackConfigValue", transformer: BadgeTransformer())
private(set) var badge: Badge?
}
```

If you mutate a source property after decoding, call `rederiveValues()` to refresh the derived
properties. The generated method is `mutating` for structs and nonmutating for classes; it is
`throws` when a non-optional derived property without a default can propagate transformer failures.

### Dynamic JSON values

```swift
Expand Down
28 changes: 20 additions & 8 deletions Sources/CodableKit/CodableKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,12 @@ import CodableKitCore
///
/// - Parameters:
/// - options: Configuration options for the macro behavior. Defaults to `.default`.
/// - derivedFrom: Default source property for `@DerivedKey` properties that omit `from:`.
@attached(extension, conformances: Codable, names: named(CodingKeys), named(init(from:)), arbitrary)
@attached(member, conformances: Codable, names: named(init(from:)), named(encode(to:)), arbitrary)
@attached(member, conformances: Codable, names: named(init(from:)), named(encode(to:)), named(rederiveValues), arbitrary)
public macro Codable(
options: CodableOptions = .default
options: CodableOptions = .default,
derivedFrom: String? = nil
) = #externalMacro(module: "CodableKitMacros", type: "CodableMacro")

/// A macro that generates `Decodable` conformance for structs and classes.
Expand Down Expand Up @@ -280,10 +282,12 @@ public macro Codable(
///
/// - Parameters:
/// - options: Configuration options for the macro behavior. Defaults to `.default`.
/// - derivedFrom: Default source property for `@DerivedKey` properties that omit `from:`.
@attached(extension, conformances: Decodable, names: named(CodingKeys), named(init(from:)), arbitrary)
@attached(member, conformances: Decodable, names: named(init(from:)), arbitrary)
@attached(member, conformances: Decodable, names: named(init(from:)), named(rederiveValues), arbitrary)
public macro Decodable(
options: CodableOptions = .default
options: CodableOptions = .default,
derivedFrom: String? = nil
) = #externalMacro(module: "CodableKitMacros", type: "CodableMacro")

/// A macro that generates `Encodable` conformance for structs and classes.
Expand Down Expand Up @@ -600,11 +604,11 @@ public macro CodableKey(
/// decoded normally — for example, slot-bag dictionaries with embedded JSON strings:
///
/// ```swift
/// @Codable
/// @Codable(derivedFrom: "userConfigValue")
/// public struct UserCommonConfigInfo: Sendable, Hashable {
/// public var userConfigValue: [String: UserConfigValue]?
///
/// @DerivedKey(from: "userConfigValue", transformer: AvatarFrameSlotTransformer())
/// @DerivedKey(transformer: AvatarFrameSlotTransformer())
/// public private(set) var avatarFrame: AvatarFrame?
/// }
/// ```
Expand All @@ -622,6 +626,13 @@ public macro CodableKey(
/// 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.
/// - **Default source:** `@Codable(derivedFrom:)` and `@Decodable(derivedFrom:)` provide a
/// containing-type default for derived properties that omit `from:`. A property-level `from:`
/// always overrides that default.
/// - **Re-derivation:** types with derived properties get `rederiveValues()` so callers can
/// recompute derived values after mutating source properties. The method is `mutating` on
/// structs and nonmutating on classes. It is generated as `throws` when any non-optional,
/// non-defaulted derived property can propagate transformer 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
Expand All @@ -637,12 +648,13 @@ public macro CodableKey(
///
/// - Parameters:
/// - property: The name of the sibling stored property whose decoded value feeds the pipeline.
/// Must be a string literal.
/// Must be a string literal. Defaults to the containing `@Codable(derivedFrom:)` or
/// `@Decodable(derivedFrom:)` value when omitted.
/// - 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,
from property: String? = nil,
transformer: any CodingTransformer
) = #externalMacro(module: "CodableKitMacros", type: "DerivedKeyMacro")

Expand Down
109 changes: 88 additions & 21 deletions Sources/CodableKitMacros/CodableMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ public struct CodableMacro: ExtensionMacro {
let structureType = try core.accessStructureType(for: declaration, in: context)
let codableType = try core.accessCodableType(for: declaration, in: context)
let codableOptions = try core.accessCodableOptions(for: declaration, in: context)
let derivedFromDefault = core.accessDerivedFromDefault(for: declaration, in: context)
let hooks = try core.accessHooksPresence(for: declaration, in: context)

// If there are no properties, return an empty array.
Expand Down Expand Up @@ -135,6 +136,7 @@ public struct CodableMacro: ExtensionMacro {
codableOptions: codableOptions,
hasSuper: false,
tree: usingTree,
derivedFromDefault: derivedFromDefault,
hooks: hooks
)
)
Expand Down Expand Up @@ -188,6 +190,7 @@ extension CodableMacro: MemberMacro {
let structureType = try core.accessStructureType(for: declaration, in: context)
let codableType = try core.accessCodableType(for: declaration, in: context)
let codableOptions = try core.accessCodableOptions(for: declaration, in: context)
let derivedFromDefault = core.accessDerivedFromDefault(for: declaration, in: context)
let hooks = try core.accessHooksPresence(for: declaration, in: context)

// If there are no properties, return an empty array.
Expand Down Expand Up @@ -232,24 +235,47 @@ extension CodableMacro: MemberMacro {

var result: [DeclSyntax] = []

switch structureType {
case .classType:
if codableType.contains(.decodable) {
result.append(
DeclSyntax(
genInitDecoderDecl(
from: properties,
modifiers: decodeModifiers,
codableOptions: codableOptions,
hasSuper: hasSuper,
tree: decodeTree,
hooks: hooks
)
if case .classType = structureType,
codableType.contains(.decodable)
{
result.append(
DeclSyntax(
genInitDecoderDecl(
from: properties,
modifiers: decodeModifiers,
codableOptions: codableOptions,
hasSuper: hasSuper,
tree: decodeTree,
derivedFromDefault: derivedFromDefault,
hooks: hooks
)
)
)
}

let canGenerateRederiveValues =
switch structureType {
case .structType, .classType: true
case .enumType: false
}
fallthrough
case .structType:
if codableType.contains(.decodable),
canGenerateRederiveValues,
properties.contains(where: \.isDerived)
{
result.append(
DeclSyntax(
genRederiveValuesDecl(
from: properties,
modifiers: [accessModifier.witnessSafe],
structureType: structureType,
derivedFromDefault: derivedFromDefault
)
Comment on lines +267 to +272
)
)
}

switch structureType {
case .classType, .structType:
if codableType.contains(.encodable) {
result.append(
DeclSyntax(
Expand Down Expand Up @@ -283,6 +309,7 @@ extension CodableMacro {
codableOptions: CodableOptions,
hasSuper: Bool,
tree: NamespaceNode,
derivedFromDefault: String?,
hooks: HooksPresence
) -> InitializerDeclSyntax {
InitializerDeclSyntax(
Expand Down Expand Up @@ -313,7 +340,7 @@ extension CodableMacro {

// 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) {
for item in derivedPropertyAssignments(from: properties, derivedFromDefault: derivedFromDefault) {
item
}

Expand Down Expand Up @@ -343,18 +370,20 @@ extension CodableMacro {
/// 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] {
fileprivate static func derivedPropertyAssignments(
from properties: [Property],
derivedFromDefault: String?
) -> [CodeBlockItemSyntax] {
properties
.filter(\.isDerived)
.compactMap { property in
guard
let transformerExpr = property.derivedTransformerExpr,
let sourceName = property.derivedFromPropertyName
let sourceName = property.derivedFromPropertyName ?? derivedFromDefault
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.
// Invalid `let` derived properties are diagnosed before generation. Keep this defensive
// skip so invalid partial expansions do not cascade into secondary compiler errors.
guard !(property.isConstant && property.defaultValue != nil) else { return nil }

let callExpr: ExprSyntax =
Expand All @@ -368,6 +397,44 @@ extension CodableMacro {
}
}

fileprivate static func derivedPropertyRederiveValuesThrows(from properties: [Property]) -> Bool {
properties
.filter(\.isDerived)
.contains { !$0.isOptional && $0.defaultValue == nil }
}

fileprivate static func genRederiveValuesDecl(
from properties: [Property],
modifiers: [DeclModifierSyntax],
structureType: StructureType,
derivedFromDefault: String?
) -> FunctionDeclSyntax {
let isMutating =
switch structureType {
case .structType: true
case .classType, .enumType: false
}
var modifiers = modifiers
if isMutating {
modifiers.append(.init(name: .keyword(.mutating)))
}
let isThrowing = derivedPropertyRederiveValuesThrows(from: properties)

return FunctionDeclSyntax(
leadingTrivia: .newline,
modifiers: DeclModifierListSyntax(modifiers),
name: .identifier("rederiveValues"),
signature: .init(
parameterClause: FunctionParameterClauseSyntax {},
effectSpecifiers: isThrowing ? .init(throwsClause: .init(throwsSpecifier: .keyword(.throws))) : nil
)
) {
for item in derivedPropertyAssignments(from: properties, derivedFromDefault: derivedFromDefault) {
item
}
}
}

/// Generate the `func encode(to encoder: Encoder)` method of the `Codable` protocol.
fileprivate static func genEncodeFuncDecl(
from properties: [Property],
Expand Down
9 changes: 8 additions & 1 deletion Sources/CodableKitMacros/CodableProperty+Derived.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ extension CodableProperty {
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.
/// literal. `nil` when the argument is omitted, empty, or not a simple string literal.
var derivedFromPropertyName: String? {
guard
let expr = derivedKeyAttribute?.arguments?
Expand All @@ -35,6 +35,13 @@ extension CodableProperty {
return text.isEmpty ? nil : text
}

/// Whether `@DerivedKey` explicitly provides a `from:` argument, even if invalid.
var hasExplicitDerivedFromArgument: Bool {
derivedKeyAttribute?.arguments?
.as(LabeledExprListSyntax.self)?
.getExpr(label: "from") != nil
}

/// The transformer expression provided via `@DerivedKey(transformer:)`.
var derivedTransformerExpr: ExprSyntax? {
derivedKeyAttribute?.arguments?
Expand Down
Loading
Loading