mcpField
C
core-data

core-data

github.com/dpearson2699データ・データベース1.2k2.7k 回インストール13 週間前に更新
リポジトリ

core-data について

SwiftData をまだ採用していないアプリを保守する開発者向けの Core Data スキルです。永続化、並行処理、一括操作、マイグレーションに加え、複数ターゲット間の履歴追跡とテストを扱います。

主な機能

  1. 01Core Data スタックと並行処理
  2. 02NSFetchedResultsController によるデータ表示
  3. 03一括挿入、更新、削除
  4. 04永続履歴の追跡
  5. 05段階的なモデルマイグレーション
  6. 06インメモリストアでのテスト

活用シーン

  1. 01Core Data によるアプリデータの永続化
  2. 02バックグラウンドコンテキストでのデータ取り込みや更新
  3. 03アプリの複数ターゲット間でのストア変更追跡
  4. 04モデルマイグレーションの計画と永続化処理のテスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill core-data

インストール後、Claude Code で/core-dataを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

evals/
evals.json
references/
persistent-history.md
staged-migration.md

詳細

Core Data

SwiftData を採用していないアプリ向けに、Core Data を使用したデータ永続化を構築、保守します。スタックの設定、並行処理、バッチ操作、NSFetchedResultsController、永続履歴の追跡、段階的なマイグレーション、テストについて説明します。

目次

スタックの設定

NSPersistentContainer は Core Data スタックをカプセル化します。

ドキュメント:NSPersistentContainer

import CoreData

final class CoreDataStack: @unchecked Sendable {
    static let shared = CoreDataStack()

    let container: NSPersistentContainer

    private init() {
        container = NSPersistentContainer(name: "MyAppModel")
        container.loadPersistentStores { _, error in
            if let error { fatalError("Core Data store failed: \(error)") }
        }
        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }

    var viewContext: NSManagedObjectContext { container.viewContext }

    func newBackgroundContext() -> NSManagedObjectContext {
        container.newBackgroundContext()
    }
}

CloudKit と同期する場合は、代わりに NSPersistentCloudKitContainer を使用してください。

並行処理とスレッド

Core Data のコンテキストはキューに関連付けられています。viewContext はメインキュー上にあり、バックグラウンドコンテキストはプライベートキュー上で動作します。

ドキュメント:NSManagedObjectContext

ルール:

  • コンテキスト自身のキュー以外からアクセスする場合は、必ず perform(_:) または performAndWait(_:) を使用してください。
  • NSManagedObject インスタンスをコンテキストやスレッドの境界を越えて渡さないでください。代わりに NSManagedObjectID を渡し、再取得してください。
  • viewContext で automaticallyMergesChangesFromParent = true を設定してください。
// Writing on a background context
func updateTrip(id: NSManagedObjectID, newName: String) async throws {
    let context = CoreDataStack.shared.newBackgroundContext()
    try await context.perform {
        guard let trip = try context.existingObject(with: id) as? CDTrip else {
            throw PersistenceError.notFound
        }
        trip.name = newName
        try context.save()
    }
}
Swift Concurrency との統合

NSManagedObjectContext.perform(_:) には async throws オーバーロードがあります(iOS 15 以降)。NSManagedObject のサブクラスに Sendable を指定するのは避けてください。

func importItems(_ records: [ItemRecord]) async throws {
    let context = CoreDataStack.shared.newBackgroundContext()
    try await context.perform {
        for record in records {
            let item = CDItem(context: context)
            item.id = record.id
            item.title = record.title
        }
        try context.save()
    }
    // After save completes, viewContext auto-merges if configured
}

管理オブジェクトに @unchecked Sendable を使用しないでください。 境界を越えて通信する必要がある場合は、objectID(Sendable に準拠)を渡して再取得してください。

