Skip to content

Commit 456c3e1

Browse files
authored
Merge pull request #39 from PureSwift/feature/coding-keys-macro
Generate CodingKeys enum in @entity macro
2 parents e43de80 + c6f4b47 commit 456c3e1

3 files changed

Lines changed: 102 additions & 15 deletions

File tree

‎Sources/CoreModelMacros/Entity.swift‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ public struct EntityMacro: MemberMacro, ExtensionMacro {
1818
public static var expansionNames: [String] {
1919
[
2020
"entityName",
21+
"CodingKeys",
2122
"attributes",
2223
"relationships",
2324
"init(from:)",
@@ -63,13 +64,17 @@ public struct EntityMacro: MemberMacro, ExtensionMacro {
6364
let relationshipsDeclarationSyntax = try relationshipsDeclarationSyntax(of: node, providingMembersOf: declaration, in: context)
6465
let initDeclarationSyntax = try initDeclarationSyntax(of: node, providingMembersOf: declaration, in: context)
6566
let encodeDeclarationSyntax = try encodeDeclarationSyntax(of: node, providingMembersOf: declaration, in: context)
66-
return [
67+
var declarations = [
6768
entityNameDeclarationSyntax,
6869
attributesDeclarationSyntax,
6970
relationshipsDeclarationSyntax,
7071
initDeclarationSyntax,
7172
encodeDeclarationSyntax
7273
]
74+
if let codingKeysDeclarationSyntax = try codingKeysDeclarationSyntax(of: node, providingMembersOf: declaration, in: context) {
75+
declarations.insert(codingKeysDeclarationSyntax, at: 1)
76+
}
77+
return declarations
7378
}
7479
}
7580

@@ -123,6 +128,40 @@ extension EntityMacro {
123128
return DeclSyntax(stringLiteral: entityNameDecl)
124129
}
125130

131+
/// Generates a `public enum CodingKeys: String, CodingKey` with a case for `id`
132+
/// and each `@Attribute`/`@CompositeAttribute`/`@Relationship` property, in declaration order.
133+
///
134+
/// Returns `nil` if the declaration already contains a `CodingKeys` member,
135+
/// so manually written coding keys are left untouched.
136+
public static func codingKeysDeclarationSyntax(
137+
of node: AttributeSyntax,
138+
providingMembersOf declaration: some DeclGroupSyntax,
139+
in context: some MacroExpansionContext
140+
) throws -> DeclSyntax? {
141+
// Skip generation if the type declares its own CodingKeys.
142+
for member in declaration.memberBlock.members {
143+
if let enumDecl = member.decl.as(EnumDeclSyntax.self), enumDecl.name.text == "CodingKeys" {
144+
return nil
145+
}
146+
if let typealiasDecl = member.decl.as(TypeAliasDeclSyntax.self), typealiasDecl.name.text == "CodingKeys" {
147+
return nil
148+
}
149+
}
150+
var caseNames = ["id"]
151+
for property in codableProperties(of: declaration) where caseNames.contains(property.name) == false {
152+
caseNames.append(property.name)
153+
}
154+
let cases = caseNames
155+
.map { " case \($0)" }
156+
.joined(separator: "\n")
157+
let codingKeysDecl = """
158+
public enum CodingKeys: String, CodingKey, Sendable, CaseIterable {
159+
\(cases)
160+
}
161+
"""
162+
return DeclSyntax(stringLiteral: codingKeysDecl)
163+
}
164+
126165
public static func attributesDeclarationSyntax(
127166
of node: AttributeSyntax,
128167
providingMembersOf declaration: some DeclGroupSyntax,

‎Tests/CoreModelMacrosTests/EntityMacroTests.swift‎

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,10 @@ import SwiftSyntaxMacroExpansion
3535
""")
3636
let context = BasicMacroExpansionContext()
3737
let members = try expandMembers(of: node, attachedTo: declaration, in: context)
38-
#expect(members.count == 5)
38+
#expect(members.count == 6)
3939
let source = members.map { $0.description }.joined(separator: "\n")
4040
#expect(source.contains(#"public static var entityName: EntityName { "Person" }"#))
41+
#expect(source.contains("public enum CodingKeys: String, CodingKey, Sendable, CaseIterable"))
4142
#expect(source.contains(".name: .string"))
4243
#expect(source.contains(".age: .int64"))
4344
#expect(source.contains(".created: .date"))
@@ -299,7 +300,56 @@ import SwiftSyntaxMacroExpansion
299300
}
300301

301302
@Test func expansionNames() {
302-
#expect(EntityMacro.expansionNames.count == 5)
303+
#expect(EntityMacro.expansionNames.count == 6)
304+
}
305+
306+
// MARK: - Coding Keys
307+
308+
@Test func codingKeysExpansion() throws {
309+
let (node, declaration) = try parse("""
310+
@Entity
311+
struct Person {
312+
var id: UUID
313+
@Attribute
314+
var name: String
315+
@Relationship(destination: Pet.self, inverse: .owner)
316+
var pets: [Pet.ID]
317+
}
318+
""")
319+
let context = BasicMacroExpansionContext()
320+
let expansion = try EntityMacro.codingKeysDeclarationSyntax(of: node, providingMembersOf: declaration, in: context)
321+
let codingKeysDecl = try #require(expansion)
322+
let source = codingKeysDecl.description
323+
#expect(source.contains("public enum CodingKeys: String, CodingKey, Sendable, CaseIterable"))
324+
// `id` first, then attributes and relationships in declaration order
325+
let idIndex = try #require(source.range(of: "case id")?.lowerBound)
326+
let nameIndex = try #require(source.range(of: "case name")?.lowerBound)
327+
let petsIndex = try #require(source.range(of: "case pets")?.lowerBound)
328+
#expect(idIndex < nameIndex)
329+
#expect(nameIndex < petsIndex)
330+
}
331+
332+
/// A manually declared `CodingKeys` suppresses the generated one.
333+
@Test func manualCodingKeysSkipsGeneration() throws {
334+
let (node, declaration) = try parse("""
335+
@Entity
336+
struct Person {
337+
var id: UUID
338+
@Attribute
339+
var name: String
340+
enum CodingKeys: String, CodingKey {
341+
case id
342+
case name = "personName"
343+
}
344+
}
345+
""")
346+
let context = BasicMacroExpansionContext()
347+
let expansion = try EntityMacro.codingKeysDeclarationSyntax(of: node, providingMembersOf: declaration, in: context)
348+
#expect(expansion == nil)
349+
let members = try expandMembers(of: node, attachedTo: declaration, in: context)
350+
#expect(members.count == 5)
351+
let source = members.map { $0.description }.joined(separator: "\n")
352+
#expect(source.contains("enum CodingKeys") == false)
303353
}
304354

305355
// MARK: - Composite Attributes
@@ -418,7 +468,7 @@ import SwiftSyntaxMacroExpansion
418468
}
419469

420470
/// An entity mixing scalar attributes, required and optional composites, and a
421-
/// relationship generates all five members consistently.
471+
/// relationship generates all six members consistently.
422472
@Test func mixedEntityExpansion() throws {
423473
let (node, declaration) = try parse("""
424474
@Entity("Campground")
@@ -436,7 +486,7 @@ import SwiftSyntaxMacroExpansion
436486
""")
437487
let context = BasicMacroExpansionContext()
438488
let members = try expandMembers(of: node, attachedTo: declaration, in: context)
439-
#expect(members.count == 5)
489+
#expect(members.count == 6)
440490
let source = members.map { $0.description }.joined(separator: "\n")
441491
#expect(source.contains(#"public static var entityName: EntityName { "Campground" }"#))
442492
// scalar and composite attributes live side by side

‎Tests/CoreModelTests/TestModel.swift‎

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@ struct Person: Equatable, Hashable, Codable, Identifiable {
3232
self.age = age
3333
self.events = events
3434
}
35-
35+
36+
// - Note: Declared manually because `Event.@Relationship(inverse: .events)`
37+
// must type-check against this enum while macros are still expanding;
38+
// the compiler will not expand `@Entity`'s generated `CodingKeys` to
39+
// satisfy another macro's argument. Entities that are not referenced as a
40+
// relationship destination (e.g. `Facility`) can rely on the generated enum.
3641
enum CodingKeys: CodingKey {
3742
case id
3843
case name
@@ -62,7 +67,8 @@ struct Event: Equatable, Hashable, Codable, Identifiable {
6267
self.date = date
6368
self.people = people
6469
}
65-
70+
71+
// - Note: Declared manually; see `Person.CodingKeys`.
6672
enum CodingKeys: CodingKey {
6773
case id
6874
case name
@@ -458,12 +464,4 @@ public struct Facility: Equatable, Hashable, Codable, Identifiable {
458464
self.address = address
459465
self.billingAddress = billingAddress
460466
}
461-
462-
public enum CodingKeys: CodingKey {
463-
464-
case id
465-
case name
466-
case address
467-
case billingAddress
468-
}
469467
}

0 commit comments

Comments
 (0)