I am facing an issue with an NSFetchRequest returning object with "empty" properties, even though it is correctly retrieved from database and returnsObjectsAsFaults is set to false.
// Object
@objc(Task)
final class Task: NSManagedObject {
@NSManaged var id: String
@NSManaged var name: String
@NSManaged var summary: String?
@NSManaged var completionDate: Date?
}
// Core Data store
final class CoreDataStore {
static let modelName = "Store"
private static let model = NSManagedObjectModel.with(name: modelName, in: Bundle(for: CoreDataStore.self))
enum StoreError: Error {
case modelNotFound
case failedToLoadPersistentContainer(Error)
}
let container: NSPersistentContainer
let context: NSManagedObjectContext
init(storeURL: URL) throws {
guard let model = CoreDataStore.model else {
throw StoreError.modelNotFound
}
do {
container = try NSPersistentContainer.load(name: CoreDataStore.modelName, model: model, url: storeURL)
context = container.newBackgroundContext()
} catch {
throw StoreError.failedToLoadPersistentContainer(error)
}
}
func perform(_ action: @escaping (NSManagedObjectContext) -> Void) {
let context = self.context
context.perform { action(context) }
}
}
extension NSPersistentContainer {
static func load(name: String, model: NSManagedObjectModel, url: URL) throws -> NSPersistentContainer {
let description = NSPersistentStoreDescription(url: url)
let container = NSPersistentContainer(name: name, managedObjectModel: model)
container.persistentStoreDescriptions = [description]
var loadError: Swift.Error?
container.loadPersistentStores { loadError = $1 }
try loadError.map { throw $0 }
return container
}
}
extension NSManagedObjectModel {
static func with(name: String, in bundle: Bundle) -> NSManagedObjectModel? {
return bundle
.url(forResource: name, withExtension: "momd")
.flatMap { NSManagedObjectModel(contentsOf: $0) }
}
}
// Testing class
final class CoreDataFetchTests: XCTestCase {
override func setUpWithError() throws {
try super.setUpWithError()
try deleteStoreArtifacts()
}
override func tearDownWithError() throws {
try super.tearDownWithError()
try deleteStoreArtifacts()
}
func test_fetch_deliversExpectedObject() async throws {
let storeURL = try testSpecificStoreURL()
let task: [String: Any] = [
"id": "id123",
"name": "a name",
"summary": "a summary"
]
try await prefillStore(storeURL: storeURL, objects: [task])
let tasks = try await loadTasksFromStore(storeURL: storeURL)
XCTAssertEqual(tasks.count, 1)
let firstTask = try XCTUnwrap(tasks.first)
print(firstTask)
XCTAssertEqual(firstTask.name, "a name") // Failling here because all properties are empty
}
}
private extension CoreDataFetchTests {
func loadTasksFromStore(storeURL: URL) async throws -> [Task] {
let store = try CoreDataStore(storeURL: storeURL)
let context = store.context
return try await context.perform {
let request = NSFetchRequest<Task>(entityName: "Task")
request.returnsObjectsAsFaults = false
return try context.fetch(request)
}
}
func prefillStore(storeURL: URL, objects: [[String: Any]]) async throws {
let container = try makeContainer(storeURL: storeURL)
let context = container.viewContext
try await context.perform {
_ = try context.execute(NSBatchInsertRequest(
entityName: "Task",
objects: objects
))
}
}
func makeContainer(storeURL: URL) throws -> NSPersistentContainer {
let bundle = Bundle(for: CoreDataStore.self)
let model = try XCTUnwrap(
bundle
.url(
forResource: "Store.momd/Store",
withExtension: "mom"
)
.flatMap { NSManagedObjectModel(contentsOf: $0) }
)
let description = NSPersistentStoreDescription(url: storeURL)
let container = NSPersistentContainer(
name: "Store",
managedObjectModel: model
)
container.persistentStoreDescriptions = [description]
var loadError: Error?
container.loadPersistentStores { loadError = $1 }
try loadError.map { throw $0 }
return container
}
func testSpecificStoreURL() throws -> URL {
try XCTUnwrap(
FileManager
.default
.urls(for: .cachesDirectory, in: .userDomainMask)
.first?
.appendingPathComponent("\(type(of: self)).store")
)
}
func deleteStoreArtifacts() throws {
let storeURL = try testSpecificStoreURL()
guard FileManager.default.fileExists(atPath: storeURL.path) else { return }
try FileManager.default.removeItem(at: storeURL)
}
}
// Core data fetch logs
CoreData: annotation: fetch using NSSQLiteStatement <0x600002119bd0> on entity 'Task' with sql text 'SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZCOMPLETIONDATE, t0.ZID, t0.ZNAME, t0.ZSUMMARY FROM ZTASK t0 ' returned 1 rows
CoreData: annotation: with values: (
"<Task: 0x600002119c70> (entity: Task; id: 0xbb6a8453c0f08478 <x-coredata://274749AF-C595-4279-BD2C-9ACFD22E27E4/Task/p1>; data: {\n completionDate = nil;\n id = id123;\n name = \"a name\";\n summary = \"a summary\";\n})"
)
CoreData: annotation: total fetch execution time: 0.0003s for 1 rows.
CoreData: annotation: Disconnecting from sqlite database.
// Logging the object
<Task: 0x600002119c70> (entity: Task; id: 0xbb6a8453c0f08478 <x-coredata://274749AF-C595-4279-BD2C-9ACFD22E27E4/Task/p1>; data: <fault>)
// Test assertion failing
/.../CoreDataFetch/CoreDataFetchTests/CoreDataFetchTests.swift:39: error: -[CoreDataFetchTests.CoreDataFetchTests test_fetch_deliversExpectedObject] : XCTAssertEqual failed: ("") is not equal to ("a name")
Complete project to reproduce the issue can be found at https://github.com/yonicsurny/CoreDataFetchIssue
Why aren't the properties of the object filled-in?
Ok, we found the issue.
The
loadTasksFromStoremethod creates aCoreDataStoreinstance, fetches the data, then returns. At this stage, theCoreDataStoreis deallocated since it doesn't escape theloadTasksFromStorescope. Along with theCoreDataStore, the context also seems to be deallocated and the managed objects are invalidated. So trying to access any properties in this invalid managed object will returnnil.The solution is to return and hold a reference to the context in the test
This way the objects are not invalidated before the test function exits.