SwiftData 自定义 DataStore 获取的 snapshot 属性映射异常如何解决?
先补齐 ProductPostgresSnapshot 的编码、解码实现,并用 DataStoreSnapshotCodingKey 标明模型属性。当前的 encode(to:) 是空实现,init(from:) 则直接调用 fatalError,这两处是快照实现中最明确的问题。
数据库行成功解码为 (UUID, String, Int),只完成了从 PostgreSQL 到快照的转换。SwiftData 还要根据快照还原模型属性,因此,即使 SQL 由 PostgresNIO 执行,快照仍需完整实现 Codable。
如果改用 DefaultSnapshot,可以先把数据库列值写入 BackingData<ProductRecord>,再通过 init(from:relatedBackingDatas:) 创建快照。
为什么保存成功,读取属性却崩溃?
保存时,代码通过:
userBacking.getValue(forKey: \ProductRecord.id)
从模型的 BackingData 取值,再直接传给 SQL。这个过程不依赖自定义的 encode(to:),所以保存看起来可以正常运行。
获取时,即使 ProductPostgresSnapshot.id 已经是 UUID,SwiftData 仍需要读取快照中的模型状态。DataStoreSnapshot 继承了 Encodable、Decodable 和 Sendable,编码和解码方法都需要能正常工作。Apple 的协议声明列出了这些要求。
报错中的 Swift.Optional<Any> 说明模型 getter 得到了无法转换为 UUID 的值。考虑到这里的编码实现为空,应先修复快照到模型的数据传递。仅凭这条错误,还不能认定 PostgreSQL 的 UUID 解码有问题。
修复自定义快照
当前的 ProductRecord 只有三个普通属性,没有关系,可以使用以下快照实现:
import Foundation
import SwiftData
struct ProductPostgresSnapshot: DataStoreSnapshot {
var persistentIdentifier: PersistentIdentifier
var id: UUID
var sku: String
var stockQuantity: Int
init(
persistentIdentifier: PersistentIdentifier,
id: UUID,
sku: String,
stockQuantity: Int
) {
self.persistentIdentifier = persistentIdentifier
self.id = id
self.sku = sku
self.stockQuantity = stockQuantity
}
init(
from backingData: any BackingData,
relatedBackingDatas: inout [
PersistentIdentifier: any BackingData
]
) {
guard
let persistentIdentifier = backingData.persistentModelID,
let productData =
backingData as? (any BackingData<ProductRecord>)
else {
preconditionFailure(
"Expected ProductRecord backing data with an identifier"
)
}
self.persistentIdentifier = persistentIdentifier
self.id = productData.getValue(forKey: \ProductRecord.id)
self.sku = productData.getValue(forKey: \ProductRecord.sku)
self.stockQuantity = productData.getValue(
forKey: \ProductRecord.stockQuantity
)
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(
keyedBy: DataStoreSnapshotCodingKey.self
)
self.persistentIdentifier = try container.decode(
PersistentIdentifier.self,
forKey: .persistentIdentifier
)
self.id = try container.decode(
UUID.self,
forKey: .modeledProperty("id")
)
self.sku = try container.decode(
String.self,
forKey: .modeledProperty("sku")
)
self.stockQuantity = try container.decode(
Int.self,
forKey: .modeledProperty("stockQuantity")
)
}
func encode(to encoder: any Encoder) throws {
var container = encoder.container(
keyedBy: DataStoreSnapshotCodingKey.self
)
try container.encode(
persistentIdentifier,
forKey: .persistentIdentifier
)
try container.encode(
id,
forKey: .modeledProperty("id")
)
try container.encode(
sku,
forKey: .modeledProperty("sku")
)
try container.encode(
stockQuantity,
forKey: .modeledProperty("stockQuantity")
)
}
func copy(
persistentIdentifier: PersistentIdentifier,
remappedIdentifiers: [
PersistentIdentifier: PersistentIdentifier
]? = nil
) -> Self {
Self(
persistentIdentifier: persistentIdentifier,
id: id,
sku: sku,
stockQuantity: stockQuantity
)
}
}
这里使用 Apple 为快照编码提供的 DataStoreSnapshotCodingKey:
.persistentIdentifier表示 SwiftData 的持久标识。.modeledProperty("id")表示模型的id属性。.modeledProperty("stockQuantity")中的名称必须与 Swift 模型属性一致。即使数据库列名是stockquantity,这里仍要写"stockQuantity"。
编码时直接传入实际的 UUID、String 和 Int,无需添加类型名称,也无需包装成 Any。
原来的兜底分支也应移除。类型不匹配时生成新 UUID、填入 "UNKNOWN",会把实现错误掩盖为有效的业务数据。上面的 preconditionFailure 会在不满足条件时直接暴露问题,因为当前实现仅支持带有持久标识的 ProductRecord backing data。
这个模型没有关系,因此 copy 只需替换快照自身的标识。以后增加关系时,还需要处理关系中的标识重映射和 relatedBackingDatas,届时要相应扩展这段实现。
替换后,fetch 中根据数据库列创建 ProductPostgresSnapshot 的代码可以保留。
如何从数据库列创建 DefaultSnapshot?
SDK 的公开声明中,DefaultSnapshot 提供了从 BackingData 和 Decoder 初始化的入口,没有直接接收数据库列字典的初始化器。
对于当前模型,可以先创建指定模型类型的 backing data,再写入列值:
func makeProductSnapshot(
storeIdentifier: String,
id: UUID,
sku: String,
stockQuantity: Int
) throws -> DefaultSnapshot {
let persistentIdentifier = try PersistentIdentifier.identifier(
for: storeIdentifier,
entityName: "ProductRecord",
primaryKey: id
)
var backingData: any BackingData<ProductRecord> =
ProductRecord.createBackingData()
backingData.persistentModelID = persistentIdentifier
backingData.setValue(forKey: \ProductRecord.id, to: id)
backingData.setValue(forKey: \ProductRecord.sku, to: sku)
backingData.setValue(
forKey: \ProductRecord.stockQuantity,
to: stockQuantity
)
var relatedBackingDatas: [
PersistentIdentifier: any BackingData
] = [:]
return DefaultSnapshot(
from: backingData,
relatedBackingDatas: &relatedBackingDatas
)
}
这种方式使用公开的 createBackingData() 和 BackingData.setValue(forKey:to:) 接口,适用于这里没有关系的模型。
在行解码循环中调用:
let snapshot = try makeProductSnapshot(
storeIdentifier: identifier,
id: dbID,
sku: dbSku,
stockQuantity: dbStock
)
collectedSnapshots.append(snapshot)
采用这种方式时,存储的快照类型也要改为:
typealias Snapshot = DefaultSnapshot
相应的 fetch、save 请求和结果类型,以及快照数组,都要改用 DefaultSnapshot。保存逻辑也需要调整,因为 DefaultSnapshot 不直接暴露 snapshot.id、snapshot.sku 这些业务字段。
Apple JSON 示例中的类型信息属于其序列化表示,不应据此手工拼接 JSON,再猜测如何解码成快照。官方示例展示的是对已有 DefaultSnapshot 进行编码和解码。对于当前的关系型表结构,保留自定义快照并补齐编码实现,所需改动更小。
原代码中还要修正的地方
以下问题需要与 UUID 映射错误分别处理:
UPDATE使用了items_inventory,插入、删除和查询使用的却是products,需要统一表名。如果没有配置对应的search_path,还应使用swiftdatastore.products。save和fetch捕获数据库错误后只打印日志,仍会返回成功结果或部分数据。应将错误传回同步方法并抛出。保存多项变更时,应使用数据库事务保证一致性,提交成功后再返回标识映射。stockQuantity int DEFAULT 0仍允许显式写入NULL。如果 Swift 属性保持为非可选Int,数据库也应添加NOT NULL约束,并先处理已有空值。- 当前
fetch没有处理谓词和排序。可以实现 SQL 转换;对于适合整体载入的小数据集,也可以按官方示例抛出DataStoreError.preferInMemoryFilter和DataStoreError.preferInMemorySort,交由上下文处理。 - 使用
Task配合DispatchSemaphore.wait()阻塞等待异步数据库操作,可能造成执行器阻塞或死锁,还涉及跨任务访问共享变量。同步接口与异步驱动之间的桥接需要单独设计,当前写法还不能作为完整的存储实现。
另外,ProductRecord.id 和 persistentIdentifier 是两个不同的概念。即使持久标识以该 UUID 为主键创建,快照中也仍需提供模型的 id 属性。
如何确认映射已经修好?
先用 JSONEncoder 编码快照,再用 JSONDecoder 解码,检查三个业务字段和持久标识是否保持一致。这能验证基本的 Codable 实现,之后还需要验证 SwiftData 的获取过程。
使用新的 ModelContext 获取已保存的记录。更稳妥的做法是重启程序后只执行获取,不再插入测试数据。逐一访问 id、sku 和 stockQuantity,与数据库列值比较,避免同一上下文中已注册的对象掩盖获取问题。
这些代码只涉及快照映射,不包含数据库连接、事务和并发桥接的完整实现,也不代表已在题目所用环境中运行验证。
备注:内容仅供参考。
