From 9be4a66300c75bcfbe984388f122652107502fd6 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Mon, 24 Aug 2026 22:29:23 -0400 Subject: [PATCH 1/3] Generate public CodingKeys enum in @Entity macro --- Sources/CoreModelMacros/Entity.swift | 41 +++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/Sources/CoreModelMacros/Entity.swift b/Sources/CoreModelMacros/Entity.swift index 751ec6a..50ee8b0 100644 --- a/Sources/CoreModelMacros/Entity.swift +++ b/Sources/CoreModelMacros/Entity.swift @@ -18,6 +18,7 @@ public struct EntityMacro: MemberMacro, ExtensionMacro { public static var expansionNames: [String] { [ "entityName", + "CodingKeys", "attributes", "relationships", "init(from:)", @@ -63,13 +64,17 @@ public struct EntityMacro: MemberMacro, ExtensionMacro { let relationshipsDeclarationSyntax = try relationshipsDeclarationSyntax(of: node, providingMembersOf: declaration, in: context) let initDeclarationSyntax = try initDeclarationSyntax(of: node, providingMembersOf: declaration, in: context) let encodeDeclarationSyntax = try encodeDeclarationSyntax(of: node, providingMembersOf: declaration, in: context) - return [ + var declarations = [ entityNameDeclarationSyntax, attributesDeclarationSyntax, relationshipsDeclarationSyntax, initDeclarationSyntax, encodeDeclarationSyntax ] + if let codingKeysDeclarationSyntax = try codingKeysDeclarationSyntax(of: node, providingMembersOf: declaration, in: context) { + declarations.insert(codingKeysDeclarationSyntax, at: 1) + } + return declarations } } @@ -123,6 +128,40 @@ extension EntityMacro { return DeclSyntax(stringLiteral: entityNameDecl) } + /// Generates a `public enum CodingKeys: String, CodingKey` with a case for `id` + /// and each `@Attribute`/`@CompositeAttribute`/`@Relationship` property, in declaration order. + /// + /// Returns `nil` if the declaration already contains a `CodingKeys` member, + /// so manually written coding keys are left untouched. + public static func codingKeysDeclarationSyntax( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> DeclSyntax? { + // Skip generation if the type declares its own CodingKeys. + for member in declaration.memberBlock.members { + if let enumDecl = member.decl.as(EnumDeclSyntax.self), enumDecl.name.text == "CodingKeys" { + return nil + } + if let typealiasDecl = member.decl.as(TypeAliasDeclSyntax.self), typealiasDecl.name.text == "CodingKeys" { + return nil + } + } + var caseNames = ["id"] + for property in codableProperties(of: declaration) where caseNames.contains(property.name) == false { + caseNames.append(property.name) + } + let cases = caseNames + .map { " case \($0)" } + .joined(separator: "\n") + let codingKeysDecl = """ + public enum CodingKeys: String, CodingKey, Sendable, CaseIterable { + \(cases) + } + """ + return DeclSyntax(stringLiteral: codingKeysDecl) + } + public static func attributesDeclarationSyntax( of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, From 1c1051aaf59609762ff694769ecfcfef351d15de Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Mon, 24 Aug 2026 22:29:23 -0400 Subject: [PATCH 2/3] Add tests for generated CodingKeys expansion --- .../EntityMacroTests.swift | 58 +++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/Tests/CoreModelMacrosTests/EntityMacroTests.swift b/Tests/CoreModelMacrosTests/EntityMacroTests.swift index 81e2d8b..f251c62 100644 --- a/Tests/CoreModelMacrosTests/EntityMacroTests.swift +++ b/Tests/CoreModelMacrosTests/EntityMacroTests.swift @@ -35,9 +35,10 @@ import SwiftSyntaxMacroExpansion """) let context = BasicMacroExpansionContext() let members = try expandMembers(of: node, attachedTo: declaration, in: context) - #expect(members.count == 5) + #expect(members.count == 6) let source = members.map { $0.description }.joined(separator: "\n") #expect(source.contains(#"public static var entityName: EntityName { "Person" }"#)) + #expect(source.contains("public enum CodingKeys: String, CodingKey, Sendable, CaseIterable")) #expect(source.contains(".name: .string")) #expect(source.contains(".age: .int64")) #expect(source.contains(".created: .date")) @@ -299,7 +300,56 @@ import SwiftSyntaxMacroExpansion } @Test func expansionNames() { - #expect(EntityMacro.expansionNames.count == 5) + #expect(EntityMacro.expansionNames.count == 6) + } + + // MARK: - Coding Keys + + @Test func codingKeysExpansion() throws { + let (node, declaration) = try parse(""" + @Entity + struct Person { + var id: UUID + @Attribute + var name: String + @Relationship(destination: Pet.self, inverse: .owner) + var pets: [Pet.ID] + } + """) + let context = BasicMacroExpansionContext() + let expansion = try EntityMacro.codingKeysDeclarationSyntax(of: node, providingMembersOf: declaration, in: context) + let codingKeysDecl = try #require(expansion) + let source = codingKeysDecl.description + #expect(source.contains("public enum CodingKeys: String, CodingKey, Sendable, CaseIterable")) + // `id` first, then attributes and relationships in declaration order + let idIndex = try #require(source.range(of: "case id")?.lowerBound) + let nameIndex = try #require(source.range(of: "case name")?.lowerBound) + let petsIndex = try #require(source.range(of: "case pets")?.lowerBound) + #expect(idIndex < nameIndex) + #expect(nameIndex < petsIndex) + } + + /// A manually declared `CodingKeys` suppresses the generated one. + @Test func manualCodingKeysSkipsGeneration() throws { + let (node, declaration) = try parse(""" + @Entity + struct Person { + var id: UUID + @Attribute + var name: String + enum CodingKeys: String, CodingKey { + case id + case name = "personName" + } + } + """) + let context = BasicMacroExpansionContext() + let expansion = try EntityMacro.codingKeysDeclarationSyntax(of: node, providingMembersOf: declaration, in: context) + #expect(expansion == nil) + let members = try expandMembers(of: node, attachedTo: declaration, in: context) + #expect(members.count == 5) + let source = members.map { $0.description }.joined(separator: "\n") + #expect(source.contains("enum CodingKeys") == false) } // MARK: - Composite Attributes @@ -418,7 +468,7 @@ import SwiftSyntaxMacroExpansion } /// An entity mixing scalar attributes, required and optional composites, and a - /// relationship generates all five members consistently. + /// relationship generates all six members consistently. @Test func mixedEntityExpansion() throws { let (node, declaration) = try parse(""" @Entity("Campground") @@ -436,7 +486,7 @@ import SwiftSyntaxMacroExpansion """) let context = BasicMacroExpansionContext() let members = try expandMembers(of: node, attachedTo: declaration, in: context) - #expect(members.count == 5) + #expect(members.count == 6) let source = members.map { $0.description }.joined(separator: "\n") #expect(source.contains(#"public static var entityName: EntityName { "Campground" }"#)) // scalar and composite attributes live side by side From c6f4b47ad9b0b31d7df3cf0d0c09721766517fd3 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Mon, 24 Aug 2026 22:29:23 -0400 Subject: [PATCH 3/3] Use generated CodingKeys for Facility entity --- Tests/CoreModelTests/TestModel.swift | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/Tests/CoreModelTests/TestModel.swift b/Tests/CoreModelTests/TestModel.swift index 9c16177..d5720b4 100644 --- a/Tests/CoreModelTests/TestModel.swift +++ b/Tests/CoreModelTests/TestModel.swift @@ -32,7 +32,12 @@ struct Person: Equatable, Hashable, Codable, Identifiable { self.age = age self.events = events } - + + // - Note: Declared manually because `Event.@Relationship(inverse: .events)` + // must type-check against this enum while macros are still expanding; + // the compiler will not expand `@Entity`'s generated `CodingKeys` to + // satisfy another macro's argument. Entities that are not referenced as a + // relationship destination (e.g. `Facility`) can rely on the generated enum. enum CodingKeys: CodingKey { case id case name @@ -62,7 +67,8 @@ struct Event: Equatable, Hashable, Codable, Identifiable { self.date = date self.people = people } - + + // - Note: Declared manually; see `Person.CodingKeys`. enum CodingKeys: CodingKey { case id case name @@ -458,12 +464,4 @@ public struct Facility: Equatable, Hashable, Codable, Identifiable { self.address = address self.billingAddress = billingAddress } - - public enum CodingKeys: CodingKey { - - case id - case name - case address - case billingAddress - } }