let objectID = trip.objectID  // Sendable
Task.detached {
    let bgContext = CoreDataStack.shared.newBackgroundContext()
    try await bgContext.perform {
        let trip = try bgContext.existingObject(with: objectID) as! CDTrip
        trip.isFavorite = true
        try bgContext.save()
    }
}

NSFetchedResultsController

Core Data のフェッチリクエストから UITableView / UICollectionView を効率的に更新します。変更追跡機能が組み込まれており、キャッシュも任意で使用できます。

ドキュメント:NSFetchedResultsController

import CoreData
import UIKit

class TripsViewController: UITableViewController, NSFetchedResultsControllerDelegate {

    private lazy var fetchedResultsController: NSFetchedResultsController<CDTrip> = {
        let request: NSFetchRequest<CDTrip> = CDTrip.fetchRequest()
        request.sortDescriptors = [
            NSSortDescriptor(keyPath: \CDTrip.startDate, ascending: false)
        ]
        request.fetchBatchSize = 20

        let controller = NSFetchedResultsController(
            fetchRequest: request,
            managedObjectContext: CoreDataStack.shared.viewContext,
            sectionNameKeyPath: nil,
            cacheName: "TripsCache"
        )
        controller.delegate = self
        return controller
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        try? fetchedResultsController.performFetch()
    }

    // MARK: - UITableViewDataSource

    override func numberOfSections(in tableView: UITableView) -> Int {
        fetchedResultsController.sections?.count ?? 0
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        fetchedResultsController.sections?[section].numberOfObjects ?? 0
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "TripCell", for: indexPath)
        let trip = fetchedResultsController.object(at: indexPath)
        cell.textLabel?.text = trip.name
        return cell
    }

    // MARK: - NSFetchedResultsControllerDelegate (diffable)

    func controller(
        _ controller: NSFetchedResultsController<any NSFetchRequestResult>,
        didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference
    ) {
        let snapshot = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>
        dataSource.apply(snapshot, animatingDifferences: true)
    }
}

重要なポイント:

  • フェッチリクエストには、少なくとも 1 つのソート記述子を必ず設定してください。
  • フェッチリクエストの述語またはソート記述子を変更する前に deleteCache(withName:) を呼び出すか、cacheName を nil に設定してください。
  • 差分可能なスナップショットのデリゲートメソッド(didChangeContentWith:)は iOS 13 以降で使用でき、以前の変更ごとのコールバックよりも推奨されます。
  • コンテキストで reset() を呼び出した後は、再度 performFetch() を呼び出してください。

バッチ操作

バッチ操作は SQL レベルで実行され、管理オブジェクトコンテキストを経由しません。高速ですが、コンテキスト通知は自動的に発生しません。

NSBatchInsertRequest(iOS 13 以降)

ドキュメント:NSBatchInsertRequest

func batchImport(_ records: [[String: Any]]) async throws {
    let context = CoreDataStack.shared.newBackgroundContext()
    try await context.perform {
        let request = NSBatchInsertRequest(
            entity: CDTrip.entity(),
            objects: records
        )
        request.resultType = .objectIDs
        let result = try context.execute(request) as? NSBatchInsertResult
        if let ids = result?.result as? [NSManagedObjectID] {
            NSManagedObjectContext.mergeChanges(
                fromRemoteContextSave: [NSInsertedObjectsKey: ids],
                into: [CoreDataStack.shared.viewContext]
            )
        }
    }
}
NSBatchDeleteRequest(iOS 9 以降)

ドキュメント:NSBatchDeleteRequest

