diff --git a/README.md b/README.md index 0a2174d..18d478d 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Main features include: - Customize nested containers during Coding using `@CodingContainer` - Support specified `CodingKey` for Encode, like `EncodingKey("encode_key")` - Allow using default values when decoding fails to avoid `keyNotFound` errors -- Allow using `@CodingIgnored` to ignore specific properties during encoding/decoding +- Allow using `@CodingIgnored`, `@EncodingIgnored`, and `@DecodingIgnored` to ignore specific properties during coding - Support automatic conversion between base64 strings and `Data` `[UInt8]` types using `@Base64Coding` - Through `@CompactDecoding`, ignore `null` values when Decoding `Array`, `Dictionary`, `Set` instead of throwing errors - Support various encoding/decoding of `Date` through `@DateCoding` @@ -333,15 +333,21 @@ struct Preferences { ### 8. Ignore Properties -Use `@CodingIgnored` to ignore specific properties during encoding/decoding. During decoding, non-`Optional` properties must have a default value to satisfy Swift initialization requirements. `ReerCodable` automatically generates default values for basic data types and collection types. For other custom types, users need to provide default values. +Use `@CodingIgnored` to ignore properties during both encoding and decoding, `@EncodingIgnored` to ignore properties only during encoding, and `@DecodingIgnored` to ignore properties only during decoding. When decoding is ignored, non-`Optional` properties must have a default value to satisfy Swift initialization requirements. `ReerCodable` automatically generates default values for basic data types and collection types. For other custom types, users need to provide default values. ```swift @Codable struct User { var name: String - + @CodingIgnored - var ignore: Set + var transient: Set + + @EncodingIgnored + var serverToken: String + + @DecodingIgnored + var localDraft: String = "draft" } ``` diff --git a/README_CN.md b/README_CN.md index 31221e2..f9077b2 100644 --- a/README_CN.md +++ b/README_CN.md @@ -35,7 +35,7 @@ ReerCodable 框架提供了一系列自定义宏,用于生成动态的 Codable - 通过使用 `@CodingContainer` 自定义 Coding 时的嵌套容器 - 支持 Encode 时指定的 `CodingKey`, 如 `EncodingKey("encode_key")` - 允许解码失败时使用默认值, 从而避免 `keyNotFound` 错误发生 -- 允许使用 `@CodingIgnored` 在编解码过程中忽略特定属性 +- 允许使用 `@CodingIgnored`、`@EncodingIgnored`、`@DecodingIgnored` 在编解码过程中按需忽略特定属性 - 支持使用 `@Base64Coding` 自动对 base64 字符串和 `Data` `[UInt8]` 类型进行转换 - 在 Decode `Array`, `Dictionary`, `Set` 时, 通过 `@CompactDecoding` 可以忽略 `null` 值, 而不是抛出错误 - 支持通过 `@DateCoding` 实现对 `Date` 的各种编解码 @@ -327,15 +327,21 @@ struct Preferences { ### 8. 忽略属性 -使用 `@CodingIgnored` 在编解码过程中忽略特定属性. 在解码过程中对于非 `Optional` 属性要有一个默认值才能满足 Swift 初始化的要求, `ReerCodable` 对基本数据类型和集合类型会自动生成默认值, 如果是其他自定义类型, 则需用用户提供默认值. +使用 `@CodingIgnored` 可以同时在编码和解码时忽略属性, `@EncodingIgnored` 只在编码时忽略, `@DecodingIgnored` 只在解码时忽略. 当属性会被解码侧忽略时, 对于非 `Optional` 属性要有一个默认值才能满足 Swift 初始化的要求, `ReerCodable` 对基本数据类型和集合类型会自动生成默认值, 如果是其他自定义类型, 则需由用户提供默认值. ```swift @Codable struct User { var name: String - + @CodingIgnored - var ignore: Set + var transient: Set + + @EncodingIgnored + var serverToken: String + + @DecodingIgnored + var localDraft: String = "draft" } ``` diff --git a/Sources/ReerCodable/MacroDeclarations/CodingIgnored.swift b/Sources/ReerCodable/MacroDeclarations/CodingIgnored.swift index 4d87a85..fd2cb42 100644 --- a/Sources/ReerCodable/MacroDeclarations/CodingIgnored.swift +++ b/Sources/ReerCodable/MacroDeclarations/CodingIgnored.swift @@ -38,3 +38,20 @@ /// ``` @attached(peer) public macro CodingIgnored() = #externalMacro(module: "ReerCodableMacros", type: "CodingIgnored") + +/// The `@EncodingIgnored` macro marks a property to be ignored during encoding only. +/// +/// When applied to a property in a type marked with `@Codable`, this property will be: +/// - Skipped during encoding (not written to the encoded data) +/// - Still participate in decoding as usual +@attached(peer) +public macro EncodingIgnored() = #externalMacro(module: "ReerCodableMacros", type: "EncodingIgnored") + +/// The `@DecodingIgnored` macro marks a property to be ignored during decoding only. +/// +/// When applied to a property in a type marked with `@Codable`, this property will be: +/// - Skipped during decoding (not read from the encoded data) +/// - Still participate in encoding as usual +/// - Initialized with its default value or nil if optional +@attached(peer) +public macro DecodingIgnored() = #externalMacro(module: "ReerCodableMacros", type: "DecodingIgnored") diff --git a/Sources/ReerCodableMacros/MacroImplementations/CustomCodingImpl.swift b/Sources/ReerCodableMacros/MacroImplementations/CustomCodingImpl.swift index a0aea4f..667e838 100644 --- a/Sources/ReerCodableMacros/MacroImplementations/CustomCodingImpl.swift +++ b/Sources/ReerCodableMacros/MacroImplementations/CustomCodingImpl.swift @@ -34,6 +34,8 @@ public struct CustomCoding: PeerMacro { if variable.attributes.count > 1 { let incompatibleMacros = [ "CodingIgnored", + "EncodingIgnored", + "DecodingIgnored", "Base64Coding", "DateCoding", "CompactDecoding", diff --git a/Sources/ReerCodableMacros/MacroImplementations/IgnoreCodingImpl.swift b/Sources/ReerCodableMacros/MacroImplementations/IgnoreCodingImpl.swift index 94e194e..ec5cac2 100644 --- a/Sources/ReerCodableMacros/MacroImplementations/IgnoreCodingImpl.swift +++ b/Sources/ReerCodableMacros/MacroImplementations/IgnoreCodingImpl.swift @@ -22,47 +22,109 @@ import SwiftSyntax import SwiftSyntaxMacros +private enum IgnoreCodingMode: Equatable { + case both + case encoding + case decoding + + var macroName: String { + switch self { + case .both: + return "CodingIgnored" + case .encoding: + return "EncodingIgnored" + case .decoding: + return "DecodingIgnored" + } + } + + var diagnosticPrefix: String { + "@\(macroName) macro" + } +} + +private func validateIgnoredProperty( + declaration: some DeclSyntaxProtocol, + mode: IgnoreCodingMode +) throws { + guard + let variable = declaration.as(VariableDeclSyntax.self), + let name = variable.name + else { + throw MacroError(text: "\(mode.diagnosticPrefix) is only for property.") + } + + let ignoreMacros = ["CodingIgnored", "EncodingIgnored", "DecodingIgnored"] + let usedIgnoreMacros = ignoreMacros.filter { variable.attributes.containsAttribute(named: $0) } + if usedIgnoreMacros.count > 1 { + throw MacroError( + text: "\(mode.diagnosticPrefix) cannot be used together with @\(usedIgnoreMacros.filter { $0 != mode.macroName }.joined(separator: ", @"))." + ) + } + + if mode == .encoding, variable.attributes.containsAttribute(named: "EncodingKey") { + throw MacroError(text: "@EncodingIgnored macro cannot be used together with @EncodingKey.") + } + + if mode != .encoding { + if variable.isOptional { + return + } + if variable.initExpr != nil { + return + } + if let type = variable.type, + canGenerateDefaultValue(for: type) { + return + } + throw MacroError(text: "The ignored property `\(name)` should have a default value, or be set as an optional type.") + } +} + public struct CodingIgnored: PeerMacro { public static func expansion( of node: AttributeSyntax, providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { - guard - let variable = declaration.as(VariableDeclSyntax.self), - let name = variable.name - else { - throw MacroError(text: "@CodingIgnored macro is only for property.") - } - - if variable.attributes.firstAttribute(named: "CodingIgnored") != nil { - if variable.isOptional { - return [] - } - if variable.initExpr != nil { - return [] - } - if let type = variable.type, - canGenerateDefaultValue(for: type) { - return [] - } - throw MacroError(text: "The ignored property `\(name)` should have a default value, or be set as an optional type.") - } + try validateIgnoredProperty(declaration: declaration, mode: .both) return [] } - - static func canGenerateDefaultValue(for type: String) -> Bool { - let trimmed = type.trimmingCharacters(in: .whitespaces) - let basicType = [ - "Int", "Int8", "Int16", "Int32", "Int64", "Int128", - "UInt", "UInt8", "UInt16", "UInt32", "UInt64", "UInt128", - "Bool", "String", "Float", "Double" - ].contains(trimmed) - if basicType - || (trimmed.hasPrefix("[") && trimmed.hasSuffix("]")) - || trimmed.hasPrefix("Set<") { - return true - } - return false +} + +public struct EncodingIgnored: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + try validateIgnoredProperty(declaration: declaration, mode: .encoding) + return [] + } +} + +public struct DecodingIgnored: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + try validateIgnoredProperty(declaration: declaration, mode: .decoding) + return [] + } +} + +func canGenerateDefaultValue(for type: String) -> Bool { + let trimmed = type.trimmingCharacters(in: .whitespaces) + let basicType = [ + "Int", "Int8", "Int16", "Int32", "Int64", "Int128", + "UInt", "UInt8", "UInt16", "UInt32", "UInt64", "UInt128", + "Bool", "String", "Float", "Double" + ].contains(trimmed) + if basicType + || (trimmed.hasPrefix("[") && trimmed.hasSuffix("]")) + || trimmed.hasPrefix("Set<") { + return true } + return false } diff --git a/Sources/ReerCodableMacros/Plugins.swift b/Sources/ReerCodableMacros/Plugins.swift index 3d38cdd..968dd9f 100644 --- a/Sources/ReerCodableMacros/Plugins.swift +++ b/Sources/ReerCodableMacros/Plugins.swift @@ -54,6 +54,8 @@ struct ReerCodablePlugin: CompilerPlugin { CodingKey.self, EncodingKey.self, CodingIgnored.self, + EncodingIgnored.self, + DecodingIgnored.self, DecodingDefault.self, EncodingDefault.self, CodingDefault.self, diff --git a/Sources/ReerCodableMacros/PropertyInfo.swift b/Sources/ReerCodableMacros/PropertyInfo.swift index 2078518..f891684 100644 --- a/Sources/ReerCodableMacros/PropertyInfo.swift +++ b/Sources/ReerCodableMacros/PropertyInfo.swift @@ -27,7 +27,8 @@ struct PropertyInfo { var name: String var type: String var isOptional = false - var isIgnored = false + var ignoreEncoding = false + var ignoreDecoding = false var keys: [String] = [] var encodingKey: String? var treatDotAsNestedWhenEncoding: Bool = true diff --git a/Sources/ReerCodableMacros/TypeInfo.swift b/Sources/ReerCodableMacros/TypeInfo.swift index 6cc8325..c902a4f 100644 --- a/Sources/ReerCodableMacros/TypeInfo.swift +++ b/Sources/ReerCodableMacros/TypeInfo.swift @@ -336,7 +336,14 @@ extension TypeInfo { property.caseStyles = propertyCaseStyles.uniqueMerged(with: caseStyles) // ignore coding if variable.attributes.firstAttribute(named: "CodingIgnored") != nil { - property.isIgnored = true + property.ignoreEncoding = true + property.ignoreDecoding = true + } + if variable.attributes.firstAttribute(named: "EncodingIgnored") != nil { + property.ignoreEncoding = true + } + if variable.attributes.firstAttribute(named: "DecodingIgnored") != nil { + property.ignoreDecoding = true } // base64 coding if variable.attributes.containsAttribute(named: "Base64Coding") { @@ -681,7 +688,7 @@ extension TypeInfo { } else { assignments = try properties .compactMap { property in - if property.isIgnored { + if property.ignoreDecoding { if property.isOptional { return nil } if let initExpr = property.initExpr { return "self.\(property.name) = \(initExpr)" @@ -824,7 +831,7 @@ extension TypeInfo { } else { encoding = properties .compactMap { property in - if property.isIgnored { return nil } + if property.ignoreEncoding { return nil } let valueExpr = property.encodingValueExpr let resolvedValueExpr = property.resolvedEncodingValueExpr let needsOptionalHandling = property.needsOptionalEncodingHandling @@ -914,7 +921,7 @@ extension TypeInfo { text += ": \(property.type)" if let initExpr = property.initExpr { text += "= \(initExpr)" - } else if property.isIgnored, let defaultValue = property.defaultValue { + } else if property.ignoreDecoding, let defaultValue = property.defaultValue { text += "= \(defaultValue)" } else if property.isOptional { text += "= nil" diff --git a/Tests/ReerCodableTests/MacroExpandTests.swift b/Tests/ReerCodableTests/MacroExpandTests.swift index 3eb837d..1a8a797 100644 --- a/Tests/ReerCodableTests/MacroExpandTests.swift +++ b/Tests/ReerCodableTests/MacroExpandTests.swift @@ -16,6 +16,8 @@ let testMacros: [String: Macro.Type] = [ "CodingKey": CodingKey.self, "EncodingKey": EncodingKey.self, "CodingIgnored": CodingIgnored.self, + "EncodingIgnored": EncodingIgnored.self, + "DecodingIgnored": DecodingIgnored.self, "Base64Coding": Base64Coding.self, "DateCoding": DateCoding.self, "CompactDecoding": CompactDecoding.self, @@ -655,4 +657,151 @@ final class ReerCodableTests: XCTestCase { throw XCTSkip("macros are only supported when running tests for the host platform") #endif } + + func testSingleSideIgnoredMacros() throws { + #if canImport(ReerCodableMacros) + assertMacroExpansion( + """ + @Codable + struct User { + var id: Int + + @EncodingIgnored + var serverToken: String + + @DecodingIgnored + var localNote: String = "draft" + } + """, + expandedSource: """ + struct User { + var id: Int + var serverToken: String + var localNote: String = "draft" + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: AnyCodingKey.self) + self.id = try container.decode(Int.self, forKey: AnyCodingKey("id", false)) + self.serverToken = try container.decode(String.self, forKey: AnyCodingKey("serverToken", false)) + self.localNote = "draft" + try self.didDecode(from: decoder) + } + + func encode(to encoder: any Encoder) throws { + try self.willEncode(to: encoder) + var container = encoder.container(keyedBy: AnyCodingKey.self) + try container.encode(value: self.id, key: AnyCodingKey("id", false), treatDotAsNested: true) + try container.encode(value: self.localNote, key: AnyCodingKey("localNote", false), treatDotAsNested: true) + } + + init( + id: Int, + serverToken: String, + localNote: String = "draft" + ) { + self.id = id + self.serverToken = serverToken + self.localNote = localNote + } + } + + extension User: Codable, ReerCodableDelegate { + } + """, + macros: testMacros, + indentationWidth: .spaces(4) + ) + assertMacroExpansion( + """ + @Codable + struct User { + @EncodingIgnored + @EncodingKey("token") + var token: String + } + """, + expandedSource: """ + struct User { + var token: String + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: AnyCodingKey.self) + self.token = try container.decode(String.self, forKey: AnyCodingKey("token", false)) + try self.didDecode(from: decoder) + } + + func encode(to encoder: any Encoder) throws { + try self.willEncode(to: encoder) + var container = encoder.container(keyedBy: AnyCodingKey.self) + + } + + init( + token: String + ) { + self.token = token + } + } + + extension User: Codable, ReerCodableDelegate { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@EncodingIgnored macro cannot be used together with @EncodingKey.", + line: 3, + column: 5 + ) + ], + macros: testMacros, + indentationWidth: .spaces(4) + ) + assertMacroExpansion( + """ + @Codable + struct User { + @DecodingIgnored + var token: URL + } + """, + expandedSource: """ + struct User { + var token: URL + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: AnyCodingKey.self) + self.token = URL(string: "/")! + try self.didDecode(from: decoder) + } + + func encode(to encoder: any Encoder) throws { + try self.willEncode(to: encoder) + var container = encoder.container(keyedBy: AnyCodingKey.self) + try container.encode(value: self.token, key: AnyCodingKey("token", false), treatDotAsNested: true) + } + + init( + token: URL = URL(string: "/")! + ) { + self.token = token + } + } + + extension User: Codable, ReerCodableDelegate { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "The ignored property `token` should have a default value, or be set as an optional type.", + line: 3, + column: 5 + ) + ], + macros: testMacros, + indentationWidth: .spaces(4) + ) + #else + throw XCTSkip("macros are only supported when running tests for the host platform") + #endif + } } diff --git a/Tests/ReerCodableTests/PropertyListCodingTests.swift b/Tests/ReerCodableTests/PropertyListCodingTests.swift index c614194..1be7fae 100644 --- a/Tests/ReerCodableTests/PropertyListCodingTests.swift +++ b/Tests/ReerCodableTests/PropertyListCodingTests.swift @@ -21,6 +21,17 @@ struct PlistPerson { var ignored: String = "should be ignored" } +@Codable +struct PlistSingleSideIgnoredPerson { + var name: String + + @EncodingIgnored + var serverToken: String + + @DecodingIgnored + var localNote: String = "local default" +} + // Model with @Base64Coding @Codable struct PlistDataModel { @@ -195,6 +206,33 @@ struct PropertyListCodingTests { #expect(encodedDict?.double("score") == 95.5) #expect(encodedDict?["ignored"] == nil) // Should be ignored } + + @Test + func singleSideIgnored() throws { + let dict: [String: Any] = [ + "name": "Phoenix", + "serverToken": "plist-token", + "localNote": "remote-note" + ] + let plistData = try PropertyListSerialization.data(fromPropertyList: dict, format: .binary, options: 0) + + let model = try PropertyListDecoder().decode(PlistSingleSideIgnoredPerson.self, from: plistData) + #expect(model.name == "Phoenix") + #expect(model.serverToken == "plist-token") + #expect(model.localNote == "local default") + + let encodedData = try PropertyListEncoder().encode( + PlistSingleSideIgnoredPerson( + name: model.name, + serverToken: model.serverToken, + localNote: "client-note" + ) + ) + let encodedDict = try PropertyListSerialization.propertyList(from: encodedData, format: nil) as? [String: Any] + #expect(encodedDict?.string("name") == "Phoenix") + #expect(encodedDict?.string("localNote") == "client-note") + #expect(encodedDict?["serverToken"] == nil) + } @Test func base64Coding() throws { diff --git a/Tests/ReerCodableTests/ReerCodableTests.swift b/Tests/ReerCodableTests/ReerCodableTests.swift index 62af164..a128a96 100644 --- a/Tests/ReerCodableTests/ReerCodableTests.swift +++ b/Tests/ReerCodableTests/ReerCodableTests.swift @@ -121,6 +121,29 @@ let jsonData = """ } """.data(using: .utf8)! +@Codable +struct SingleSideIgnoredUser { + var id: Int + + @EncodingIgnored + var serverToken: String + + @DecodingIgnored + var localDraft: String = "draft" + + @CodingIgnored + var transient: [String] = [] +} + +let singleSideIgnoredJSON = """ +{ + "id": 7, + "serverToken": "token-from-server", + "localDraft": "server-draft", + "transient": ["server"] +} +""".data(using: .utf8)! + struct TestReerCodable { @@ -172,6 +195,29 @@ struct TestReerCodable { #expect(anyDict?.int("key1") == 1) #expect(anyDict?.string("key2") == "2") } + + @Test + func singleSideIgnored() throws { + let model = try JSONDecoder().decode(SingleSideIgnoredUser.self, from: singleSideIgnoredJSON) + #expect(model.id == 7) + #expect(model.serverToken == "token-from-server") + #expect(model.localDraft == "draft") + #expect(model.transient == []) + + let encoded = try JSONEncoder().encode( + SingleSideIgnoredUser( + id: model.id, + serverToken: model.serverToken, + localDraft: "client-draft", + transient: ["client"] + ) + ) + let dict = encoded.stringAnyDictionary + #expect(dict.int("id") == 7) + #expect(dict.string("localDraft") == "client-draft") + #expect(dict?["serverToken"] == nil) + #expect(dict?["transient"] == nil) + } } // MARK: - Recursive