Skip to content

Conversation

nashysolutions
Copy link

Have you considered combine for handling non sendable core data types?

Key points

  • Uses a single NSFetchRequest with SELF IN %@ so filtering, sorting, and optional prefetching happen in the persistent store.
  • Emits on a configurable scheduler (DispatchQueue.main by default) and supports optional debouncing of ID changes.
  • Provides best-effort semantics: missing IDs simply yield fewer objects. (A stricter, throwing variant can be layered on top if needed.)
  • Handles cancellation cleanly—each flatMap inner Future completes or is discarded as soon as its Core Data work finishes.
@MainActor
final class UsersStore: ObservableObject {
    
    @Published private(set) var users: [LocalUser] = []
    
    private var cancellables = Set<AnyCancellable>()
    
    private let repository: UsersRepository
    private let resolver: CoreDataCombineObjectResolver<LocalUser>
    
    init(
        context: NSManagedObjectContext,
        requestExecutor: @escaping (URLRequest) async throws -> (Data, URLResponse)
    ) {
        self.repository = UsersRepository(
            context: context,
            requestExecutor: requestExecutor
        )
        self.resolver = CoreDataCombineObjectResolver<LocalUser>(context: context)
        self.resolver.publisher
            .receive(on: DispatchQueue.main)
            .sink { [weak self] users in
                self?.users = users
            }
            .store(in: &cancellables)
    }

    func loadUsers() async throws {
        let managedObjects = try await repository.downloadAndSaveToDatabase()
        resolver.resolve(managedObjects.map { $0.objectID })
    }
}

Notes for maintainers

Thread confinement

  • All Core Data work happens inside context.perform { … }.
  • We deliver downstream on deliverOn (default .main). If you change the scheduler, ensure UI subscribers still hop to main.

“Best-effort” semantics

  • This resolver is intentionally relaxed: missing IDs simply yield fewer objects.
  • If you need strictness (“throw if any ID missing”), use a batched fetch + compare sets in a separate utility.

RemoveDuplicates vs debounce

  • We dedupe emissions by order-sensitive equality to reduce pointless fetches.
  • For very large ID arrays or bursty updates, consider removing removeDuplicates and relying on a small debounce instead.

Fetch strategy

  • We use a single NSFetchRequest with SELF IN %@ so sorting/prefetching can be done by the store.
  • Beware huge IN lists (thousands of IDs) — consider chunking (e.g. 500–1000 IDs) and concatenating results.

Sorting

  • sortDescriptors are pushed into the fetch (better than sorting in memory).
  • If you need to preserve the caller’s ID order, do not set sortDescriptors; instead, reorder results against the incoming IDs post-fetch.

Prefetching

  • If you routinely touch relationships right after the fetch, expose a prefetch parameter and set request.relationshipKeyPathsForPrefetching = [...] to avoid a wall of faults.

Error handling

  • This stream is Never-failing by design (errors map to []). If you need visibility for diagnostics, consider logging the caught error (e.g. os_log) or expose a secondary error publisher.

Context type

  • Context captured strongly on purpose; weak isn’t useful here.
  • If using a background MOC, keep receive(on:) to the main queue for UI.

Model mismatches

  • If T.entity().name is nil or not in the model, we currently emit []. You may prefer throwing or surfacing an assertion in debug.

Cancellation

  • Combine handles cancellation automatically (cancellable.cancel()). Each flatMap inner Future is short-lived and will not retain work beyond perform { … }.

.removeDuplicates(by: { lhs, rhs in
lhs.count == rhs.count && lhs.elementsEqual(rhs, by: { $0 == $1 })
})
.eraseToAnyPublisher()
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We erase to AnyPublisher early so we can reassign the variable after applying operators like debounce, because each Combine operator produces a different concrete publisher type.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant