crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

PersistenceController.swift (3276B)


      1 import CoreData
      2 import Foundation
      3 
      4 /// Wraps the app's `NSPersistentContainer`. Plain Core Data with no
      5 /// CloudKit mirroring — sync (single-user iPhone↔iPad and CKShare
      6 /// collaboration alike) is the job of a separate sync engine that will
      7 /// drive CloudKit directly on top of this same store. See PLAN.md for the
      8 /// layered design.
      9 @MainActor
     10 final class PersistenceController {
     11     let container: NSPersistentContainer
     12 
     13     var viewContext: NSManagedObjectContext { container.viewContext }
     14 
     15     init(inMemory: Bool = false) {
     16         container = NSPersistentContainer(
     17             name: "CrossmateModel",
     18             managedObjectModel: Self.sharedModel
     19         )
     20 
     21         if inMemory {
     22             // NSInMemoryStoreType keeps each store fully isolated in process
     23             // memory with no file involvement, which prevents concurrent test
     24             // runs from colliding through shared SQLite WAL files at /dev/null.
     25             let description = NSPersistentStoreDescription()
     26             description.type = NSInMemoryStoreType
     27             container.persistentStoreDescriptions = [description]
     28         }
     29 
     30         container.loadPersistentStores { _, error in
     31             if let error {
     32                 // Failing to open the store is unrecoverable for the app.
     33                 // Crash loudly so we notice in development rather than
     34                 // silently running with no persistence.
     35                 fatalError("Failed to load Core Data store: \(error)")
     36             }
     37         }
     38 
     39         container.viewContext.automaticallyMergesChangesFromParent = true
     40 
     41         if !inMemory {
     42             backfillCachedSummaryFields()
     43         }
     44     }
     45 
     46     /// One-shot pass for `GameEntity` rows created before cached summary
     47     /// fields were wired into the creation paths. Runs off the main thread,
     48     /// no-ops on every subsequent launch.
     49     private func backfillCachedSummaryFields() {
     50         let bg = container.newBackgroundContext()
     51         bg.perform {
     52             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
     53             req.predicate = NSPredicate(
     54                 format: "gridWidth == 0 AND puzzleSource != nil AND puzzleSource != %@",
     55                 ""
     56             )
     57             guard let rows = try? bg.fetch(req), !rows.isEmpty else { return }
     58             for entity in rows {
     59                 guard let source = entity.puzzleSource,
     60                       let xd = try? XD.parse(source) else { continue }
     61                 entity.populateCachedSummaryFields(from: Puzzle(xd: xd))
     62             }
     63             if bg.hasChanges { try? bg.save() }
     64         }
     65     }
     66 
     67     // Loaded once and shared across all container instances so that entity
     68     // descriptions are identical objects, which is required for CoreData
     69     // relationship type-checking to pass when tests create multiple containers
     70     // concurrently.
     71     private static let sharedModel: NSManagedObjectModel = {
     72         for bundle in Bundle.allBundles + Bundle.allFrameworks {
     73             if let url = bundle.url(forResource: "CrossmateModel", withExtension: "momd"),
     74                let model = NSManagedObjectModel(contentsOf: url) {
     75                 return model
     76             }
     77         }
     78         fatalError("CrossmateModel.momd not found in any loaded bundle")
     79     }()
     80 }