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 loadTasksFromStore
method creates a CoreDataStore
instance, fetches the data, then returns. At this stage, the CoreDataStore
is deallocated since it doesn't escape the loadTasksFromStore
scope. Along with the CoreDataStore
, 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 return nil
.
The solution is to return and hold a reference to the context in the test
func loadTasksFromStore(storeURL: URL) async throws -> ([Task], NSManagedObjectContext) {
let store = try CoreDataStore(storeURL: storeURL)
let context = store.context
let tasks = try await context.perform {
let request = NSFetchRequest<Task>(entityName: "Task")
request.returnsObjectsAsFaults = false
return try context.fetch(request)
}
return (tasks, context) // in the test, hold a reference to the context
}
This way the objects are not invalidated before the test function exits.