func deleteOldTrips(before cutoff: Date) async throws {
    let context = CoreDataStack.shared.newBackgroundContext()
    try await context.perform {
        let fetchRequest: NSFetchRequest<NSFetchRequestResult> = CDTrip.fetchRequest()
        fetchRequest.predicate = NSPredicate(format: "endDate < %@", cutoff as NSDate)
        let request = NSBatchDeleteRequest(fetchRequest: fetchRequest)
        request.resultType = .resultTypeObjectIDs
        let result = try context.execute(request) as? NSBatchDeleteResult
        if let ids = result?.result as? [NSManagedObjectID] {
            NSManagedObjectContext.mergeChanges(
                fromRemoteContextSave: [NSDeletedObjectsKey: ids],
                into: [CoreDataStack.shared.viewContext]
            )
        }
    }
}
NSBatchUpdateRequest(iOS 8 以降)
func markAllTripsAsNotFavorite() async throws {
    let context = CoreDataStack.shared.newBackgroundContext()
    try await context.perform {
        let request = NSBatchUpdateRequest(entity: CDTrip.entity())
        request.propertiesToUpdate = ["isFavorite": false]
        request.resultType = .updatedObjectIDsResultType
        let result = try context.execute(request) as? NSBatchUpdateResult
        if let ids = result?.result as? [NSManagedObjectID] {
            NSManagedObjectContext.mergeChanges(
                fromRemoteContextSave: [NSUpdatedObjectsKey: ids],
                into: [CoreDataStack.shared.viewContext]
            )
        }
    }
}

バッチ操作の後は、関連するコンテキストに変更を必ずマージしてください。バッチ削除では、削除ルールの Deny は適用されません。

破壊的なバッチ処理や再試行可能なバッチ処理には、検証ループを使用してください。述語と想定件数を事前に確認し、オブジェクト ID の結果型で実行し、ID を稼働中のコンテキストにマージしてから再フェッチし、事後条件を検証します。失敗した場合は、初期状態のフィクスチャを復元するか、再試行の前に操作のべき等性を確認してください。一部だけ完了したバッチを、確認せずに再実行してはいけません。

永続履歴の追跡

ターゲット(アプリ、拡張機能、ウィジェット)やプロセスをまたいで、ストアレベルの変更を追跡します。基本的なワークフローは次のとおりです。

ドキュメント:NSPersistentHistoryChangeRequest

  1. ストアを読み込む前に、永続履歴とリモート変更通知を有効にします。
  2. 変更を監視し、ターゲットの永続トークン以降のトランザクションを取得します。
  3. トランザクション通知を実行中のコンテキストにマージしてから、新しいトークンを永続化します。
  4. 関連するすべての利用者が処理を完了した履歴のみを削除します。

ストアオプション、オブザーバー、トークンの永続化、マージループ、または削除ポリシーを実装する際は、persistent-history.md を読み込んでください。

段階的なマイグレーション

NSStagedMigrationManager(iOS 17 以降)は、順序付けされた軽量またはカスタムのステージを通じて、スキーマのマイグレーションを順次実行します。ステージの入力ではモデル名ではなく、コンパイル済みモデルバージョンのチェックサムを使用します。iOS 17 より前のシステムに対応するアプリでは、軽量マイグレーションまたはマッピングモデルの方法が必要です。

ドキュメント:NSStagedMigrationManager

順序付けされたステージ、モデル参照、カスタムハンドラー、永続ストアオプションを構築する際は、staged-migration.md を読み込んでください。

複合属性

iOS 17 以降では複合属性がサポートされています。複合属性とは、エンティティ上のサブ属性のグループで、単一の論理単位として機能します。モデルエディターで Composite 型の属性を追加し、その下にサブ属性をネストして定義します。

ドキュメント:NSCompositeAttributeDescription

SwiftData との共存シナリオでは、複合属性は Swift の Codable 構造体に対応します。

SwiftData との境界

Core Data と SwiftData の共存、またはマイグレーションの実装には、swiftdata スキルを使用してください。引き継ぐ前に、以下の Core Data との境界を維持してください。

  • Core Data データを共有またはマイグレーションする場合、SwiftData は既存の永続ストア URL を参照する必要があります。
  • 共有される永続データでは、Core Data モデルと SwiftData の @Model クラス間で、エンティティ名、プロパティ名、型、スキーマに互換性を保つ必要があります。
  • 名前が変更された永続化プロパティは、SwiftData の @Attribute(originalName:) でマッピングしてください。

