SwiftData JSONDataStore with relationships

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 a JSON file, which while it isn't a format I would choose, is acceptable, though I notice that the "hack" property (the relationship) doesn't have the correct identifier.

When I run the app again to load the data, I get an error (that there wasn't room to include in this post).

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, or are relationships not supported in custom data stores?

Since there wasn't room in the post for the created JSON, here it is:

[
{
"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"
}
}
}
]

And the 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<Settings>(predicate: nil, sortBy: [], fetchLimit: nil, fetchOffset: Optional(0), includePendingChanges: true, propertiesToFetch: [], relationshipKeyPathsForPrefetching: [], returnModelsAsFutures: false)

I had the same problem and fixed it by changing the fetch to filter by the entity name, e.g.

let objs = try read()
let snapshots = objs.values.filter { v in
v.persistentIdentifier.entityName == String(describing: T.self)
}
return DataStoreFetchResult(descriptor: request.descriptor, fetchedSnapshots: snapshots, relatedSnapshots: objs)
SwiftData JSONDataStore with relationships
 
 
Q