Nerdy Bunz
Nerdy Bunz

Reputation: 7437

"Cannot Synthesize" -- Why is this class not ready to be declared "@Model" for use with SwiftData?

I am trying to convert the Landmarks app to SwiftData... why isn't this class conforming to codable/decodable? It won't compile but the messages are non-specific:

"Type 'Landmark' does not conform to protocol 'Decodable'" "Type 'Landmark' does not conform to protocol 'Encodable'"

and

In expansion of macro 'Model' here: "Cannot automatically synthesize 'Decodable' because 'any SwiftData.BackingData' does not conform to 'Decodable'"

import Foundation
import SwiftUI
import CoreLocation
import SwiftData

@Model
final class Landmark: Hashable, Codable, Identifiable {
    var id: Int
    var name: String
    var park: String
    var state: String
    var dezcription: String
    var isFavorite: Bool
    var isFeatured: Bool
    
    var category: Category
    private var coordinates: Coordinates
    private var imageName: String
    
    init(id:Int = Int.random(in: 2000...Int.max), name:String = "", park: String = "", state:String = "", dezcription: String = "", isFavorite: Bool = false, isFeatured:Bool = false, category: Category = Category.mountains, coordinates: Coordinates = Coordinates(latitude: 0, longitude: 0), imageName:String = "umbagog") {
        self.id = id
        self.name = name
        self.park = park
        self.state = state
        self.dezcription = dezcription
        self.isFavorite = isFavorite
        self.isFeatured = isFeatured
        self.category = category
        self.coordinates = coordinates
        self.imageName = imageName
        
    }

    
    enum Category: String, CaseIterable, Codable {
        case lakes = "Lakes"
        case rivers = "Rivers"
        case mountains = "Mountains"
    }

    var image: Image {
        Image(imageName)
    }
    
    
    var locationCoordinate: CLLocationCoordinate2D {
        CLLocationCoordinate2D(
            latitude: coordinates.latitude,
            longitude: coordinates.longitude)
    }

    struct Coordinates: Hashable, Codable {
        var latitude: Double
        var longitude: Double
    }
}

PS - I know that the design of this (the random int id... the default location of 0,0) are bad... but that's not the point of the question.

I've tried:

Upvotes: 0

Views: 377

Answers (3)

Nerdy Bunz
Nerdy Bunz

Reputation: 7437

Sorry, this is a case where I made a false assumption in my question — That this class needs to be codable.

The reason the class was marked Codable was because that was the way it was in the original Landmarks app and I didnt think to change it.

What I didn't realize is that a class marked with @Model does not require any explicit protocols, and in fact should not be marked codable unless one plans on doing some serious surgery as in Sweeper's answer.

If I simply remove the Codable protocol (and can also remove all other protocols safely), it works.

I enterpreted the error message as a Swiftdata error when it is just a “normal” error saying “this class is not codable [because of Swiftdata macro]”

So the other previous answers are appreciated and valid if anyone out there actually has a class they need to be a swift data model and codable, but in my case it was just a false assumption.

One could say that this renders the question invalid, but that is hindsight and I think this the answer to my question, and the information will be valuable to people with the same misunderstanding.

Upvotes: -2

Sweeper
Sweeper

Reputation: 271410

The reason this happens is that the synthesis of the Codable implementation happens after the macros have expanded.

After the macros have expanded, all the properties you declared become computed properties, and the macros add an additional _$backingData property, as well as an underscored-prefixed version of each property you declared, of type _SwiftDataNoType (just an empty struct, acting as a placeholder), among other things.

Basically, @Model turns this:

class Foo {
    var foo: Int
    var bar: String
}

into:

class Foo: PersisrentModel {
    var foo: Int { get { ... } set { ... } }
    var bar: String { get { ... } set { ... } }
    private var _foo: _SwiftDataNoType
    private var _bar: _SwiftDataNoType

    private var _$backingData: any SwiftData.BackingData<Foo>
}

No wonder the Codable synthesis fails!

I recommend checking out the actual code that it generates (the above is only a simplified version) with Xcode's "Inline Macro" and "Expand Macro" context menu items.

You can conform to Codable manually, like this article shows. See also workingdog's answer.

Alternatively, try writing a macro that generates a Codable implementation. Your macro would receive the same syntax tree as @Model, so you will have access to the class' members before the macro expansion. You'd use it like this:

@Model
@CodableModel
final class Landmark: Hashable, Identifiable {
    var id: Int
    var name: String
    ...
}

Here is a rough sketch of such a CodableModel:

@attached(member, names: named(init(from:)), named(encode(to:)), named(CodingKeys))
@attached(extension, conformances: Codable)
public macro CodableModel() = #externalMacro(module: "...", type: "CodableModel")

// ...

