PAYATHON 2026

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,与数据库列值比较,避免同一上下文中已注册的对象掩盖获取问题。

这些代码只涉及快照映射,不包含数据库连接、事务和并发桥接的完整实现,也不代表已在题目所用环境中运行验证。

备注:内容仅供参考。