swiftuirangecloudkitswiftdatakey-value-coding

How to make a Range key value compliant in CloudKit?


In my app (which utilizes SwiftUI, SwiftData and CloudKit), I have the following class and enum:

@Model
class CategoryType
{
    var name: String = ""
    var parameterType: CategoryTypeParameterType = CategoryTypeParameterType.none
    
    init(name: String, parameterType: CategoryTypeParameterType)
    {
        self.name = name
        self.parameterType = parameterType
    }
}

enum CategoryTypeParameterType: Codable, CaseIterable, Hashable
{
    case none
    case number(number: UInt)
    case range(range: ClosedRange<UInt>)
    
    var name: String
    {
        switch self
        {
            case .none:
                return "None"
            case .number:
                return "Number"
            case .range:
                return "Range"
        }
    }
    
    static var allCases: [CategoryTypeParameterType]
    {
        return  [.none,
                 .number(number: 1),
                 .range(range: 1...10)]
    }
}

Simply creating a CategoryType object that has a parameterType with the value CategoryTypeParameterType.range crashes the program with the following:

Thread 1: "[<__SwiftValue 0x301467570> valueForUndefinedKey:]: this class is not key value coding-compliant for the key lowerBound.

I'm creating the object like so:

let book = ProjectType(name: "Book")
self.modelContext.insert(projectType)
book.categoryTypes?.append(CategoryType(name: "Pages", parameterType: CategoryTypeParameterType.range(range: 1...10)))

and here is the ProjectType class for reference:

@Model
class ProjectType
{
    var name: String = ""
    @Relationship(deleteRule: .cascade, inverse: \CategoryType.projectTypes) var categoryTypes: [CategoryType]?
    var createdDate: Date = Date.now
    
    init(name: String)
    {
        self.name = name
        self.categoryTypes = []
    }
}

Why isn't this working?


Solution

  • What happens is that SwiftData creates a sql table with separate columns for number and the lower and upper bound for the range. But even though it creates such table itself it fails to use it properly when trying to save an object so this is clearly some bug involved here.

    So I have no solution for this but a workaround to use for the time being. The workaround is a pattern sometime used in other cases in SwiftData and Core Data and that is to store a base type and then use a computed property to convert between that type and the "public" one.

    So first we add a private property of type Data to CategoryType

    private var parameterTypeData: Data
    

    and then the computed property that uses json encoding and decoding to convert between the enum and Data

    var parameterType: CategoryTypeParameterType {
        get { try! JSONDecoder().decode(CategoryTypeParameterType.self, from: parameterTypeData) }
        set { parameterTypeData = try! JSONEncoder().encode(newValue) }
    }
    

    I also changed the init so that we give the parameter the default value there and we need to encode that parameter separately.

    init(name: String, parameterType: CategoryTypeParameterType = .none) {
        self.name = name
        parameterTypeData = try! JSONEncoder().encode(parameterType)
    }
    

    Note that I use try! when encoding and decoding and it should work fine since the parameterTypeData property is private so no other values can be set than what exists in the enum but you might want to change this if you want.

    Also note that this might not be ideal from a performance perspective with all the encoding and decoding and creating objects for that even though some improvements can be made.