public struct CodableModel: MemberMacro, ExtensionMacro {
    public static func expansion(
      of node: AttributeSyntax,
      providingMembersOf declaration: some DeclGroupSyntax,
      in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        var properties = [(type: TypeSyntax, name: TokenSyntax)]()
        let members = declaration.memberBlock.members
        for member in members {
            guard let propertyDecl = member.decl.as(VariableDeclSyntax.self) else { continue }
            guard propertyDecl.bindingSpecifier.text == "var" else { continue } // ignore lets
            guard let binding = propertyDecl.bindings.first else { continue }
            guard binding.accessorBlock == nil else { continue } // ignore computed properties
            guard let type = binding.typeAnnotation?.type else { continue }
            guard let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier else { continue }
            properties.append((type, name))
        }
        let codingKeys = try EnumDeclSyntax("enum CodingKeys: String, CodingKey") {
            for (_, name) in properties {
                "case \(name)"
            }
        }
        
        let initialiser = try InitializerDeclSyntax("init(from decoder: Decoder) throws") {
            "let container = try decoder.container(keyedBy: CodingKeys.self)"
            for (type, name) in properties {
                "\(name) = try container.decode(\(type).self, forKey: .\(name))"
                // TODO: use decodeIfPresent if the type is optional
            }
        }
        
        let encodeMethod = try FunctionDeclSyntax("func encode(to encoder: Encoder) throws") {
            "var container = encoder.container(keyedBy: CodingKeys.self)"
            for (_, name) in properties {
                "try container.encode(\(name), forKey: .\(name))"
            }
        }
        
        return [
            DeclSyntax(codingKeys),
            DeclSyntax(initialiser),
            DeclSyntax(encodeMethod),
        ]
    }
    
    public static func expansion(
        of node: AttributeSyntax,
        attachedTo declaration: some DeclGroupSyntax,
        providingExtensionsOf type: some TypeSyntaxProtocol,
        conformingTo protocols: [TypeSyntax],
        in context: some MacroExpansionContext
    ) throws -> [ExtensionDeclSyntax] {
        [try ExtensionDeclSyntax("extension \(type): Codable") {}]
     }
}

Upvotes: 1

Try this approach of using a specific public init(from decoder: Decoder) and public func encode(to encoder: Encoder) to make your Landmark Codable.

Note you cannot have var image: Image, Image is a View and should not be part of your model. Similarly, var locationCoordinate: CLLocationCoordinate2D {...} is not Codable, use a function instead, as shown in the example code.

@Model
final class Landmark: Hashable, Codable, Identifiable {
    var id: Int
    var name: String
    var park: String
    var state: String
    var dezcription: String
    var isFavorite: Bool
    var isFeatured: Bool
    
    var category: Category
    private var coordinates: Coordinates
    private var imageName: String
    
    enum CodingKeys: CodingKey {
        case id, name, park, state, dezcription, isFavorite, isFeatured
        case category, coordinates, imageName
    }
    
    init(id:Int = Int.random(in: 2000...Int.max), name:String = "", park: String = "", state:String = "", dezcription: String = "", isFavorite: Bool = false, isFeatured:Bool = false, category: Category = Category.mountains, coordinates: Coordinates = Coordinates(latitude: 0, longitude: 0), imageName:String = "umbagog") {
        
        self.id = id
        self.name = name
        self.park = park
        self.state = state
        self.dezcription = dezcription
        self.isFavorite = isFavorite
        self.isFeatured = isFeatured
        self.category = category
        self.coordinates = coordinates
        self.imageName = imageName
    }
    
    enum Category: String, CaseIterable, Codable {
        case lakes = "Lakes"
        case rivers = "Rivers"
        case mountains = "Mountains"
    }
    
    //    var image: Image {
    //        Image(imageName) // <--- should NOT be here, Image is a View
    //    }
    
    // not Codable, use a function
    //    var locationCoordinate: CLLocationCoordinate2D {
    //        CLLocationCoordinate2D(
    //            latitude: coordinates.latitude,
    //            longitude: coordinates.longitude)
    //    }
    
    func locationCoordinate() -> CLLocationCoordinate2D {
        CLLocationCoordinate2D(
            latitude: coordinates.latitude,
            longitude: coordinates.longitude)
    }
    
    struct Coordinates: Hashable, Codable {
        var latitude: Double
        var longitude: Double
    }

    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(Int.self, forKey: .id)
        name = try container.decode(String.self, forKey: .name)
        park = try container.decode(String.self, forKey: .park)
        state = try container.decode(String.self, forKey: .state)
        dezcription = try container.decode(String.self, forKey: .dezcription)
        isFavorite = try container.decode(Bool.self, forKey: .isFavorite)
        isFeatured = try container.decode(Bool.self, forKey: .isFeatured)
        category = try container.decode(Category.self, forKey: .category)
        coordinates = try container.decode(Coordinates.self, forKey: .coordinates)
        imageName = try container.decode(String.self, forKey: .imageName)
    }
    
    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(id, forKey: .id)
        try container.encode(name, forKey: .name)
        try container.encode(park, forKey: .park)
        try container.encode(state, forKey: .state)
        try container.encode(dezcription, forKey: .dezcription)
        try container.encode(isFavorite, forKey: .isFavorite)
        try container.encode(isFeatured, forKey: .isFeatured)
        try container.encode(category, forKey: .category)
        try container.encode(coordinates, forKey: .coordinates)
        try container.encode(imageName, forKey: .imageName)
    }
    
}

Upvotes: 0

Related Questions