0

I am trying to add a custom JSON DataStore and DataStoreConfiguration for SwiftData. Apple kindly provided some sample code in the WWDC24 session, "Create a custom data store with SwiftData", and (once updated for API changes since WWDC) that works fine.

However, when I try to add a relationship between two classes, it fails. Has anyone successfully made a JSONDataStore with a relationship?

Here's my code; firstly the cleaned up code from the WWDC session:

import SwiftData

final class JSONStoreConfiguration: DataStoreConfiguration {
    typealias Store = JSONStore
    
    var name: String
    var schema: Schema?
    var fileURL: URL
    
    init(name: String, schema: Schema? = nil, fileURL: URL) {
        self.name = name
        self.schema = schema
        self.fileURL = fileURL
    }
    
    static func == (lhs: JSONStoreConfiguration, rhs: JSONStoreConfiguration) -> Bool {
        return lhs.name == rhs.name
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(name)
    }
}

final class JSONStore: DataStore {
    typealias Configuration = JSONStoreConfiguration
    typealias Snapshot = DefaultSnapshot
    
    var configuration: JSONStoreConfiguration
    var name: String
    var schema: Schema
    var identifier: String
    
    init(_ configuration: JSONStoreConfiguration, migrationPlan: (any SchemaMigrationPlan.Type)?) throws {
        self.configuration = configuration
        self.name = configuration.name
        self.schema = configuration.schema!
        self.identifier = configuration.fileURL.lastPathComponent
    }
    
    func save(_ request: DataStoreSaveChangesRequest<DefaultSnapshot>) throws -> DataStoreSaveChangesResult<DefaultSnapshot> {
        var remappedIdentifiers = [PersistentIdentifier: PersistentIdentifier]()
        var serializedData = try read()
        
        for snapshot in request.inserted {
            let permanentIdentifier = try PersistentIdentifier.identifier(for: identifier,
                                                                          entityName: snapshot.persistentIdentifier.entityName,
                                                                          primaryKey: UUID())
            let permanentSnapshot = snapshot.copy(persistentIdentifier: permanentIdentifier)
            
            serializedData[permanentIdentifier] = permanentSnapshot
            remappedIdentifiers[snapshot.persistentIdentifier] = permanentIdentifier
        }
        
        for snapshot in request.updated {
            serializedData[snapshot.persistentIdentifier] = snapshot
        }
        
        for snapshot in request.deleted {
            serializedData[snapshot.persistentIdentifier] = nil
        }
        
        try write(serializedData)
        
        return DataStoreSaveChangesResult<DefaultSnapshot>(for: self.identifier, remappedIdentifiers: remappedIdentifiers)
    }
    
    func fetch<T>(_ request: DataStoreFetchRequest<T>) throws -> DataStoreFetchResult<T, DefaultSnapshot> where T : PersistentModel {
        if request.descriptor.predicate != nil {
            throw DataStoreError.preferInMemoryFilter
        } else if request.descriptor.sortBy.count > 0 {
            throw DataStoreError.preferInMemorySort
        }
        
        let objs = try read()
        let snapshots = objs.values.map({ $0 })
        
        return DataStoreFetchResult(descriptor: request.descriptor, fetchedSnapshots: snapshots, relatedSnapshots: objs)
    }
    
    func read() throws -> [PersistentIdentifier : DefaultSnapshot] {
        if FileManager.default.fileExists(atPath: configuration.fileURL.path(percentEncoded: false)) {
            let decoder = JSONDecoder()
            
            decoder.dateDecodingStrategy = .iso8601
            
            let data = try decoder.decode([DefaultSnapshot].self, from: try Data(contentsOf: configuration.fileURL))
            var result = [PersistentIdentifier: DefaultSnapshot]()
            
            data.forEach { s in
                result[s.persistentIdentifier] = s
            }
            
            return result
        } else {
            return [:]
        }
    }
    
    func write(_ data: [PersistentIdentifier : DefaultSnapshot]) throws {
        let encoder = JSONEncoder()
        
        encoder.dateEncodingStrategy = .iso8601
        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
        
        let jsonData = try encoder.encode(data.values.map({ $0 }))
        
        try jsonData.write(to: configuration.fileURL)
    }
}

The data model classes:

import SwiftData

@Model
class Settings {
    private(set) var version = 1
    
