Post

Core Data Orphan CareEvent Store Assignment

Core Data Orphan CareEvent Store Assignment

MeowCare 這次在真機安裝執行時爆掉,錯誤停在 PersistenceController.attachOrphanEvents

1
Can't reassign an object to a different store once it has been saved

畫面上的情境是:App 啟動後會確保存在一個 default CareSession,並把舊資料裡 session == nilCareEvent 補掛到這個 session。這種舊資料就是 orphan event。

原本想解的問題

MeowCare 有 private store 與 shared store。為了避免新建的 CareEvent 沒有明確 store,早先的想法是在補 session 時順手 assign 到 private store:

1
2
3
4
5
6
for event in orphans {
    event.session = session
    if let privateStore {
        context.assign(event, to: privateStore)
    }
}

這段看起來像是在保護資料一致性:event 要掛到 default session,而 default session 屬於 private store,所以 event 也應該指定到 private store。

但這個寫法少了一個關鍵前提:context.assign(_:to:) 不是拿來搬已存檔物件的。

Apple 的 NSManagedObjectContext.assign(_:to:) 說明是:指定 newly inserted object 要存到哪個 persistent store。也就是說,它適合「剛 insert、還沒 save、尚未固定 store」的物件,不適合拿來重新指定已存檔物件。

怎麼發生

舊版留下的 orphan CareEvent 已經存在 SQLite 裡,只是 relationship 還沒有補上:

1
2
3
event.session == nil
event.objectID.isTemporaryID == false
event.objectID.persistentStore != nil

這種 object 已經屬於某個 store。當程式再呼叫:

1
context.assign(event, to: privateStore)

Core Data 會拒絕,因為它不是新 inserted object。真機就會丟出:

1
Can't reassign an object to a different store once it has been saved

所以問題不是 event.session = session,而是「對已存檔 object 重新 assign store」。

最後怎麼改

修正後只在 event 還沒有固定 store 時才 assign:

1
2
3
4
5
6
7
8
for event in orphans {
    if event.objectID.isTemporaryID,
       event.objectID.persistentStore == nil,
       let targetStore = session.objectID.persistentStore ?? privateStore {
        context.assign(event, to: targetStore)
    }
    event.session = session
}

這裡有三個重點。

第一,isTemporaryID 必須是 true。這表示物件還沒有取得 permanent object ID,尚未真正固定到某個 persistent store。

第二,persistentStore == nil。這是再確認物件目前沒有 store 歸屬。

第三,target store 優先取 session.objectID.persistentStore

1
let targetStore = session.objectID.persistentStore ?? privateStore

因為 relationship 兩端必須在同一個 store。既然 event 要掛到 session,最可靠的 target 是 session 自己所在的 store;只有 session 還沒有 store 時,才 fallback 到 private store。

已存檔 orphan event 的處理就很單純:

1
event.session = session

不要 assign,不要嘗試搬 store。

測試環境也一起修

這次新增測試後,另一個問題浮出來:hosted unit tests 會先啟動 App process。也就是 PersistenceController.shared 可能在測試中被建立。

如果測試仍使用正式 SQLite / CloudKit-aware store,就會有兩個問題:

  • 測試資料可能寫進正式 store。
  • 測試自己再建立 container 時,可能和正式 store 互相干擾。

所以測試期間也要讓 shared persistence controller 使用 in-memory store:

1
2
3
4
5
6
7
8
9
10
11
static let isRunningForTests: Bool = {
    ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
}()

static let shouldUseInMemoryStore: Bool = {
    isRunningForPreviews || isRunningForTests
}()

static let shared = shouldUseInMemoryStore
    ? PersistenceController(inMemory: true)
    : PersistenceController()

in-memory store 也要有不同 URL identity

MeowCare 有 private/shared 兩個 persistent store description。即使使用 NSInMemoryStoreType,也仍然給它們不同 URL:

1
2
let inMemoryStoreDirectory = URL(fileURLWithPath: NSTemporaryDirectory())
    .appendingPathComponent("MeowCare-InMemory-\(UUID().uuidString)", isDirectory: true)
1
2
3
4
privateDesc = NSPersistentStoreDescription(
    url: inMemoryStoreDirectory.appendingPathComponent("MeowCare-Private.sqlite")
)
privateDesc.type = NSInMemoryStoreType
1
2
3
4
sharedDesc = NSPersistentStoreDescription(
    url: inMemoryStoreDirectory.appendingPathComponent("MeowCare-Shared.sqlite")
)
sharedDesc.type = NSInMemoryStoreType

這不是要讓 in-memory store 寫 SQLite。NSInMemoryStoreType 還是記憶體 store。

重點是 Core Data 仍可能用 URL 當 store identity。若 private/shared 都沒有 URL,兩者可能都落到相同 default identity,第二個 store 載入時就可能被視為重複 store。

測試案例

新增測試專門覆蓋「已存檔 orphan event 補 session」:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test("已存檔 orphan CareEvent 補 session 時不重新指定 store")
func savedOrphanCareEventCanAttachDefaultSession() throws {
    let controller = PersistenceController.shared
    let context = controller.container.viewContext

    let event = CareEvent(context: context)
    event.id = UUID().uuidString
    event.eventTypeRaw = EventType.note.rawValue
    event.occurredAt = try date(year: 2026, month: 7, day: 27)
    event.updatedAt = event.occurredAt

    try context.save()
    #expect(event.session == nil)

    let session = controller.ensureDefaultSession(in: context)
    try context.save()

    #expect(event.session === session)
}

這個測試的重點不是建立全新 event,而是先 save(),讓 event 變成「已存檔 orphan」。如果程式仍然一律 assign,這種情境就會重現真機 crash。

記住

這次修正可以整理成幾條規則:

  • context.assign(_:to:) 只用在新 inserted、尚未固定 store 的 object。
  • 已存檔 orphan object 只補 relationship,不重新指定 store。
  • relationship 的 target store 優先跟 session 一致。
  • hosted unit tests 會啟動 App process,所以 shared persistence 也要避開正式 store。
  • in-memory 多 store 仍要給不同 URL identity。

最後驗證:

  • MeowCareTests 通過。
  • Debug 模擬器建置通過。
  • Release archive 通過。
  • 真機不再因 orphan CareEvent 補 session 而 crash。
This post is licensed under CC BY 4.0 by the author.