Skip to content

roanutil/CoreDataRepository

Repository files navigation

CoreDataRepository

CI codecov

📣 Checkout the discussion for SwiftData

CoreDataRepository is a library for using CoreData on a background queue. It features endpoints for CRUD, batch, fetch, and aggregate operations. Also, it offers a stream like subscription for fetch and read.

Since NSManagedObjects are not thread safe, a value type model must exist for each NSMangaedObject subclass.

Motivation

CoreData is a great framework for local persistence on Apple's platforms. However, it can be tempting to create strong dependencies on it throughout an app. Even worse, the viewContext runs on the main DispatchQueue along with the UI. Even fetching data from the store can be enough to cause performance problems.

The goals of CoreDataRepository are:

  • Ease isolation of CoreData related code away from the rest of the app.
  • Improve ergonomics by providing an asynchronous API.
  • Improve usability of private contexts to relieve load from the main DispatchQueue.
  • Make local persistence with CoreData feel more 'Swift-like' by allowing the model layer to use value types.

Mapping NSManagedObjects to value types

It may feel convoluted to add this layer of abstraction over local persistence and the overhead of mapping between objects and value types. Similar to the motivation for only exposing views to the minimum data they need, why should the model layer be concerned with the details of the persistence layer? NSManagedObjects are complicated types that really should be isolated as much as possible.

To give some weight to this idea, here's a quote from the Q&A portion of this talk by Andy Matuschak:

Q: How do dependencies work out? It seems like the greatest value of using values is in the model layer, yet that’s the layer at which you have the most dependencies across the rest of your app, which is probably in Objective-C.

Andy: In my experience, we had a CoreData stack, which is the opposite of isolation. Our strategy was putting a layer about the CoreData layer that would perform queries and return values. But where would we add functionality in the model layer? As far as using values in the view layer, we do a lot of that actually. We have a table view cell all the way down the stack that will render some icon and a label. The traditional thing to do would be to pass the ManagedObject for that content to the cell, but it doesn’t need that. There’s no reason to create this dependency between the cell and everything the model knows about, and so we make these lightweight little value types that the view needs. The owner of the view can populate that value type and give it to the view. We make these things called presenters that given some model can compute the view data. Then the thing which owns the presenter can pass the results into the view.

Basic Usage

Model Bridging

There are various protocols for defining how a value type should bridge to the corresponding NSManagedObject subclass. Each protocol is intended for a general pattern of use.

A single value type can conform to multiple protocols to combine their supported functionality. A single NSManagedObject subclass can be bridged to by multiple value types.

  • FetchableUnmanagedModel for types that will be queried through 'fetch' endpoints.
  • ReadableUnmanagedModel for types that can be accessed individually. Inherits from FetchableUnmanagedModel.
    • IdentifiedUnmanagedModel for ReadableUnmanagedModel types that have a unique, hashable ID value.
    • ManagedIdReferencable for ReadableUnmanagedModel types that store their NSManagedObjectID.
    • ManagedIdUrlReferencable for ReadableUnmanagedModel types that store their NSManagedObjectID in URL form.
  • WritableUnmanagedModel for that types that need to write to the store via create, update, and delete operations.
  • UnmanagedModel for types that conform to both ReadableUnmanagedModel and WritableUnmanagedModel.

UnmanagedModel

@objc(ManagedMovie)
public final class ManagedMovie: NSManagedObject {
    @NSManaged var id: UUID?
    @NSManaged var title: String?
    @NSManaged var releaseDate: Date?
    @NSManaged var boxOffice: Decimal?
}

public struct Movie: Equatable, ManagedIdUrlReferencable, Sendable {
    public let id: UUID
    public var title: String = ""
    public var releaseDate: Date
    public var boxOffice: Decimal = 0
    public var managedIdUrl: URL?
}

extension Movie: FetchableUnmanagedModel {
    public init(managed: ManagedMovie) {
        self.id = managed.id
        self.title = managed.title
        self.releaseDate = managed.releaseDate
        self.boxOffice = managed.boxOffice
        self.managedIdUrl = managed.objectID.uriRepresentation()
    }
}

extension Movie: ReadableUnmanagedModel {}

extension Movie: WritableUnmanagedModel {
    public func updating(managed: ManagedMovie) throws {
        managed.id = id
        managed.title = title
        managed.releaseDate = releaseDate
        managed.boxOffice = boxOffice
    }
}

extension Movie: UnmanagedModel {}

CRUD

