Core Data with TCA

Hi guys, I've started using ComposableArchitecture and I am currently in the Effect part of the video series.

I'm working on a side project with ComposableArchitecture and I'm wondering if this is the correct way to model a Core Data Repository:

public final class AccountRepository: Repository {

    public init(context: NSManagedObjectContext) {
        self.context = context
    }

    // MARK: Stored Properties
    public let context: NSManagedObjectContext

    // MARK: Methods
    public func getAll() -> Effect<[ThreadSafe.Account], Error> {
        let context = self.context
        return Effect<[ThreadSafe.Account], Error>.result { [context] () -> Result<[ThreadSafe.Account], Error> in
            Result<[ThreadSafe.Account], Error> {
                let accounts = try context.fetch(RepositoryModel.fetchRequest)
                return accounts.map(\.asValue)
            }
        }
    }

    public func create(model: ThreadSafe.Account) -> Effect<Void, Error> {
        let context = self.context
        return Effect<Void, Error>.result { [context] () -> Result<Void, Error> in
            Result<Void, Error> {
                _ = model.asEntity(in: context)
                try context.save()
            }
        }
    }

    public func deleteModel(by id: NSManagedObjectID) -> Effect<Void, Error> {
        let context = self.context
        return Effect<Void, Error>.result { [context] () -> Result<Void, Error> in
            Result<Void, Error> {
                let object = context.object(with: id)
                context.delete(object)
                try context.save()
            }
        }
    }

    public func update(model: ThreadSafe.Account, id: NSManagedObjectID) -> Effect<Void, Error> {
        let context = self.context
        return Effect<Void, Error>.result { [context] () -> Result<Void, Error> in
            Result<Void, Error> {
                guard let databaseModel = context.object(with: id) as? Account else { fatalError() }
                databaseModel.name = model.name
                try context.save()
            }
        }
    }

}

My AccountAction, AccountState, and AccountEnvironment:

public struct AccountState: Equatable {
    public var accounts: [ThreadSafe.Account]
}

public enum AccountAction: Equatable {
    case addAccount(ThreadSafe.Account)
    case deleteAccount(NSManagedObjectID)
    case fetchAccounts
    case getAccounts(Result<[ThreadSafe.Account], Error>)
    case updateAccount(ThreadSafe.Account, NSManagedObjectID)

    public static func == (lhs: AccountAction, rhs: AccountAction) -> Bool {
        switch (lhs, rhs) {
            case let (.addAccount((lValue)), .addAccount((rValue))):
                return lValue == rValue
            case (.fetchAccounts, .fetchAccounts):
                return true
            case let (.getAccounts(.success(lValue)), .getAccounts(.success(rValue))):
                return lValue == rValue
            case let (.getAccounts(.failure(lValue)), .getAccounts(.failure(rValue))):
                return lValue.localizedDescription == rValue.localizedDescription
                case let (.updateAccount(lValue, lID), .updateAccount(rValue, rID)):
                return lValue.name == rValue.name && lID == rID
            case let (.deleteAccount(lValue), .deleteAccount(rValue)):
                return lValue == rValue
            default:
                return false
        }
    }
}

public struct AccountEnvironment {
    public var backgroundQueue: AnySchedulerOf<DispatchQueue>
    public var mainQueue: AnySchedulerOf<DispatchQueue>
    public var repository: AccountRepository
}

public let accountReducer = Reducer<AccountState, AccountAction, AccountEnvironment> {
    (state: inout AccountState, action: AccountAction, env: AccountEnvironment) -> Effect<AccountAction, Never> in
    switch action {
        case .addAccount(let newAccount):
            return env.repository.create(model: newAccount)
                .subscribe(on: env.backgroundQueue)
                .receive(on: env.mainQueue)
                .catchToEffect()
                .map { _ in AccountAction.fetchAccounts }

        case .deleteAccount(let id):
            return env.repository.deleteModel(by: id)
                .subscribe(on: env.backgroundQueue)
                .receive(on: env.mainQueue)
                .catchToEffect()
                .map { _ in AccountAction.fetchAccounts }

        case .fetchAccounts:
            return env.repository.getAll()
                .subscribe(on: env.backgroundQueue)
                .receive(on: env.mainQueue)
                .catchToEffect()
                .map(AccountAction.getAccounts)

        case .getAccounts(let result):
            if case let .success(accounts) = result {
                state.accounts = accounts
            }
            return .none

        case let .updateAccount(account, id):
            return env.repository.update(model: account, id: id)
                .subscribe(on: env.backgroundQueue)
                .receive(on: env.mainQueue)
                .catchToEffect()
                .map { _ in AccountAction.fetchAccounts }
    }
}