    @Relationship(deleteRule: .cascade) var hack: Hack? = Hack()
    
    init() {
    }
}

@Model
class Hack {
    var foo = "Foo"
    
    var bar = 42
    
    init() {
    }
}

Container:

lazy var mainContainer: ModelContainer = {
        do {
            let url = // URL to file
            let configuration = JSONStoreConfiguration(name: "Settings", schema: Schema([Settings.self, Hack.self]), fileURL: url)
            
            return try ModelContainer(for: Settings.self, Hack.self, configurations: configuration)
        }
        catch {
            fatalError("Container error: \(error.localizedDescription)")
        }
    }()

Load function, that saves a new Settings JSON file if there isn't an existing one:

    @MainActor func loadSettings() {
        let mainContext = mainContainer.mainContext
        let descriptor = FetchDescriptor<Settings>()

        let settingsArray = try? mainContext.fetch(descriptor)
        
        print("\(settingsArray?.count ?? 0) settings found")
        
        if let settingsArray, let settings = settingsArray.last {
            print("Loaded")
        } else {
            let settings = Settings()
            
            mainContext.insert(settings)
            
            do {
                try mainContext.save()
            } catch {
                print("Error saving settings: \(error)")
            }
        }
    }

The save operation creates this JSON file, which while it isn't a format I would choose, is acceptable, though notice that the "hack" property (the relationship) doesn't have the correct identifier:

[
  {
    "hack" : {
      "implementation" : {
        "entityName" : "Hack",
        "isTemporary" : true,
        "primaryKey" : "52747F74-ED6C-40FF-B008-ED3AA1AEF8EB",
        "uriRepresentation" : "x-swiftdata:\/\/Hack\/52747F74-ED6C-40FF-B008-ED3AA1AEF8EB"
      }
    },
    "persistentIdentifier" : {
      "implementation" : {
        "entityName" : "Settings",
        "isTemporary" : false,
        "primaryKey" : "D767DB6F-8D55-4261-85DC-536F6E77D81D",
        "storeIdentifier" : "Settings.timeoutsettings",
        "typedPrimaryKey" : "D767DB6F-8D55-4261-85DC-536F6E77D81D",
        "uriRepresentation" : "x-developer-provided:\/\/Settings.timeoutsettings\/Settings\/D767DB6F-8D55-4261-85DC-536F6E77D81D"
      }
    },
    "version" : 1
  },
  {
    "bar" : 42,
    "foo" : "Foo",
    "persistentIdentifier" : {
      "implementation" : {
        "entityName" : "Hack",
        "isTemporary" : false,
        "primaryKey" : "769933C4-3ADE-4808-914C-C21EA6833F56",
        "storeIdentifier" : "Settings.timeoutsettings",
        "typedPrimaryKey" : "769933C4-3ADE-4808-914C-C21EA6833F56",
        "uriRepresentation" : "x-developer-provided:\/\/Settings.timeoutsettings\/Hack\/769933C4-3ADE-4808-914C-C21EA6833F56"
      }
    }
  }
]

When I run the app again to load the data, I get this error:

