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
41 changes: 40 additions & 1 deletion Sources/CoreModelMacros/Entity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public struct EntityMacro: MemberMacro, ExtensionMacro {
public static var expansionNames: [String] {
[
"entityName",
"CodingKeys",
"attributes",
"relationships",
"init(from:)",
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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,
Expand Down
58 changes: 54 additions & 4 deletions Tests/CoreModelMacrosTests/EntityMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down
18 changes: 8 additions & 10 deletions Tests/CoreModelTests/TestModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Loading