テスト

テスト用のインメモリストア
import CoreData
import Testing

struct CoreDataTests {
    func makeTestContainer() throws -> NSPersistentContainer {
        let container = NSPersistentContainer(name: "MyAppModel")
        let description = NSPersistentStoreDescription()
        description.type = NSInMemoryStoreType
        container.persistentStoreDescriptions = [description]

        var loadError: Error?
        container.loadPersistentStores { _, error in loadError = error }
        if let loadError { throw loadError }
        return container
    }

    @Test func createAndFetchTrip() throws {
        let container = try makeTestContainer()
        let context = container.viewContext

        let trip = CDTrip(context: context)
        trip.name = "Test Trip"
        trip.startDate = .now
        try context.save()

        let request: NSFetchRequest<CDTrip> = CDTrip.fetchRequest()
        let trips = try context.fetch(request)
        #expect(trips.count == 1)
        #expect(trips.first?.name == "Test Trip")
    }
}

ヒント:

  • 「duplicate entity」警告を避けるため、テスト間で NSManagedObjectModel インスタンスを共有します。
  • 一度だけ読み込む共有モデルを使用します。
private let sharedModel: NSManagedObjectModel = {
    let url = Bundle.main.url(forResource: "MyAppModel", withExtension: "momd")!
    return NSManagedObjectModel(contentsOf: url)!
}()

func makeTestContainer() throws -> NSPersistentContainer {
    let container = NSPersistentContainer(name: "MyAppModel",
                                          managedObjectModel: sharedModel)
    // ... configure in-memory store
}

よくある間違い

間違い修正方法
スレッド間で NSManagedObject を渡すobjectID を渡し、対象のコンテキストで再取得します
バッチ操作の結果をマージし忘れるmergeChanges(fromRemoteContextSave:into:) を呼び出します
hasChanges を確認せずに save() を呼び出す先に context.hasChanges で条件を確認します
非推奨の init(concurrencyType:) の confinement 型を使用する.privateQueueConcurrencyType または .mainQueueConcurrencyType を使用します
viewContext に mergePolicy を設定しない競合によるクラッシュを避けるため、NSMergeByPropertyObjectTrumpMergePolicy を設定します
ライブの NSFetchedResultsController のフェッチリクエストをキャッシュを削除せずに変更する先に deleteCache(withName:) を呼び出すか、cacheName: nil を使用します
バッチ削除で Deny 削除ルールを無視するバッチ削除は削除ルールを回避するため、手動で検証します
NSManagedObject を @unchecked Sendable としてマークするしてはいけません。代わりに objectID を渡します
共存中に SwiftData が新しいストアを参照するSwiftData で Core Data データを共有またはマイグレーションする場合は、既存のストア URL と互換性のあるスキーマを使用します

レビューチェックリスト

  • NSPersistentContainer は一度だけ初期化し、共有します
  • viewContext はメインキューでのみ使用し、書き込みにはバックグラウンドコンテキストを使用します
  • キュー外でコンテキストにアクセスする処理はすべて、perform(_:) または performAndWait(_:) で囲みます
  • viewContext に automaticallyMergesChangesFromParent を設定します
  • 競合によるクラッシュを防ぐため、viewContext に mergePolicy を設定します
  • バッチ操作の結果を関連するコンテキストにマージします
  • NSFetchedResultsController のフェッチリクエストにソート記述子を設定します
  • 複数ターゲットのアプリでは永続履歴の追跡を有効にします
  • Core Data と SwiftData の引き継ぎでは、ストアの URL、スキーマの互換性、エンティティ名とプロパティ名、および名前変更マッピングを維持します
  • テストでは共有の NSManagedObjectModel とインメモリストアを使用します
  • NSManagedObject インスタンスをスレッド間で受け渡しません

参考資料

コメント