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
let settings = Settings(), that's all I see but no Hack object is created so where does it come from?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.