My CoreData Entity:

// sourcery: AsValue
extension Account {

    @nonobjc public class func fetchRequest() -> NSFetchRequest<Account> {
        return NSFetchRequest<Account>(entityName: "Account")
    }

    @NSManaged public var name: String?
    @NSManaged public var startingBalance: Double
    @NSManaged public var transactions: Set<Transaction>?

}

My Struct Account

/**
 Namespace for structs of classes annotated with AsValue.
*/
public enum ThreadSafe {
    /**
     The struct equivalent of Account. Use this data structure if you want an instance of Account with
     value type semantics.
    */
    public struct Account: Hashable, Identifiable, Model {

        /**
         The NSFetchRequest of MyApp.Account.
        */
        public static var fetchRequest: NSFetchRequest<MyApp.Account> {
            return MyApp.Account.fetchRequest()
        }

        public var id: ThreadSafe.Account {
            return self
        }

        /**
         The NSManagedObjectID of the Core Data entity this struct represents if that entity is managed by a persistent store. Otherwise this is nil,
         meaning the Core Data entity this represents is currently unmanaged.
        */
        public var objectID: NSManagedObjectID?

        /**
         Identical property of Account's name.
        */
        public var name: String?
        /**
         Identical property of Account's startingBalance.
        */
        public var startingBalance: Double
        /**
         Identical property of Account's transactions.
        */
        public var transactions: Set<Transaction>?

        /**
          Transforms the Account struct into its mirrored NSManagedObject subclass
          - parameter context: NSManagedObjectContext where the generated entity is inserted to.
          - returns: An Account with identical properties as this instance.
        */
        public func asEntity(in context: NSManagedObjectContext) -> MyApp.Account  {
            let entity: MyApp.Account = MyApp.Account(context: context)
            entity.name = self.name
            entity.startingBalance = self.startingBalance
            entity.transactions = Set(self.transactions?.map { $0.asEntity(in: context)} ?? [])
            return entity
        }
    }

One thing that strikes me is that managed objects may not be accessed on the correct queue and therefore violate Core Data’s queue confinement rules. That subscribeOn operator may be problematic.

Depending on the size of your object graph and other performance concerns, you might also want to consider resetting contexts after fetching.

I edited my post so its more clear why threading isn't an issue for me. I'm mapping to and from a code generated value type (via Sourcery) that represents the Core Data entity.

The subscribeOn method ensures the work at the start is done on a background serial queue but received on the main thread according to the ComposableArchitecture's advice

Can you elaborate on this clearing of context?

Threading is an issue for you here, not because of anything related to the Composable Architecture but rather related to how you're using managed objects. The documentation is pretty clear about this: managed objects can only be accessed on the queue they're confined to. In practice, this turns out to mean that using a managed object must only be within the scope of the closure passed to the perform or performBlockAndWait methods because the queue of the context is a private implementation detail. If you do not respect this, you're walking straight into multithreading issues and other strangeness. If a managed object is owned by a context initialised with the main queue confinement type, you can dispatch to the main queue rather than using the context's perform API.

Here's some documentation about this.

Hope this helps!

In all the repository methods, the managed object is initialized inside the Result closure and never passed between threads. The only method that passes objects is getAll where the NSManagedObjects are immediately transformed into structs and those structs are passed between threads.

Can you point out where I'm accessing managed objects outside of the thread they're instantiated at?

ThreadSafe.Account = struct version of MyApp.Account (the Core Data entity)

EDIT:
I think I understand what you mean. I must wrap my Result closures in a perform or performAndWait closure right?

something like this:

public func getAll() -> Effect<[ThreadSafe.Account], Error> {
    let context = self.context
    return Effect<[ThreadSafe.Account], Error>.future { [context] (result: @escaping (Result<[ThreadSafe.Account], Error>) -> Void) -> Void in
        context.performAndWait { [context] () -> Void in
            result(
                Result<[ThreadSafe.Account], Error> {
                    let accounts = try context.fetch(RepositoryModel.fetchRequest)
                    return accounts.map(\.asValue)
                }
            )
        }
    }
}

@hooliooo you also should add the -com.apple.CoreData.ConcurrencyDebug 1 launch argument to the Run action of your build scheme in Xcode.

Terms of Service

Privacy Policy

Cookie Policy