var movie = Movie(id: UUID(), title: "The Madagascar Penguins in a Christmas Caper", releaseDate: Date(), boxOffice: 100)
let result: Result<Movie, CoreDataError> = await repository.create(movie)
if case let .success(movie) = result {
    os_log("Created movie with title - \(movie.title)")
}

Fetch

let fetchRequest = Movie.managedFetchRequest
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \ManagedMovie.title, ascending: true)]
fetchRequest.predicate = NSPredicate(value: true)
let result: Result<[Movie], CoreDataError> = await repository.fetch(fetchRequest)
if case let .success(movies) = result {
    os_log("Fetched \(movies.count) movies")
}

Fetch Subscription

Similar to a regular fetch:

let stream: AsyncThrowingStream<[Movie], any Error> = repository.fetchThrowingSubscription(fetchRequest)
for try await movies in stream {
    os_log("Fetched \(movies.count) movies")
}

Aggregate

let result: Result<[[String: Decimal]], CoreDataError> = await repository.sum(
    predicate: NSPredicate(value: true),
    entityDesc: ManagedMovie.entity(),
    attributeDesc: ManagedMovie.entity().attributesByName.values.first(where: { $0.name == "boxOffice" })!
)
if case let .success(values) = result {
    os_log("The sum of all movies' boxOffice is \(values.first!.values.first!)")
}

Batch

let movies: [[String: Any]] = [
    ["id": UUID(), "title": "A", "releaseDate": Date()],
    ["id": UUID(), "title": "B", "releaseDate": Date()],
    ["id": UUID(), "title": "C", "releaseDate": Date()],
    ["id": UUID(), "title": "D", "releaseDate": Date()],
    ["id": UUID(), "title": "E", "releaseDate": Date()]
]
let request = NSBatchInsertRequest(entityName: ManagedMovie.entity().name!, objects: movies)
let result: Result<NSBatchInsertResult, CoreDataError> = await repository.insert(request)

OR

let movies: [Movie] = [
    Movie(id: UUID(), title: "A", releaseDate: Date()),
    Movie(id: UUID(), title: "B", releaseDate: Date()),
    Movie(id: UUID(), title: "C", releaseDate: Date()),
    Movie(id: UUID(), title: "D", releaseDate: Date()),
    Movie(id: UUID(), title: "E", releaseDate: Date())
]
let result: (success: [Movie], failed: [Movie]) = await repository.create(movies)
os_log("Created these movies: \(result.success)")
os_log("Failed to create these movies: \(result.failed)")

Transactions

Use withTransaction to group multiple operations together atomically:

let newMovies = [
    Movie(id: UUID(), title: "Movie A", releaseDate: Date(), boxOffice: 1000),
    Movie(id: UUID(), title: "Movie B", releaseDate: Date(), boxOffice: 2000)
]

// All operations within the transaction will succeed or fail together
let result = try await repository.withTransaction(transactionAuthor: "BulkMovieImport") { transaction in
    var createdMovies: [Movie] = []

    for movie in newMovies {
        let createResult = try await repository.create(movie).get()
        createdMovies.append(createResult)
    }

    // Update existing movie
    let fetchRequest = Movie.managedFetchRequest
    fetchRequest.predicate = NSPredicate(format: "title == %@", "Old Movie")
    if let existingMovie = try await repository.fetch(fetchRequest).get().first {
        var updatedMovie = existingMovie
        updatedMovie.boxOffice = 5000
        _ = try await repository.update(updatedMovie).get()
    }

    return createdMovies
}

os_log("Transaction completed with \(result.count) new movies")

Important: When using batch operations within transactions, don't specify transactionAuthor for individual operations as it's handled at the transaction level:

// ✅ Correct - transactionAuthor only on withTransaction
try await repository.withTransaction(transactionAuthor: "BatchUpdate") { _ in
    let request = NSBatchUpdateRequest(entityName: "ManagedMovie")
    request.propertiesToUpdate = ["boxOffice": 0]
    return await repository.update(request) // No transactionAuthor here
}

// ❌ Incorrect - don't specify transactionAuthor on both
try await repository.withTransaction(transactionAuthor: "BatchUpdate") { _ in
    let request = NSBatchUpdateRequest(entityName: "ManagedMovie")
    request.propertiesToUpdate = ["boxOffice": 0]
    return await repository.update(request, transactionAuthor: "BatchUpdate") // Ignored
}

Contributing

I welcome any feedback or contributions. It's probably best to create an issue where any possible changes can be discussed before doing the work and creating a PR.

About

An async library for using CoreData in the background

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •  

Languages