0

I'm trying to setup a SwiftData model with a property whose type is an enum with an associated value. The code works if the enum has no associated value.

Here's a simple model that demonstrates the issue:

@Model
final class Something {
    var name: String
    var thing: Something.Whatever

    init() {
        self.name = ""
        self.thing = Something.Whatever.a
    }

    enum Whatever: Codable, Hashable {
        case a
        case b(Int)
    }
}

Here's code that demonstrates the crash:

final class SwiftDataPlayTests: XCTestCase {
    var container: ModelContainer!
    var schema: Schema!
    var modelContext: ModelContext!

    override func setUpWithError() throws {
        schema = SwiftData.Schema([
            Something.self,
        ])
        container = try ModelContainer(for: schema, configurations: [.init(inMemory: true)])
        modelContext = ModelContext(container)
    }

    func testExample() throws {
        let item = Something()
        let x = item.thing // <== crashes here
        print(x)
    }
}

The crash is happening in the generated code for the thing property:

var thing: Something.Whatever
{
    init(newValue) accesses (_$backingData) {
                    self.setValue(for: \.thing, to: newValue)
        }

    get {
                    _$observationRegistrar.access(self, keyPath: \.thing)
                    return self.getValue(for: \.thing) // <== crash here
        }

    set {
                    _$observationRegistrar.withMutation(of: self, keyPath: \.thing) {
                            self.setValue(for: \.thing, to: newValue)
                    }
        }
}

The underlying error is:

SwiftData/BackingData.swift:211: Fatal error: 'try!' expression unexpectedly raised an error: Swift.DecodingError.typeMismatch(SwiftDataPlayTests.Something.Whatever, Swift.DecodingError.Context(codingPath: [], debugDescription: "Invalid number of keys found, expected one.", underlyingError: nil))

Note that this is happening for any value assigned to the property. item.thing = .a or item.thing == .b(42) both give the same error on the let x = item.thing line.

Are enums with associated values supposed to be supported with SwiftData? If so, what do I need to change in my code to make this work? I'm currently using Xcode 15.0 beta 3.

HangarRash
  • 7,314
  • 5
  • 5
  • 32
  • I submitted a bug report (FB12559800) to Apple on this issue. I played around with custom encode/decode methods for the enum and was getting some very buggy results. – HangarRash Jul 09 '23 at 16:13
  • Nice job Apple. Xcode 15.0 beta 5 with iOS 17.0 beta 4 has actually made this even worse. Now it fails trying to initializer `ModelContainer`. – HangarRash Jul 26 '23 at 00:14

1 Answers1

0

I found an ugly work around until Apple (hopefully) fixes this issue. You need to implement a custom encoder/decoder for the enumeration. What's really strange is that the custom encoder is never called when the property is given a value that uses an associated value. But the decoder is called for all values.

I added three more cases, one with and one without an associated value, and one with two associated values. This helped flesh out a more complete work around solution.

Here's the updated Something model class with the three additional cases in the Whatever enum and a custom implementation of the Codable protocol.

@Model
final class Something {
    var name: String
    var thing: Something.Whatever

    init() {
        self.name = ""
        self.thing = Something.Whatever.a
    }

    enum Whatever: Codable, Hashable {
        case a
        case b(Int)
        case c
        case d(String)
        case e(Double, String)

        // NOTE: The following custom decoding works around the SwiftData crash but clearly this should not be needed.
        enum CodingKeys: CodingKey {
            case a
            case b
            case b0 // for the associated value
            case c
            case d
            case d0 // for the associated value
            case e
            case e0 // for the 1st associated value
            case e1 // for the 2nd associated value
        }

        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            var allKeys = ArraySlice(container.allKeys)
            if let oneKey = allKeys.popFirst(), allKeys.isEmpty {
                // There is only a key for the associated value cases
                if container.contains(.b) {
                    let sub = try container.nestedContainer(keyedBy: CodingKeys.self, forKey: oneKey)
                    let b = try sub.decode(Int.self, forKey: .b0)
                    self = .b(b)
                } else if container.contains(.d) {
                    let sub = try container.nestedContainer(keyedBy: CodingKeys.self, forKey: oneKey)
                    let d = try sub.decode(String.self, forKey: .d0)
                    self = .d(d)
                } else if container.contains(.e) {
                    let sub = try container.nestedContainer(keyedBy: CodingKeys.self, forKey: oneKey)
                    let d = try sub.decode(Double.self, forKey: CodingKeys(stringValue: "e0")!)
                    let s = try sub.decode(String.self, forKey: CodingKeys(stringValue: "e1")!)
                    self = .e(d, s)
                } else {
                    throw DecodingError.typeMismatch(Whatever.self, DecodingError.Context.init(codingPath: container.codingPath, debugDescription: "Unexpected value", underlyingError: nil))
                }
            } else {
                // Only reached for the cases with no associated value
                let container = try! decoder.singleValueContainer()
                let val = try container.decode(String.self)
                switch val {
                    case "a":
                        self = .a
                    case "c":
                        self = .c
                    default:
                        throw DecodingError.typeMismatch(Whatever.self, DecodingError.Context.init(codingPath: container.codingPath, debugDescription: "Unexpected value of '\(val)'", underlyingError: nil))
                }
            }
        }

        // Note: This is only be called for cases with no associated value
        func encode(to encoder: Encoder) throws {
            print("encode \(self)")
            var container = encoder.container(keyedBy: CodingKeys.self)
            switch self {
                case .a:
                    try! container.encode("a", forKey: .a)
                case .b(let val):
                    try! container.encode(val, forKey: .b)
                case .c:
                    try! container.encode("c", forKey: .c)
                case .d(let val):
                    try! container.encode(val, forKey: .d)
                case .e(let d, let s):
                    try! container.encode(d, forKey: .e0)
                    try! container.encode(s, forKey: .e1)
            }
        }
    }
}

Here's an updated test case:

func testExample() throws {
    let item = Something()
    modelContext.insert(item)
    item.name = "Please work"
    var x = item.thing // crashes here
    print("result \(x)")
    item.thing = .a // crashes whether this is used or not
    x = item.thing
    print("result \(x)")
    item.thing = .b(42) // crashes whether this is used or not
    x = item.thing
    print("result \(x)")
    item.thing = .c
    x = item.thing
    print("result \(x)")
    item.thing = .d("Hi")
    x = item.thing
    print("result \(x)")
    item.thing = .e(3.14, "Bye")
    x = item.thing
    print("result \(x)")
}

This gives the output:

encode a
result a
encode a
result a
result b(42)
encode c
result c
result d("Hi")
result e(3.14, "Bye")

That initial "encode a" is coming from the assignment in Something.init. Note the lack of "encode x" output for the three associated value cases.

HangarRash
  • 7,314
  • 5
  • 5
  • 32
  • I tried to solve this in a similar way without success and it's hard to know how to solve this and what exactly the "right solution is now since the representation of enums in the underlying database is so strange. Unfortunately the lasted beta (4) doesn't seem to contain any improvements so we'll have to wait some more. – Joakim Danielson Jul 12 '23 at 14:39
  • @JoakimDanielson I testing with beta 4 as well. It only updates Xcode and not iOS or macOS so I'm not surprised this issue isn't fixed yet. My answer here is hopefully a short term solution. But the basic pattern I show here should work for any enum. It's allowing me to move forward to see what other issues I will run into. But sadly this enum issue is actually part of a bigger issue with any Codable property being used in a SwiftData model. It's hard to believe SwiftData is so broken when it comes to any Codable. You can write values but not read them. – HangarRash Jul 12 '23 at 15:39