SwiftData/ModelContext.swift:2595: Fatal error: Failed to materialize a model for Settings from snapshot:DefaultSnapshot(_values: ["bar": 42, "foo": "Foo"], persistentIdentifier: SwiftData.PersistentIdentifier(id: SwiftData.PersistentIdentifier.ID(url: x-developer-provided://Settings.timeoutsettings/Hack/769933C4-3ADE-4808-914C-C21EA6833F56), implementation: SwiftData.PersistentIdentifierImplementation)) For fetch descriptor: FetchDescriptor(predicate: nil, sortBy: [], fetchLimit: nil, fetchOffset: Optional(0), includePendingChanges: true, propertiesToFetch: [], relationshipKeyPathsForPrefetching: [], returnModelsAsFutures: false)

Even if I change Apple's code to not assign a new identifier, so the relationship property and its pointee have the same identifier, it still doesn't load.

Am I doing something obviously wrong, are relationships not supported in custom data stores, or is this an Apple bug?

Update: based on a comment I tried adding the Settings or Hack to the context before establishing the relationship connection, but that didn't help either:

            let settings = Settings()            
            mainContext.insert(settings)
            
            let hack = Hack()
            mainContext.insert(hack)
            
            settings.hack = hack
7
  • The hack property of your Settings object in the json seems to have a temporary identifier and the primary key doesn’t match the primary key of the stored Hack object. Are you creating and connecting the objects correctly in your swift code? Commented Nov 27, 2024 at 7:30
  • Yes, I mentioned that. You can see the creation and connection in the provided code. I could be doing something wrong, but I can't see it; maybe you can. Commented Nov 27, 2024 at 15:43
  • let settings = Settings(), that's all I see but no Hack object is created so where does it come from? Commented Nov 27, 2024 at 16:09
  • Oh I found it now, var hack: Hack? = Hack(). That is not a good way to create relationship objects, you don't want to assign either end of the relationship until at least one of the objects has been inserted into the model context. See also this answer. So either do it properly or simply remove the Hack type and incorporate it into the Settings model since it's a one-to-one relationship. Commented Nov 27, 2024 at 17:25
  • Good thought. See my update at the end of the post; unfortunately that didn't help. Any other ideas? Commented Nov 27, 2024 at 17:37

1 Answer 1

0

There are two issues here:

  1. When you are fetching Settings you are also trying to return Hacks, these need to be filtered out in fetch.

  2. The persistentIdentifier remapping needs to be passed to .copy() to update the relationships.

Filtering fetch results

The JSON store is a flat file of different types, in your case Settings and Hack.

When fetch<Settings> is called it is expecting only Settings in the results.

In the error you provided you can see that it is saying that Settings contains foo and bar which are Hack properties, so it is trying to interpret the Hack as Settings.

The solution is to filter by the entity type in fetch, e.g.:

let filteredSnapshots = snapshots.filter({ $0.persistentIdentifier.entityName == "\(T.self)"})

This should fix the error, but the relationship persistentIdentifiers will still be incorrect in the JSON file.

Remapping Relationship persistentIdentifiers

When you make a copy of the snapshot there is an optional parameter, remappedIdentifiers, that allows you to pass in the remapping so all of the relationships can be updated.

However, this means that we need to formulate the mapping first before doing the copies. Also updates may require remappings as well, so the update should make a copy.

This is the save function from my code:

public func save(_ request: DataStoreSaveChangesRequest<Snapshot>) throws -> DataStoreSaveChangesResult<Snapshot> {
        
        var snapshotsByIdentifier = [PersistentIdentifier: DefaultSnapshot]()
        
        try self.read().forEach { snapshotsByIdentifier[$0.persistentIdentifier] = $0 }
        
        // Temporary to permanent identifier map
        var remappedIdentifiers = [PersistentIdentifier: PersistentIdentifier]()
        
        // Create remapping of temp to permanent identifiers first
        for snapshot in request.inserted {
            let entityName = snapshot.persistentIdentifier.entityName
            let permanentIdentifier = try PersistentIdentifier.identifier(for: identifier, entityName: entityName, primaryKey: UUID())
            remappedIdentifiers[snapshot.persistentIdentifier] = permanentIdentifier
        }
        
        // Make copies of snapshots with the new permanent identifiers passing remapping into copy
        for snapshot in request.inserted {
            guard let permanentIdentifier = remappedIdentifiers[snapshot.persistentIdentifier] else {
                print("Can't find permanent identifier for \(snapshot.persistentIdentifier)")
                continue
            }
            let snapshotCopy = snapshot.copy(persistentIdentifier: permanentIdentifier, remappedIdentifiers: remappedIdentifiers)
            snapshotsByIdentifier[permanentIdentifier] = snapshotCopy
        }
        
        // We need to make copies of updates passing in remapped identifiers so relationships can be updated
        for snapshot in request.updated {
            let persistentIdentifier = snapshot.persistentIdentifier
            snapshotsByIdentifier[persistentIdentifier] = snapshot.copy(persistentIdentifier: persistentIdentifier, remappedIdentifiers: remappedIdentifiers)
        }
        
        for snapshot in request.deleted {
            snapshotsByIdentifier[snapshot.persistentIdentifier] = nil
        }
        
        let snapshots = snapshotsByIdentifier.values.map { $0 }
        let encoder = JSONEncoder()
        encoder.outputFormatting = .prettyPrinted
        let jsonData = try encoder.encode(snapshots)
        try jsonData.write(to: configuration.fileURL)
        
        return DataStoreSaveChangesResult(for: self.identifier, remappedIdentifiers: remappedIdentifiers)
    }
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.