From 4bb8dd8c4cc4bb885ad26cb92133cd7fd5410ff8 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 2 Jul 2026 12:47:52 +1200 Subject: [PATCH 1/2] Format ContextManager.swift with swift-format --- .../WordPressData/Swift/ContextManager.swift | 77 +++++++++++++------ 1 file changed, 54 insertions(+), 23 deletions(-) diff --git a/Modules/Sources/WordPressData/Swift/ContextManager.swift b/Modules/Sources/WordPressData/Swift/ContextManager.swift index a4c1c234a950..a6914f6adb11 100644 --- a/Modules/Sources/WordPressData/Swift/ContextManager.swift +++ b/Modules/Sources/WordPressData/Swift/ContextManager.swift @@ -78,34 +78,50 @@ public class ContextManager: NSObject, CoreDataStack, CoreDataStackSwift { } @objc(performAndSaveUsingBlock:completion:onQueue:) - public func performAndSave(_ block: @escaping (NSManagedObjectContext) -> Void, completion: (() -> Void)?, on queue: DispatchQueue) { + public func performAndSave( + _ block: @escaping (NSManagedObjectContext) -> Void, + completion: (() -> Void)?, + on queue: DispatchQueue + ) { let context = newDerivedContext() - self.writerQueue.addOperation(AsyncBlockOperation { done in - context.perform { - block(context) + self.writerQueue.addOperation( + AsyncBlockOperation { done in + context.perform { + block(context) - self.save(context, .alreadyInContextQueue) - queue.async { completion?() } - done() + self.save(context, .alreadyInContextQueue) + queue.async { completion?() } + done() + } } - }) + ) } - public func performAndSave(_ block: @escaping (NSManagedObjectContext) throws -> T, completion: ((Result) -> Void)?, on queue: DispatchQueue) { + public func performAndSave( + _ block: @escaping (NSManagedObjectContext) throws -> T, + completion: ((Result) -> Void)?, + on queue: DispatchQueue + ) { let context = newDerivedContext() - self.writerQueue.addOperation(AsyncBlockOperation { done in - context.perform { - let result = Result(catching: { try block(context) }) - if case .success = result { - self.save(context, .alreadyInContextQueue) + self.writerQueue.addOperation( + AsyncBlockOperation { done in + context.perform { + let result = Result(catching: { try block(context) }) + if case .success = result { + self.save(context, .alreadyInContextQueue) + } + queue.async { completion?(result) } + done() } - queue.async { completion?(result) } - done() } - }) + ) } - public func performAndSave(_ block: @escaping (NSManagedObjectContext) -> T, completion: ((T) -> Void)?, on queue: DispatchQueue) { + public func performAndSave( + _ block: @escaping (NSManagedObjectContext) -> T, + completion: ((T) -> Void)?, + on queue: DispatchQueue + ) { performAndSave( block, completion: { (result: Result) in @@ -147,7 +163,11 @@ public class ContextManager: NSObject, CoreDataStack, CoreDataStackSwift { return } - guard let metadata = try? NSPersistentStoreCoordinator.metadataForPersistentStore(ofType: NSSQLiteStoreType, at: storeURL), + guard + let metadata = try? NSPersistentStoreCoordinator.metadataForPersistentStore( + ofType: NSSQLiteStoreType, + at: storeURL + ), !objectModel.isConfiguration(withName: nil, compatibleWithStoreMetadata: metadata) else { return @@ -159,7 +179,8 @@ public class ContextManager: NSObject, CoreDataStack, CoreDataStackSwift { fatalError("Can't find WordPress.momd") } - guard let versionInfo = NSDictionary(contentsOf: modelFileURL.appendingPathComponent("VersionInfo.plist")) else { + guard let versionInfo = NSDictionary(contentsOf: modelFileURL.appendingPathComponent("VersionInfo.plist")) + else { fatalError("Can't get the object model's version info") } @@ -181,7 +202,14 @@ public class ContextManager: NSObject, CoreDataStack, CoreDataStackSwift { private extension ContextManager { static var localDatabasePath: URL { - guard let url = try? FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) else { + guard + let url = try? FileManager.default.url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + else { fatalError("Failed to find the document directory") } @@ -202,7 +230,10 @@ private extension ContextManager { } // Ensure that the `context`'s concurrency type is not `confinementConcurrencyType`, since it will crash if `perform` or `performAndWait` is called. - guard context.concurrencyType == .mainQueueConcurrencyType || context.concurrencyType == .privateQueueConcurrencyType else { + guard + context.concurrencyType == .mainQueueConcurrencyType + || context.concurrencyType == .privateQueueConcurrencyType + else { block() return } @@ -292,7 +323,7 @@ extension ContextManager { } public static var shared: ContextManager { - return sharedInstance() + sharedInstance() } } From 39736ba75b4ac70379e7bb7aa3b2bf16488044ab Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 2 Jul 2026 12:56:47 +1200 Subject: [PATCH 2/2] Load the current Core Data model once per process Each ContextManager instance used to load its own NSManagedObjectModel from WordPress.momd. Every loaded model registers its entities in Core Data's process-global class-to-entity table, so in unit tests (where each test creates a ContextManager) identical models accumulated and +entity could not disambiguate them. The current model is now cached in a static and shared by all instances, while versioned models for migrations still load per request. --- .../WordPressData/Swift/ContextManager.swift | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/WordPressData/Swift/ContextManager.swift b/Modules/Sources/WordPressData/Swift/ContextManager.swift index a6914f6adb11..8c76962a53f2 100644 --- a/Modules/Sources/WordPressData/Swift/ContextManager.swift +++ b/Modules/Sources/WordPressData/Swift/ContextManager.swift @@ -252,19 +252,45 @@ private extension ContextManager { // MARK: - Initialise Core Data stack private extension ContextManager { - static func createPersistentContainer(storeURL: URL, modelName: String) -> NSPersistentContainer { - guard var modelFileURL = Bundle.wordPressData.url(forResource: "WordPress", withExtension: "momd") else { + /// The current version of the data model, loaded once per process. + /// + /// Every loaded `NSManagedObjectModel` registers its entities in Core Data's global + /// class-to-entity table. Loading the model file more than once (which happens in unit + /// tests, where each test creates its own `ContextManager` instance) leaves multiple + /// entity descriptions claiming the same `NSManagedObject` subclasses, and + /// `+[NSManagedObject entity]` fails to resolve them. + static let currentObjectModel: NSManagedObjectModel = { + guard let modelFileURL = Bundle.wordPressData.url(forResource: "WordPress", withExtension: "momd") else { fatalError("Can't find WordPress.momd") } - if modelName != ContextManagerModelNameCurrent { - modelFileURL = modelFileURL.appendingPathComponent(modelName).appendingPathExtension("mom") + guard let objectModel = NSManagedObjectModel(contentsOf: modelFileURL) else { + fatalError("Can't create object model at \(modelFileURL)") + } + + return objectModel + }() + + static func objectModel(named modelName: String) -> NSManagedObjectModel { + guard modelName != ContextManagerModelNameCurrent else { + return currentObjectModel } - guard let objectModel = NSManagedObjectModel(contentsOf: modelFileURL) else { - fatalError("Can't create object model named \(modelName) at \(modelFileURL)") + guard let modelFileURL = Bundle.wordPressData.url(forResource: "WordPress", withExtension: "momd") else { + fatalError("Can't find WordPress.momd") + } + + let versionedModelURL = modelFileURL.appendingPathComponent(modelName).appendingPathExtension("mom") + guard let objectModel = NSManagedObjectModel(contentsOf: versionedModelURL) else { + fatalError("Can't create object model named \(modelName) at \(versionedModelURL)") } + return objectModel + } + + static func createPersistentContainer(storeURL: URL, modelName: String) -> NSPersistentContainer { + let objectModel = objectModel(named: modelName) + // FIXME: Import the Sentry stuff, too — But it accesses the app delegate! // let startupEvent = SentryStartupEvent()