diff --git a/README.md b/README.md index 8ccaa1e..9ea0b6a 100644 --- a/README.md +++ b/README.md @@ -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 | ## Targets @@ -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().liftOptional()) ) @@ -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 diff --git a/Sources/CodableKit/CodableKit.swift b/Sources/CodableKit/CodableKit.swift index 18793c2..90f1790 100644 --- a/Sources/CodableKit/CodableKit.swift +++ b/Sources/CodableKit/CodableKit.swift @@ -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. @@ -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. @@ -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? /// } /// ``` @@ -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 @@ -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") diff --git a/Sources/CodableKitMacros/CodableMacro.swift b/Sources/CodableKitMacros/CodableMacro.swift index 097bc17..f380c13 100644 --- a/Sources/CodableKitMacros/CodableMacro.swift +++ b/Sources/CodableKitMacros/CodableMacro.swift @@ -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. @@ -135,6 +136,7 @@ public struct CodableMacro: ExtensionMacro { codableOptions: codableOptions, hasSuper: false, tree: usingTree, + derivedFromDefault: derivedFromDefault, hooks: hooks ) ) @@ -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. @@ -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 + ) + ) + ) + } + + switch structureType { + case .classType, .structType: if codableType.contains(.encodable) { result.append( DeclSyntax( @@ -283,6 +309,7 @@ extension CodableMacro { codableOptions: CodableOptions, hasSuper: Bool, tree: NamespaceNode, + derivedFromDefault: String?, hooks: HooksPresence ) -> InitializerDeclSyntax { InitializerDeclSyntax( @@ -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 } @@ -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 = @@ -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], diff --git a/Sources/CodableKitMacros/CodableProperty+Derived.swift b/Sources/CodableKitMacros/CodableProperty+Derived.swift index 1e003e2..b14f63a 100644 --- a/Sources/CodableKitMacros/CodableProperty+Derived.swift +++ b/Sources/CodableKitMacros/CodableProperty+Derived.swift @@ -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? @@ -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? diff --git a/Sources/CodableKitMacros/CodeGenCore.swift b/Sources/CodableKitMacros/CodeGenCore.swift index 58d736b..0fde811 100644 --- a/Sources/CodableKitMacros/CodeGenCore.swift +++ b/Sources/CodableKitMacros/CodeGenCore.swift @@ -32,6 +32,7 @@ internal final class CodeGenCore: @unchecked Sendable { private var structureTypes: [MacroContextKey: StructureType] = [:] private var codableTypes: [MacroContextKey: CodableType] = [:] private var codableOptions: [MacroContextKey: CodableOptions] = [:] + private var derivedFromDefaults: [MacroContextKey: String?] = [:] private var hooksPresence: [MacroContextKey: HooksPresence] = [:] func key(for declaration: some SyntaxProtocol, in context: some MacroExpansionContext) -> MacroContextKey { @@ -246,6 +247,13 @@ extension CodeGenCore { ) } + func accessDerivedFromDefault( + for declaration: some SyntaxProtocol, + in context: some MacroExpansionContext + ) -> String? { + derivedFromDefaults[key(for: declaration, in: context)] ?? nil + } + func accessHooksPresence( for declaration: some SyntaxProtocol, in context: some MacroExpansionContext @@ -268,6 +276,46 @@ extension CodeGenCore { return inherited.contains("NSObject") } + fileprivate static func stringLiteralArgument( + named label: String, + in node: AttributeSyntax + ) -> String? { + guard + let expr = node.arguments? + .as(LabeledExprListSyntax.self)? + .getExpr(label: label)? + .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 + } + + fileprivate static func hasArgument( + named label: String, + in node: AttributeSyntax + ) -> Bool { + node.arguments? + .as(LabeledExprListSyntax.self)? + .getExpr(label: label) != nil + } + + fileprivate static func hasInstanceMethod( + named name: String, + parameterCount: Int, + in declaration: some DeclGroupSyntax + ) -> Bool { + declaration.memberBlock.members.contains { member in + guard let functionDecl = member.decl.as(FunctionDeclSyntax.self) else { return false } + guard functionDecl.name.text == name else { return false } + guard functionDecl.signature.parameterClause.parameters.count == parameterCount else { return false } + return !functionDecl.modifiers.contains { $0.name.text == "static" || $0.name.text == "class" } + } + } + /// Validate that the macro is being applied to a struct declaration fileprivate func validateDeclaration( for declaration: some DeclGroupSyntax, @@ -331,9 +379,26 @@ extension CodeGenCore { .as(LabeledExprListSyntax.self)? .first(where: { $0.label?.text == "options" })? .parseCodableOptions() ?? .default + let derivedFromDefault = Self.stringLiteralArgument(named: "derivedFrom", in: node) codableTypes[id] = options.contains(.skipProtocolConformance) ? codableType.union(macroCodableType) : codableType codableOptions[id] = options + derivedFromDefaults[id] = derivedFromDefault + + if Self.hasArgument(named: "derivedFrom", in: node), + derivedFromDefault == nil + { + if emitAdvisories { + context.diagnose( + makeDiagnostic( + node: node, + message: "\(node.attributeName.trimmedDescription) requires 'derivedFrom:' to be a non-empty string literal", + severity: .error + ) + ) + } + throw DiagnosticAlreadyEmitted() + } try validateDeclaration(for: declaration, in: context) @@ -467,12 +532,23 @@ extension CodeGenCore { "@DerivedKey is decode-only and cannot be used in an @Encodable-only type; use @Codable or @Decodable") } - guard let derivedFromName = property.derivedFromPropertyName else { + if property.isConstant { + throw derivedError("@DerivedKey cannot be applied to a 'let' property; use 'var' so generated rederiveValues() can update it") + } + + if property.hasExplicitDerivedFromArgument, property.derivedFromPropertyName == nil { throw derivedError( "@DerivedKey requires a 'from:' argument that is a non-empty string literal naming a sibling stored property" ) } + let derivedFromName = property.derivedFromPropertyName ?? derivedFromDefault + guard let derivedFromName else { + throw derivedError( + "@DerivedKey requires a 'from:' argument or a non-empty top-level 'derivedFrom:' default naming a sibling stored property" + ) + } + guard let sourceProperty = extractedProperties.first(where: { $0.name.trimmedDescription == derivedFromName }) else { throw derivedError( @@ -486,7 +562,7 @@ extension CodeGenCore { ) } - if sourceProperty.ignored { + if sourceProperty.generateProperty(for: .decodable).ignored { throw derivedError( "@DerivedKey source property '\(derivedFromName)' is excluded from decoding (.ignored); derived properties may only depend on decoded properties" ) @@ -497,6 +573,22 @@ extension CodeGenCore { } } + if macroCodableType.contains(.decodable), + extractedProperties.contains(where: \.isDerived), + Self.hasInstanceMethod(named: "rederiveValues", parameterCount: 0, in: declaration) + { + if emitAdvisories { + context.diagnose( + makeDiagnostic( + node: declaration, + message: "Types with @DerivedKey properties cannot declare rederiveValues(); it is generated by the macro", + severity: .error + ) + ) + } + throw DiagnosticAlreadyEmitted() + } + 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 index d39fd22..ad62878 100644 --- a/Sources/CodableKitMacros/DerivedKeyMacro.swift +++ b/Sources/CodableKitMacros/DerivedKeyMacro.swift @@ -25,20 +25,6 @@ public struct DerivedKeyMacro: PeerMacro { // 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/Tests/CodableKitTests/CodableMacroTests+derived.runtime.swift b/Tests/CodableKitTests/CodableMacroTests+derived.runtime.swift index a8c99c6..dc93863 100644 --- a/Tests/CodableKitTests/CodableMacroTests+derived.runtime.swift +++ b/Tests/CodableKitTests/CodableMacroTests+derived.runtime.swift @@ -53,18 +53,28 @@ private struct DerivedStrictCountTransformer: CodingTransformer { } } -@Codable +@Codable(derivedFrom: "userConfigValue") struct DerivedConfigModel: Equatable { var userConfigValue: [String: String]? - @DerivedKey(from: "userConfigValue", transformer: DerivedFrameSlotTransformer()) + @DerivedKey(transformer: DerivedFrameSlotTransformer()) private(set) var avatarFrame: DerivedFrame? } -@Codable +@Codable(derivedFrom: "tags") struct DerivedCountModel: Equatable { var tags: [String] = [] - @DerivedKey(from: "tags", transformer: DerivedTagCountTransformer()) + @DerivedKey(transformer: DerivedTagCountTransformer()) + private(set) var tagCount: Int = -1 +} + +@Codable(derivedFrom: "tags") +struct DerivedOverrideCountModel: Equatable { + var tags: [String] = [] + var fallbackTags: [String] = [] + @DerivedKey(transformer: DerivedTagCountTransformer()) private(set) var tagCount: Int = -1 + @DerivedKey(from: "fallbackTags", transformer: DerivedTagCountTransformer()) + private(set) var fallbackTagCount: Int = -1 } @Codable @@ -89,9 +99,18 @@ struct DerivedHookModel { } @Codable +struct DerivedEncodableIgnoredSourceModel: Equatable { + var name: String = "" + @EncodableKey(options: .ignored) + var tags: [String] = [] + @DerivedKey(from: "tags", transformer: DerivedTagCountTransformer()) + private(set) var tagCount: Int = -1 +} + +@Codable(derivedFrom: "userConfigValue") class DerivedConfigClass { var userConfigValue: [String: String]? - @DerivedKey(from: "userConfigValue", transformer: DerivedFrameSlotTransformer()) + @DerivedKey(transformer: DerivedFrameSlotTransformer()) private(set) var avatarFrame: DerivedFrame? } @@ -168,6 +187,20 @@ class DerivedChildClass: DerivedBaseClass { #expect(decoded.tagCount == 3) } + @Test func derivedValue_structRederiveValues_updatesAfterSourceMutation() throws { + let json = #"{"tags":["a","b"],"fallbackTags":["x"]}"# + let data = json.data(using: .utf8)! + var decoded = try JSONDecoder().decode(DerivedOverrideCountModel.self, from: data) + #expect(decoded.tagCount == 2) + #expect(decoded.fallbackTagCount == 1) + + decoded.tags = ["a", "b", "c", "d"] + decoded.fallbackTags = [] + decoded.rederiveValues() + #expect(decoded.tagCount == 4) + #expect(decoded.fallbackTagCount == 0) + } + @Test func derivedValue_nonOptionalNoDefault_pipelineFailure_throwsFromDecode() throws { let json = #"{"tags":[]}"# let data = json.data(using: .utf8)! @@ -190,6 +223,21 @@ class DerivedChildClass: DerivedBaseClass { #expect(decoded.tagCount == 3) #expect(decoded.observedCount == 3) } + + @Test func derivedValue_encodableIgnoredSource_stillDerivesFromDecodedProperty() throws { + let json = #"{"name":"box","tags":["a","b","c"]}"# + let data = json.data(using: .utf8)! + let decoded = try JSONDecoder().decode(DerivedEncodableIgnoredSourceModel.self, from: data) + #expect(decoded.name == "box") + #expect(decoded.tags == ["a", "b", "c"]) + #expect(decoded.tagCount == 3) + + let encodedData = try JSONEncoder().encode(decoded) + let encodedString = String(data: encodedData, encoding: .utf8)! + #expect(encodedString.contains("name")) + #expect(!encodedString.contains("tags")) + #expect(!encodedString.contains("tagCount")) + } } @Suite struct DerivedKeyClassRuntimeTests { @@ -200,6 +248,16 @@ class DerivedChildClass: DerivedBaseClass { #expect(decoded.avatarFrame == DerivedFrame(id: 11)) } + @Test func derivedValue_classRederiveValues_updatesAfterSourceMutation() throws { + let json = #"{"userConfigValue":{"frame":"{\"id\":11}"}}"# + let data = json.data(using: .utf8)! + let decoded = try JSONDecoder().decode(DerivedConfigClass.self, from: data) + + decoded.userConfigValue = ["frame": #"{"id":12}"#] + decoded.rederiveValues() + #expect(decoded.avatarFrame == DerivedFrame(id: 12)) + } + @Test func derivedValue_classDidDecodeHook_observesDerivedValue() throws { let json = #"{"tags":["a","b"]}"# let data = json.data(using: .utf8)! diff --git a/Tests/CodableKitTests/CodableMacroTests+derived.swift b/Tests/CodableKitTests/CodableMacroTests+derived.swift index 16c8a8c..45991f0 100644 --- a/Tests/CodableKitTests/CodableMacroTests+derived.swift +++ b/Tests/CodableKitTests/CodableMacroTests+derived.swift @@ -27,6 +27,10 @@ import Testing public var userConfigValue: [String: String]? public private(set) var avatarFrame: AvatarFrame? + public mutating func rederiveValues() { + 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) @@ -48,6 +52,56 @@ import Testing ) } + @Test func defaultDerivedFrom_omittedPropertySource_andExplicitOverride() throws { + assertMacro( + """ + @Codable(derivedFrom: "userConfigValue") + public struct UserCommonConfigInfo { + public var userConfigValue: [String: String]? + public var fallbackConfigValue: [String: String]? + @DerivedKey(transformer: FrameTransformer()) + public private(set) var avatarFrame: AvatarFrame? + @DerivedKey(from: "fallbackConfigValue", transformer: BadgeTransformer()) + public private(set) var badge: Badge? + } + """, + expandedSource: """ + public struct UserCommonConfigInfo { + public var userConfigValue: [String: String]? + public var fallbackConfigValue: [String: String]? + public private(set) var avatarFrame: AvatarFrame? + public private(set) var badge: Badge? + + public mutating func rederiveValues() { + avatarFrame = (try? __ckDecodeDerived(transformer: FrameTransformer(), from: userConfigValue)) ?? nil + badge = (try? __ckDecodeDerived(transformer: BadgeTransformer(), from: fallbackConfigValue)) ?? nil + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(userConfigValue, forKey: .userConfigValue) + try container.encodeIfPresent(fallbackConfigValue, forKey: .fallbackConfigValue) + } + } + + extension UserCommonConfigInfo: Codable { + enum CodingKeys: String, CodingKey { + case userConfigValue + case fallbackConfigValue + } + + 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 + fallbackConfigValue = try container.decodeIfPresent([String: String]?.self, forKey: .fallbackConfigValue) ?? nil + avatarFrame = (try? __ckDecodeDerived(transformer: FrameTransformer(), from: userConfigValue)) ?? nil + badge = (try? __ckDecodeDerived(transformer: BadgeTransformer(), from: fallbackConfigValue)) ?? nil + } + } + """ + ) + } + @Test func derivedProperty_nonOptionalWithDefault_fallsBackToDefault() throws { assertMacro( """ @@ -63,6 +117,10 @@ import Testing var tags: [String] = [] var tagCount: Int = 0 + public mutating func rederiveValues() { + tagCount = (try? __ckDecodeDerived(transformer: CountTransformer(), from: tags)) ?? 0 + } + public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(tags, forKey: .tags) @@ -99,6 +157,10 @@ import Testing var tags: [String] = [] var tagCount: Int + public mutating func rederiveValues() throws { + tagCount = try __ckDecodeDerived(transformer: CountTransformer(), from: tags) + } + public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(tags, forKey: .tags) @@ -144,6 +206,11 @@ import Testing @CodableHook(.didDecode) mutating func didDecode() throws {} + public mutating func rederiveValues() { + avatarFrame = (try? __ckDecodeDerived(transformer: FrameTransformer(), from: userConfigValue)) ?? nil + badge = (try? __ckDecodeDerived(transformer: BadgeTransformer(), from: userConfigValue)) ?? nil + } + public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(userConfigValue, forKey: .userConfigValue) @@ -188,6 +255,10 @@ import Testing avatarFrame = (try? __ckDecodeDerived(transformer: FrameTransformer(), from: userConfigValue)) ?? nil } + public func rederiveValues() { + 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) @@ -225,6 +296,10 @@ import Testing try super.init(from: decoder) } + public func rederiveValues() { + avatarFrame = (try? __ckDecodeDerived(transformer: FrameTransformer(), from: userConfigValue)) ?? nil + } + public override func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(userConfigValue, forKey: .userConfigValue) @@ -408,42 +483,133 @@ import Testing ) } - @Test func derivedKeyOnLetWithInitializerIsAnError() throws { + @Test func derivedKeyWithoutSourceOrDefaultIsAnError() throws { assertMacro( """ @Codable public struct User { let id: Int - @DerivedKey(from: "id", transformer: StringifyTransformer()) - let display: String = "none" + @DerivedKey(transformer: StringifyTransformer()) + var display: String? } """, expandedSource: """ public struct User { let id: Int - let display: String = "none" + var display: String? + } + """, + diagnostics: [ + .init( + message: + "@DerivedKey requires a 'from:' argument or a non-empty top-level 'derivedFrom:' default naming a sibling stored property", + line: 4, + column: 3 + ) + ] + ) + } - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - } + @Test func invalidTopLevelDerivedFromIsAnError() throws { + assertMacro( + """ + @Codable(derivedFrom: sourceName) + public struct User { + let id: Int + } + """, + expandedSource: """ + public struct User { + let id: Int } + """, + diagnostics: [ + .init( + message: "Codable requires 'derivedFrom:' to be a non-empty string literal", + line: 1, + column: 1 + ) + ] + ) + } - extension User: Codable { - enum CodingKeys: String, CodingKey { - case id - } + @Test func derivedKeyDefaultSourcePropertyMustExist() throws { + assertMacro( + """ + @Codable(derivedFrom: "missing") + public struct User { + let id: Int + @DerivedKey(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 + ) + ] + ) + } - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(Int.self, forKey: .id) - } + @Test func declaringRederiveValuesWhenDerivedPropertiesExistIsAnError() throws { + assertMacro( + """ + @Codable + public struct User { + let id: Int + @DerivedKey(from: "id", transformer: StringifyTransformer()) + var display: String? + + func rederiveValues() {} + } + """, + expandedSource: """ + public struct User { + let id: Int + var display: String? + + func rederiveValues() {} + } + """, + diagnostics: [ + .init( + message: "Types with @DerivedKey properties cannot declare rederiveValues(); it is generated by the macro", + line: 1, + column: 1 + ) + ] + ) + } + + @Test func derivedKeyOnLetPropertyIsAnError() throws { + assertMacro( + """ + @Codable + public struct User { + let id: Int + @DerivedKey(from: "id", transformer: StringifyTransformer()) + let display: String? + } + """, + expandedSource: """ + public struct User { + let id: Int + let display: String? } """, diagnostics: [ .init( message: - "@DerivedKey cannot be applied to a 'let' property with an initializer; use 'var' or remove the initializer", + "@DerivedKey cannot be applied to a 'let' property; use 'var' so generated rederiveValues() can update it", line: 4, column: 3 ) diff --git a/Tests/DecodableKitTests/CodableMacroTests+derived.swift b/Tests/DecodableKitTests/CodableMacroTests+derived.swift index a88a796..c61decd 100644 --- a/Tests/DecodableKitTests/CodableMacroTests+derived.swift +++ b/Tests/DecodableKitTests/CodableMacroTests+derived.swift @@ -26,6 +26,10 @@ import Testing public struct UserCommonConfigInfo { public var userConfigValue: [String: String]? public private(set) var avatarFrame: AvatarFrame? + + public mutating func rederiveValues() { + avatarFrame = (try? __ckDecodeDerived(transformer: FrameTransformer(), from: userConfigValue)) ?? nil + } } extension UserCommonConfigInfo: Decodable { @@ -43,6 +47,50 @@ import Testing ) } + @Test func defaultDerivedFrom_omittedPropertySource_andExplicitOverride() throws { + assertMacro( + """ + @Decodable(derivedFrom: "userConfigValue") + public struct UserCommonConfigInfo { + public var userConfigValue: [String: String]? + public var fallbackConfigValue: [String: String]? + @DerivedKey(transformer: FrameTransformer()) + public private(set) var avatarFrame: AvatarFrame? + @DerivedKey(from: "fallbackConfigValue", transformer: BadgeTransformer()) + public private(set) var badge: Badge? + } + """, + expandedSource: """ + public struct UserCommonConfigInfo { + public var userConfigValue: [String: String]? + public var fallbackConfigValue: [String: String]? + public private(set) var avatarFrame: AvatarFrame? + public private(set) var badge: Badge? + + public mutating func rederiveValues() { + avatarFrame = (try? __ckDecodeDerived(transformer: FrameTransformer(), from: userConfigValue)) ?? nil + badge = (try? __ckDecodeDerived(transformer: BadgeTransformer(), from: fallbackConfigValue)) ?? nil + } + } + + extension UserCommonConfigInfo: Decodable { + enum CodingKeys: String, CodingKey { + case userConfigValue + case fallbackConfigValue + } + + 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 + fallbackConfigValue = try container.decodeIfPresent([String: String]?.self, forKey: .fallbackConfigValue) ?? nil + avatarFrame = (try? __ckDecodeDerived(transformer: FrameTransformer(), from: userConfigValue)) ?? nil + badge = (try? __ckDecodeDerived(transformer: BadgeTransformer(), from: fallbackConfigValue)) ?? nil + } + } + """ + ) + } + @Test func derivedProperty_nonOptionalWithoutDefault_propagatesErrors() throws { assertMacro( """ @@ -57,6 +105,10 @@ import Testing public struct TagBox { var tags: [String] = [] var tagCount: Int + + public mutating func rederiveValues() throws { + tagCount = try __ckDecodeDerived(transformer: CountTransformer(), from: tags) + } } extension TagBox: Decodable { @@ -91,6 +143,11 @@ import Testing public var userConfigValue: [String: String]? public private(set) var avatarFrame: AvatarFrame? public private(set) var badge: Badge? + + public mutating func rederiveValues() { + avatarFrame = (try? __ckDecodeDerived(transformer: FrameTransformer(), from: userConfigValue)) ?? nil + badge = (try? __ckDecodeDerived(transformer: BadgeTransformer(), from: userConfigValue)) ?? nil + } } extension UserCommonConfigInfo: Decodable {