swiftcore-datansfetchrequest

Core Data NSFetchRequest returns empty objects


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?


Solution

  • 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.