From f0c61ea632b26dae42f866d2676bc79453f6647e Mon Sep 17 00:00:00 2001 From: Petky Benedek Date: Wed, 21 Jan 2026 15:40:43 +0100 Subject: [PATCH 1/7] Create AsyncStore refs: MBL-19677 builds: Student, Teacher, Parent affects: Student, Teacher, Parent release note: none --- .../AnalyticsMetadataInteractor.swift | 4 +- .../CommonModels/Store/AsyncStore.swift | 299 ++++++++++++++++++ .../Common/CommonModels/Store/UseCase.swift | 24 ++ .../Combine/PublisherExtensions.swift | 27 +- .../NSPersistentContainerExtensions.swift | 19 +- 5 files changed, 361 insertions(+), 12 deletions(-) create mode 100644 Core/Core/Common/CommonModels/Store/AsyncStore.swift diff --git a/Core/Core/Common/CommonModels/Analytics/AnalyticsMetadataInteractor.swift b/Core/Core/Common/CommonModels/Analytics/AnalyticsMetadataInteractor.swift index 3df451dddf..82ddcca8d2 100644 --- a/Core/Core/Common/CommonModels/Analytics/AnalyticsMetadataInteractor.swift +++ b/Core/Core/Common/CommonModels/Analytics/AnalyticsMetadataInteractor.swift @@ -47,8 +47,8 @@ public class AnalyticsMetadataInteractorLive: AnalyticsMetadataInteractor { // Both stores publish non-managed-object values to avoid accessing the managed objects // from arbitrary threads which happen to call this method - async let flagEnabledPublisher = flagEnabledStore.asyncPublisher() - async let userPublisher = userStore.asyncPublisher() + async let flagEnabledPublisher = flagEnabledStore.asyncValue() + async let userPublisher = userStore.asyncValue() let isFlagEnabled = try await flagEnabledPublisher let user = try await userPublisher diff --git a/Core/Core/Common/CommonModels/Store/AsyncStore.swift b/Core/Core/Common/CommonModels/Store/AsyncStore.swift new file mode 100644 index 0000000000..c3a2a95503 --- /dev/null +++ b/Core/Core/Common/CommonModels/Store/AsyncStore.swift @@ -0,0 +1,299 @@ +// +// This file is part of Canvas. +// Copyright (C) 2026-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Foundation +import CoreData + +public struct AsyncStore { + fileprivate let offlineModeInteractor: OfflineModeInteractor? + internal let useCase: U + fileprivate let context: NSManagedObjectContext + fileprivate let environment: AppEnvironment + + public init( + offlineModeInteractor: OfflineModeInteractor? = OfflineModeAssembly.make(), + context: NSManagedObjectContext = AppEnvironment.shared.database.viewContext, + useCase: U, + environment: AppEnvironment = .shared + ) { + self.offlineModeInteractor = offlineModeInteractor + self.useCase = useCase.modified(for: environment) + self.context = context + self.environment = environment + } + + /// Produces a list of entities for the given UseCase. + /// When the device is connected to the internet and there's no valid cache, it makes a request to the API and saves the response to the database. If there's valid cache, it returns it. + /// By default it downloads all pages, and validates cache unless specificied differently. + /// When the device is offline, it will read data from Core Data. + /// - Parameters: + /// - ignoreCache: Indicates if the request should check the available cache first. + /// If it's set to **false**, it will validate the cache's expiration and return it if it's still valid. If the cache has expired it will make a request to the API. + /// If it's set to **true**, it will make a request to the API. + /// Defaults to **false**. + /// - loadAllPages: Tells the request if it should load all the pages or just the first one. Defaults to **true**. + /// - Returns: A list of entities. + public func getEntities(ignoreCache: Bool = false, loadAllPages: Bool = true) async throws -> [U.Model] { + let scope = useCase.scope + let request = NSFetchRequest(entityName: String(describing: U.Model.self)) + request.predicate = scope.predicate + request.sortDescriptors = scope.order + + + return if offlineModeInteractor?.isOfflineModeEnabled() == true { + try await Self.fetchEntitiesFromDatabase( + fetchRequest: request, + context: context + ) + } else { + if ignoreCache { + try await Self.fetchEntitiesFromAPI( + useCase: useCase, + loadAllPages: loadAllPages, + fetchRequest: request, + context: context, + environment: environment + ) + } else { + try await Self.fetchEntitiesFromCache( + useCase: useCase, + fetchRequest: request, + loadAllPages: loadAllPages, + context: context, + environment: environment + ) + } + } + } + + /// Produces an async sequence of entities for the given UseCase keeping track of database changes. + /// When the device is connected to the internet and there's no valid cache, it makes a request to the API and saves the response to the database. If there's valid cache, it returns it. + /// By default it downloads all pages, and validates cache unless specificied differently. + /// When the device is offline, it will read data from Core Data. + + /// - Parameters: + /// - ignoreCache: Indicates if the request should check the available cache first. + /// If it's set to **false**, it will validate the cache's expiration and return it if it's still valid. If the cache has expired it will make a request to the API. + /// If it's set to **true**, it will make a request to the API. + /// Defaults to **false**. + /// - loadAllPages: Tells the request if it should load all the pages or just the first one. Defaults to **true**. + /// - Returns: An async sequence of list of entities. + public func streamEntities(ignoreCache: Bool = false, loadAllPages: Bool = true) async throws -> AsyncThrowingStream<[U.Model], Error> { + let scope = useCase.scope + let request = NSFetchRequest(entityName: String(describing: U.Model.self)) + request.predicate = scope.predicate + request.sortDescriptors = scope.order + + return if offlineModeInteractor?.isOfflineModeEnabled() == true { + Self.streamEntitiesFromDatabase( + fetchRequest: request, + context: context + ) + } else { + if ignoreCache { + try await Self.streamEntitiesFromAPI( + useCase: useCase, + loadAllPages: loadAllPages, + fetchRequest: request, + context: context, + environment: environment + ) + } else { + try await Self.streamEntitiesFromCache( + useCase: useCase, + fetchRequest: request, + loadAllPages: loadAllPages, + context: context, + environment: environment + ) + } + } + } + + public func getEntitiesFromDatabase() async throws -> [U.Model] { + let scope = useCase.scope + let request = NSFetchRequest(entityName: String(describing: U.Model.self)) + request.predicate = scope.predicate + request.sortDescriptors = scope.order + + return try await Self.fetchEntitiesFromDatabase(fetchRequest: request, context: context) + } + + /// - Warning: This stream **does not terminate**. Ensure proper cancellation of its consuming task. + public func streamEntitiesFromDatabase() throws -> AsyncThrowingStream<[U.Model], Error> { + let scope = useCase.scope + let request = NSFetchRequest(entityName: String(describing: U.Model.self)) + request.predicate = scope.predicate + request.sortDescriptors = scope.order + + return Self.streamEntitiesFromDatabase(fetchRequest: request, context: context) + } + + /// Refreshes the entities by requesting the latest data from the API. + public func forceRefresh(loadAllPages: Bool = true) async { + _ = try? await getEntities(ignoreCache: true, loadAllPages: loadAllPages) + } + + private static func fetchEntitiesFromCache( + useCase: U, + fetchRequest: NSFetchRequest, + loadAllPages: Bool, + context: NSManagedObjectContext, + environment: AppEnvironment + ) async throws -> [T] { + let hasExpired = await useCase.hasCacheExpired(environment: environment) + + return if hasExpired { + try await Self.fetchEntitiesFromAPI( + useCase: useCase, + loadAllPages: loadAllPages, + fetchRequest: fetchRequest, + context: context, + environment: environment + ) + } else { + try await Self.fetchEntitiesFromDatabase(fetchRequest: fetchRequest, context: context) + } + } + + private static func streamEntitiesFromCache( + useCase: U, + fetchRequest: NSFetchRequest, + loadAllPages: Bool, + context: NSManagedObjectContext, + environment: AppEnvironment + ) async throws -> AsyncThrowingStream<[T], Error> { + let hasExpired = await useCase.hasCacheExpired(environment: environment) + + return if hasExpired { + try await Self.streamEntitiesFromAPI( + useCase: useCase, + loadAllPages: loadAllPages, + fetchRequest: fetchRequest, + context: context, + environment: environment + ) + } else { + Self.streamEntitiesFromDatabase(fetchRequest: fetchRequest, context: context) + } + } + + private static func fetchEntitiesFromAPI( + useCase: U, + getNextUseCase: GetNextUseCase? = nil, + loadAllPages: Bool, + fetchRequest: NSFetchRequest, + context: NSManagedObjectContext, + environment: AppEnvironment + ) async throws -> [T] { + let urlResponse = if let getNextUseCase { + try await getNextUseCase.fetch(environment: environment) + } else { + try await useCase.fetch(environment: environment) + } + + let nextResponse = urlResponse.flatMap { useCase.getNext(from: $0) } + try await Self.fetchAllPagesIfNeeded( + useCase: useCase, + loadAllPages: loadAllPages, + nextResponse: nextResponse, + fetchRequest: fetchRequest, + context: context, + environment: environment + ) + + return try await Self.fetchEntitiesFromDatabase(fetchRequest: fetchRequest, context: context) + } + + private static func streamEntitiesFromAPI( + useCase: U, + getNextUseCase: GetNextUseCase? = nil, + loadAllPages: Bool, + fetchRequest: NSFetchRequest, + context: NSManagedObjectContext, + environment: AppEnvironment + ) async throws -> AsyncThrowingStream<[T], Error> { + let urlResponse = if let getNextUseCase { + try await getNextUseCase.fetch(environment: environment) + } else { + try await useCase.fetch(environment: environment) + } + + let nextResponse = urlResponse.flatMap { useCase.getNext(from: $0) } + try await Self.fetchAllPagesIfNeeded( + useCase: useCase, + loadAllPages: loadAllPages, + nextResponse: nextResponse, + fetchRequest: fetchRequest, + context: context, + environment: environment + ) + + return Self.streamEntitiesFromDatabase(fetchRequest: fetchRequest, context: context) + } + + private static func fetchAllPagesIfNeeded( + useCase: U, + loadAllPages: Bool, + nextResponse: GetNextRequest?, + fetchRequest: NSFetchRequest, + context: NSManagedObjectContext, + environment: AppEnvironment + ) async throws { + guard loadAllPages else { return } + let nextPageUseCase = getNextPage(useCase: useCase, nextResponse: nextResponse) + + if let nextPageUseCase { + _ = try await Self.fetchEntitiesFromAPI( + useCase: useCase, + getNextUseCase: nextPageUseCase, + loadAllPages: true, + fetchRequest: fetchRequest, + context: context, + environment: environment + ) + } + } + + private static func getNextPage( + useCase: U, + nextResponse: GetNextRequest? + ) -> GetNextUseCase? { + if let nextResponse { + GetNextUseCase(parent: useCase, request: nextResponse) + } else { + nil + } + } + + private static func fetchEntitiesFromDatabase( + fetchRequest: NSFetchRequest, + context: NSManagedObjectContext + ) async throws -> [T] { + try await FetchedResultsPublisher(request: fetchRequest, context: context) + .asyncValue() + } + + private static func streamEntitiesFromDatabase( + fetchRequest: NSFetchRequest, + context: NSManagedObjectContext + ) -> AsyncThrowingStream<[T], Error> { + FetchedResultsPublisher(request: fetchRequest, context: context) + .asyncStream() + } +} diff --git a/Core/Core/Common/CommonModels/Store/UseCase.swift b/Core/Core/Common/CommonModels/Store/UseCase.swift index ca27ac372e..22ea62c89b 100644 --- a/Core/Core/Common/CommonModels/Store/UseCase.swift +++ b/Core/Core/Common/CommonModels/Store/UseCase.swift @@ -134,6 +134,13 @@ public extension UseCase { } } + /// Cache expiration check used by the `AsyncStore`. + func hasCacheExpired(environment: AppEnvironment = .shared) async -> Bool { + await environment.database.performWriteTask { context in + self.hasExpired(in: context) + } + } + /// Reactive `fetch()`, used by the `ReactiveStore` and directly from other places. /// Returns the URLResponse after writing to the database. func fetchWithFuture(environment: AppEnvironment = .shared) -> Future { @@ -149,6 +156,12 @@ public extension UseCase { } } + /// Async `fetch()`, used by the `AsyncStore` and directly from other places. + /// Returns the URLResponse after writing to the database. + func fetch(environment: AppEnvironment = .shared) async throws -> URLResponse? { + try await fetchWithAPIResponse(environment: environment).1 + } + /// Reactive `fetch()` that returns both the API response and URLResponse. /// Use this when you need access to the API response data directly. /// The response is optional - it will be nil if the API returned no response body. @@ -160,6 +173,17 @@ public extension UseCase { } } + /// Async `fetch()` that returns both the API response and URLResponse. + /// Use this when you need access to the API response data directly. + /// The response is optional - it will be nil if the API returned no response body. + func fetchWithAPIResponse(environment: AppEnvironment = .shared) async throws -> (Response?, URLResponse?) { + try await withCheckedThrowingContinuation { continuation in + self.executeFetch(environment: environment) { result in + continuation.resume(with: result) + } + } + } + /// Private helper method that executes the fetch and write logic. private func executeFetch( environment: AppEnvironment, diff --git a/Core/Core/Common/Extensions/Combine/PublisherExtensions.swift b/Core/Core/Common/Extensions/Combine/PublisherExtensions.swift index a7dd7fb191..19300ecd08 100644 --- a/Core/Core/Common/Extensions/Combine/PublisherExtensions.swift +++ b/Core/Core/Common/Extensions/Combine/PublisherExtensions.swift @@ -53,19 +53,36 @@ extension Publisher { .eraseToAnyPublisher() } - public func asyncPublisher() async throws -> Output { + public func asyncValue() async throws -> Output { try await withCheckedThrowingContinuation { continuation in var cancellable: AnyCancellable? - cancellable = self.first() - .sink(receiveCompletion: { completion in + cancellable = self + .first() + .sink { completion in if let error = completion.error { continuation.resume(throwing: error) } cancellable?.cancel() - }, receiveValue: { value in + } receiveValue: { value in continuation.resume(returning: value) cancellable?.cancel() - }) + } + } + } + + public func asyncStream() -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let cancellable = self.sink { completion in + if let error = completion.error { + continuation.finish(throwing: error) + } + + continuation.finish() + } receiveValue: { + continuation.yield($0) + } + + continuation.onTermination = { _ in cancellable.cancel() } } } diff --git a/Core/Core/Common/Extensions/CoreData/NSPersistentContainerExtensions.swift b/Core/Core/Common/Extensions/CoreData/NSPersistentContainerExtensions.swift index 39b627db09..4b7d393ca0 100644 --- a/Core/Core/Common/Extensions/CoreData/NSPersistentContainerExtensions.swift +++ b/Core/Core/Common/Extensions/CoreData/NSPersistentContainerExtensions.swift @@ -96,18 +96,27 @@ extension NSPersistentContainer { } @objc open func performWriteTask(_ block: @escaping (NSManagedObjectContext) -> Void) { - let context = writeContext ?? { + context.perform { block(self.context) } + } + + public func performWriteTask(_ block: @escaping (NSManagedObjectContext) throws -> T) async rethrows -> T { + return try await context.perform { try block(self.context) } + } + + // MARK: - Private Methods + + private var context: NSManagedObjectContext { + if let writeContext { + return writeContext + } else { let context = newBackgroundContext() context.automaticallyMergesChangesFromParent = true context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump writeContext = context return context - }() - context.perform { block(context) } + } } - // MARK: - Private Methods - private static func destroyAndReCreatePersistentStore(in container: NSPersistentContainer) { container.destroy() // ignore migration conflicts container.loadPersistentStores { _, error in From a179ad82add00fef819fac3ab2441aa85d45e6b5 Mon Sep 17 00:00:00 2001 From: Petky Benedek Date: Wed, 21 Jan 2026 17:10:48 +0100 Subject: [PATCH 2/7] Correct renaming --- Core/Core/Common/CommonModels/Store/AsyncStore.swift | 1 - .../Domain/CourseListWidgetInteractorTests.swift | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Core/Core/Common/CommonModels/Store/AsyncStore.swift b/Core/Core/Common/CommonModels/Store/AsyncStore.swift index c3a2a95503..6b30c94aa9 100644 --- a/Core/Core/Common/CommonModels/Store/AsyncStore.swift +++ b/Core/Core/Common/CommonModels/Store/AsyncStore.swift @@ -54,7 +54,6 @@ public struct AsyncStore { request.predicate = scope.predicate request.sortDescriptors = scope.order - return if offlineModeInteractor?.isOfflineModeEnabled() == true { try await Self.fetchEntitiesFromDatabase( fetchRequest: request, diff --git a/Horizon/HorizonUnitTests/Features/Dashboard/Domain/CourseListWidgetInteractorTests.swift b/Horizon/HorizonUnitTests/Features/Dashboard/Domain/CourseListWidgetInteractorTests.swift index 35500c863a..0f042d4f80 100644 --- a/Horizon/HorizonUnitTests/Features/Dashboard/Domain/CourseListWidgetInteractorTests.swift +++ b/Horizon/HorizonUnitTests/Features/Dashboard/Domain/CourseListWidgetInteractorTests.swift @@ -38,7 +38,7 @@ final class CourseListWidgetInteractorTests: HorizonTestCase { mockCoursesAPIResponse() // When - let courses = try? await testee.getAndObserveCoursesWithoutModules(ignoreCache: true).asyncPublisher() + let courses = try? await testee.getAndObserveCoursesWithoutModules(ignoreCache: true).asyncValue() // Then XCTAssertEqual(courses?.count, 2) @@ -51,7 +51,7 @@ final class CourseListWidgetInteractorTests: HorizonTestCase { mockCoursesAPIResponseWithProgress() // When - let courses = try? await testee.getAndObserveCoursesWithoutModules(ignoreCache: true).asyncPublisher() + let courses = try? await testee.getAndObserveCoursesWithoutModules(ignoreCache: true).asyncValue() // Then XCTAssertEqual(courses?.count, 4) @@ -68,12 +68,12 @@ final class CourseListWidgetInteractorTests: HorizonTestCase { func testGetCoursesRefresh() async { // Given mockCoursesAPIResponse() - let initialCourses = try? await testee.getAndObserveCoursesWithoutModules(ignoreCache: false).asyncPublisher() + let initialCourses = try? await testee.getAndObserveCoursesWithoutModules(ignoreCache: false).asyncValue() XCTAssertEqual(initialCourses?.count, 2) // When mockCoursesAPIResponseUpdated() - let updatedCourses = try? await testee.getAndObserveCoursesWithoutModules(ignoreCache: true).asyncPublisher() + let updatedCourses = try? await testee.getAndObserveCoursesWithoutModules(ignoreCache: true).asyncValue() // Then XCTAssertEqual(updatedCourses?.count, 3) From 1595afa5e0124015ad02587622603d7581c3ff15 Mon Sep 17 00:00:00 2001 From: Petky Benedek Date: Mon, 26 Jan 2026 14:22:41 +0100 Subject: [PATCH 3/7] Created unit tests for AsyncStore --- .../CommonModels/Store/AsyncStore.swift | 1 + .../CommonModels/Store/AsyncStoreTests.swift | 367 ++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 Core/CoreTests/Common/CommonModels/Store/AsyncStoreTests.swift diff --git a/Core/Core/Common/CommonModels/Store/AsyncStore.swift b/Core/Core/Common/CommonModels/Store/AsyncStore.swift index 6b30c94aa9..a38705de51 100644 --- a/Core/Core/Common/CommonModels/Store/AsyncStore.swift +++ b/Core/Core/Common/CommonModels/Store/AsyncStore.swift @@ -85,6 +85,7 @@ public struct AsyncStore { /// By default it downloads all pages, and validates cache unless specificied differently. /// When the device is offline, it will read data from Core Data. + /// - Warning: This stream **does not terminate**. Ensure proper cancellation of its consuming task. /// - Parameters: /// - ignoreCache: Indicates if the request should check the available cache first. /// If it's set to **false**, it will validate the cache's expiration and return it if it's still valid. If the cache has expired it will make a request to the API. diff --git a/Core/CoreTests/Common/CommonModels/Store/AsyncStoreTests.swift b/Core/CoreTests/Common/CommonModels/Store/AsyncStoreTests.swift new file mode 100644 index 0000000000..94fe18ddc1 --- /dev/null +++ b/Core/CoreTests/Common/CommonModels/Store/AsyncStoreTests.swift @@ -0,0 +1,367 @@ +// +// This file is part of Canvas. +// Copyright (C) 2026-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Foundation +@testable import Core +import CoreData +import XCTest +import TestsFoundation + +final class AsyncStoreTests: CoreTestCase { + var store: AsyncStore! + + override func tearDown() { + super.tearDown() + store = nil + } + + @MainActor + func testErrorHandling() async { + let useCase = TestUseCase(courses: nil, requestError: NSError.instructureError("TestError")) + let testee = createStore(useCase: useCase) + + do { + _ = try await testee.getEntities() + XCTFail("Expected to throw error") + } catch { + XCTAssertTrue(Thread.isMainThread) + } + } + + // MARK: Publishing from Network and Cache + + func testObjectsAreReturnedFromCache() async throws { + Course.save(.make(id: "0"), in: databaseClient) + try databaseClient.save() + let useCase = TestUseCase() + let testee = createStore(useCase: useCase) + + let cache: TTL = databaseClient.insert() + cache.key = useCase.cacheKey ?? "" + cache.lastRefresh = Date() + try databaseClient.save() + + let courses = try await testee.getEntities(ignoreCache: false) + XCTAssertEqual(courses.map { $0.id }, ["0"]) + } + + func testObjectsAreReturnedFromNetwork() async throws { + let useCase = TestUseCase(courses: [.make(id: "1")]) + let testee = createStore(useCase: useCase) + + let courses = try await testee.getEntities(ignoreCache: true) + XCTAssertEqual(courses.map { $0.id }, ["1"]) + } + + // MARK: - Direct database calling + + func testObjectsAreReturnedFromDatabase() async throws { + Course.save(.make(id: "0"), in: databaseClient) + try! databaseClient.save() + let useCase = TestUseCase() + let testee = createStore(useCase: useCase) + + let cache: TTL = databaseClient.insert() + cache.key = useCase.cacheKey ?? "" + cache.lastRefresh = Date() + try databaseClient.save() + + let courses = try await testee.getEntitiesFromDatabase() + XCTAssertEqual(courses.map { $0.id }, ["0"]) + } + + // MARK: - Force fetch + + func testIgnoreCache() async throws { + Course.save(.make(id: "0"), in: databaseClient) + try databaseClient.save() + let useCase = TestUseCase(courses: [.make(id: "1")]) + let testee = createStore(useCase: useCase) + + let cache: TTL = databaseClient.insert() + cache.key = useCase.cacheKey ?? "" + cache.lastRefresh = Date() + try databaseClient.save() + + let expectation1 = expectation(description: "First iteration") + let expectation2 = expectation(description: "Second iteration") + var iterationCount = 0 + let task = Task { + let stream = try await testee.streamEntities(ignoreCache: false) + + for try await courses in stream { + iterationCount += 1 + if iterationCount == 1 { + XCTAssertEqual(courses.map { $0.id }, ["0"]) + expectation1.fulfill() + } else if iterationCount == 2 { + XCTAssertEqual(courses.map { $0.id }, ["1", "0"]) + expectation2.fulfill() + } + } + } + await fulfillment(of: [expectation1], timeout: 1) + + await testee.forceRefresh(loadAllPages: true) + await fulfillment(of: [expectation2], timeout: 1) + task.cancel() + } + + // MARK: - Database changes + + func testNewlyAddedObjectsArePublished() async { + let useCase = TestUseCase(courses: []) + let testee = createStore(useCase: useCase) + + let initialExpectation = expectation(description: "Initial iteration") + let newItemExpectation = expectation(description: "Iteration with new item") + var iterationCount = 0 + + let task = Task { + let stream = try await testee.streamEntities() + + for try await courses in stream { + iterationCount += 1 + + if iterationCount == 1 { + initialExpectation.fulfill() + } else { + XCTAssertEqual(courses.map { $0.id }, ["3rdpartyinsert"]) + newItemExpectation.fulfill() + } + } + } + await fulfillment(of: [initialExpectation], timeout: 1) + + Course.save(.make(id: "3rdpartyinsert"), in: databaseClient) + + await fulfillment(of: [newItemExpectation], timeout: 1) + task.cancel() + } + + func testDatabaseChangesArePublished() async throws { + let course = Course.save(.make(id: "1"), in: databaseClient) + try databaseClient.save() + let useCase = TestUseCase(courses: []) + let testee = createStore(useCase: useCase) + + let initialExpectation = expectation(description: "Initial iteration") + let updatedItemExpectation = expectation(description: "Iteration with updated item") + var iterationCount = 0 + let task = Task { + let stream = try await testee.streamEntities(ignoreCache: false) + + for try await courses in stream { + iterationCount += 1 + + if iterationCount == 1 { + initialExpectation.fulfill() + } else { + XCTAssertEqual(courses.map { $0.id }, ["1"]) + XCTAssertEqual(courses.first?.name, "updatedName") + updatedItemExpectation.fulfill() + } + } + } + await fulfillment(of: [initialExpectation], timeout: 1) + + course.name = "updatedName" + try await Task.sleep(for: .seconds(1)) + await fulfillment(of: [updatedItemExpectation], timeout: 1) + task.cancel() + } + + func testDatabaseDeletionsArePublished() async throws { + let course = Course.save(.make(id: "1"), in: databaseClient) + try databaseClient.save() + let store = createStore(useCase: TestUseCase()) + + let initialExpectation = expectation(description: "Initial iteration") + let deletedItemExpectation = expectation(description: "Iteration with deleted item") + var iterationCount = 0 + let task = Task { + let stream = try await store.streamEntities() + + for try await courses in stream { + iterationCount += 1 + + if iterationCount == 1 { + initialExpectation.fulfill() + } else { + XCTAssertEqual(courses.count, 0) + deletedItemExpectation.fulfill() + } + } + } + await fulfillment(of: [initialExpectation], timeout: 1) + + databaseClient.delete(course) + await fulfillment(of: [deletedItemExpectation], timeout: 1) + task.cancel() + } + + // MARK: - LoadAllPages + + func testLoadAllPages() async throws { + let prev = "https://cgnuonline-eniversity.edu/api/v1/date" + let curr = "https://cgnuonline-eniversity.edu/api/v1/date?page=2" + let next = "https://cgnuonline-eniversity.edu/api/v1/date?page=3" + let headers = [ + "Link": "<\(curr)>; rel=\"current\",<>;, <\(prev)>; rel=\"prev\", <\(next)>; rel=\"next\"; count=1" + ] + let urlResponse = HTTPURLResponse(url: URL(string: curr)!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: headers)! + let page1 = [APICourse.make(id: "1")] + let page2 = [APICourse.make(id: "2")] + let useCase = TestUseCase(courses: page1, urlResponse: urlResponse) + api.mock(useCase.getNext(from: urlResponse)!, value: page2, response: nil, error: nil) + let store = createStore(useCase: useCase) + + var courses = try await store.getEntities(ignoreCache: true, loadAllPages: false) + XCTAssertEqual(courses.count, 1) + + courses = try await store.getEntities(ignoreCache: true, loadAllPages: true) + XCTAssertEqual(courses.count, 2) + } + + // MARK: - Offline + + func test_OfflineModeIsEnabled_ObjectsAreReturnedFromDatabase() async throws { + // Given + Course.make(from: .make(id: "0")) + injectOfflineFeatureFlag(isEnabled: true) + + // When + let useCase = TestUseCase(courses: [.make(id: "1")]) + let store = createStore(useCase: useCase) + + let courses = try await store.getEntities() + XCTAssertEqual(courses.count, 1) + let ids = courses.map { $0.id } + XCTAssertEqual(ids.count, 1) + XCTAssert(ids.contains("0")) + XCTAssert(!ids.contains("1")) + } + + func test_OfflineModeIsNotEnabled_ObjectsAreReturnedFromNetwork() async throws { + // Given + Course.make(from: .make(id: "0")) + injectOfflineFeatureFlag(isEnabled: false) + + // When + let useCase = TestUseCase(courses: [.make(id: "1")]) + let store = createStore(useCase: useCase) + + let courses = try await store.getEntities() + XCTAssertEqual(courses.count, 2) + let ids = courses.map { $0.id } + XCTAssertEqual(ids.count, 2) + XCTAssert(ids.contains("0")) + XCTAssert(ids.contains("1")) + } + + + // MARK: - Custom App Environment + + func testCustomEnvironmentIsUsed() async throws { + let useCase = TestUseCase(courses: [.make(id: "1")]) + let testEnvironment = TestEnvironment() + store = AsyncStore(useCase: useCase, environment: testEnvironment) + + _ = try await store.getEntities(ignoreCache: true) + + XCTAssertTrue(useCase.receivedEnvironmentInMakeRequest === testEnvironment) + XCTAssertTrue(useCase.receivedEnvironmentInMakeRequest !== AppEnvironment.shared) + } + + // MARK: - Private methods + + private func createStore(useCase: U) -> AsyncStore { + AsyncStore( + offlineModeInteractor: createOfflineModeInteractor(), + context: environment.database.viewContext, + useCase: useCase + ) + } + + private func createOfflineModeInteractor() -> OfflineModeInteractor { + let monitor = NWPathMonitorWrapper(start: { _ in () }, cancel: {}) + let availabilityService = NetworkAvailabilityServiceLive(monitor: monitor) + let result = OfflineModeInteractorLive(availabilityService: availabilityService, + isOfflineModeEnabledForApp: true) + return result + } + + private func injectOfflineFeatureFlag(isEnabled: Bool) { + let scope: Scope = .where(#keyPath(FeatureFlag.name), + equals: EnvironmentFeatureFlags.mobile_offline_mode.rawValue, + sortDescriptors: []) + let flag: FeatureFlag = databaseClient.fetch(scope: scope).first ?? databaseClient.insert() + flag.name = EnvironmentFeatureFlags.mobile_offline_mode.rawValue + flag.isEnvironmentFlag = true + flag.enabled = isEnabled + flag.context = .currentUser + } +} + +extension AsyncStoreTests { + final class TestUseCase: UseCase { + typealias Model = Course + + let courses: [APICourse]? + let requestError: Error? + let urlResponse: URLResponse? + + private(set) var receivedEnvironmentInMakeRequest: AppEnvironment? + + init(courses: [APICourse]? = nil, requestError: Error? = nil, urlResponse: URLResponse? = nil) { + self.courses = courses + self.requestError = requestError + self.urlResponse = urlResponse + } + + var scope: Scope { + return .all(orderBy: #keyPath(Course.name)) + } + + var cacheKey: String? { + return "test-use-case" + } + + func makeRequest(environment: AppEnvironment, completionHandler: @escaping ([APICourse]?, URLResponse?, Error?) -> Void) { + receivedEnvironmentInMakeRequest = environment + completionHandler(courses, urlResponse, requestError) + } + + func write(response: [APICourse]?, urlResponse _: URLResponse?, to client: NSManagedObjectContext) { + guard let response = response else { + return + } + for item in response { + let predicate = NSPredicate(format: "%K == %@", #keyPath(Course.id), item.id.value) + let course: Course = client.fetch(predicate).first ?? client.insert() + course.name = item.name + course.id = item.id.value + course.isFavorite = item.is_favorite ?? false + } + } + + func getNext(from urlResponse: URLResponse) -> GetNextRequest<[APICourse]>? { + return GetCoursesRequest().getNext(from: urlResponse) + } + } +} From 666c44420f0da63252d7a717ba296cdb7c9e3158 Mon Sep 17 00:00:00 2001 From: Petky Benedek Date: Mon, 26 Jan 2026 15:12:55 +0100 Subject: [PATCH 4/7] Remove newline --- Core/CoreTests/Common/CommonModels/Store/AsyncStoreTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Core/CoreTests/Common/CommonModels/Store/AsyncStoreTests.swift b/Core/CoreTests/Common/CommonModels/Store/AsyncStoreTests.swift index 94fe18ddc1..127597acba 100644 --- a/Core/CoreTests/Common/CommonModels/Store/AsyncStoreTests.swift +++ b/Core/CoreTests/Common/CommonModels/Store/AsyncStoreTests.swift @@ -274,7 +274,6 @@ final class AsyncStoreTests: CoreTestCase { XCTAssert(ids.contains("1")) } - // MARK: - Custom App Environment func testCustomEnvironmentIsUsed() async throws { From 4abcbb488647ae4dadf28759b21bfdaf0a15d988 Mon Sep 17 00:00:00 2001 From: Petky Benedek Date: Thu, 29 Jan 2026 15:46:48 +0100 Subject: [PATCH 5/7] Create AsyncFecthedResults and add getFirstEntity --- .../Store/AsyncFetchedResults.swift | 109 ++++++++++++++++++ .../CommonModels/Store/AsyncStore.swift | 36 +++++- .../Common/CommonModels/Store/UseCase.swift | 5 +- .../CommonModels/Store/AsyncStoreTests.swift | 39 ++++++- 4 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 Core/Core/Common/CommonModels/Store/AsyncFetchedResults.swift diff --git a/Core/Core/Common/CommonModels/Store/AsyncFetchedResults.swift b/Core/Core/Common/CommonModels/Store/AsyncFetchedResults.swift new file mode 100644 index 0000000000..9e137a31b1 --- /dev/null +++ b/Core/Core/Common/CommonModels/Store/AsyncFetchedResults.swift @@ -0,0 +1,109 @@ +// +// This file is part of Canvas. +// Copyright (C) 2026-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Foundation +import CoreData + +@preconcurrency +public final class AsyncFetchedResults { + private let request: NSFetchRequest + private let context: NSManagedObjectContext + + public init( + request: NSFetchRequest, + context: NSManagedObjectContext + ) { + self.request = request + self.context = context + } + + public func fetch() async throws -> [ResultType] { + try await context.perform { + try self.context.fetch(self.request) + } + } + + public func stream() -> AsyncThrowingStream<[ResultType], Error> { + AsyncThrowingStream { continuation in + let observer = FetchedResultsObserver( + request: request, + context: context, + continuation: continuation + ) + + continuation.onTermination = { _ in + observer.cancel() + } + } + } +} + +private final class FetchedResultsObserver: NSObject, NSFetchedResultsControllerDelegate { + private var controller: NSFetchedResultsController? + private let continuation: AsyncThrowingStream<[ResultType], Error>.Continuation + private let context: NSManagedObjectContext + + init( + request: NSFetchRequest, + context: NSManagedObjectContext, + continuation: AsyncThrowingStream<[ResultType], Error>.Continuation + ) { + self.continuation = continuation + self.context = context + super.init() + + context.perform { [weak self] in + guard let self else { return } + + self.controller = NSFetchedResultsController( + fetchRequest: request, + managedObjectContext: context, + sectionNameKeyPath: nil, + cacheName: nil + ) + self.controller?.delegate = self + + do { + try self.controller?.performFetch() + self.sendElement() + } catch { + continuation.finish(throwing: NSError.instructureError("Error while reading from Core Data")) + } + } + } + + private func sendElement() { + context.perform { [weak self] in + guard let self else { return } + let entities = self.controller?.fetchedObjects ?? [] + self.continuation.yield(entities) + } + } + + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + sendElement() + } + + func cancel() { + context.perform { [weak self] in + self?.controller?.delegate = nil + self?.controller = nil + self?.continuation.finish() + } + } +} diff --git a/Core/Core/Common/CommonModels/Store/AsyncStore.swift b/Core/Core/Common/CommonModels/Store/AsyncStore.swift index a38705de51..10b3d26606 100644 --- a/Core/Core/Common/CommonModels/Store/AsyncStore.swift +++ b/Core/Core/Common/CommonModels/Store/AsyncStore.swift @@ -37,6 +37,29 @@ public struct AsyncStore { self.environment = environment } + /// Produces one entity for the given UseCase. + /// When the device is connected to the internet and there's no valid cache, it makes a request to the API and saves the response to the database. If there's valid cache, it returns it. + /// By default it downloads all pages, and validates cache unless specificied differently. + /// When the device is offline, it will read data from Core Data. + /// - Parameters: + /// - ignoreCache: Indicates if the request should check the available cache first. + /// If it's set to **false**, it will validate the cache's expiration and return it if it's still valid. If the cache has expired it will make a request to the API. + /// If it's set to **true**, it will make a request to the API. + /// Defaults to **false**. + /// - loadAllPages: Tells the request if it should load all the pages or just the first one. Defaults to **true**. + /// - assertOnlyOneEntityFound: Indicates if the request should assert that only one entity is found. Defaults to **true**. + /// - Returns: The first fetched entity. + /// - Throws: `AsyncStoreError.noEntityFound` if no entity is found. + /// - Throws: `AsyncStoreError.moreThanOneEntityFound` if more than one entity is found and `assertOnlyOneEntityFound` is set to true or emitted. + public func getFirstEntity(ignoreCache: Bool = false, loadAllPages: Bool = true, assertOnlyOneEntityFound: Bool = true) async throws -> U.Model { + let entities = try await getEntities(ignoreCache: ignoreCache, loadAllPages: loadAllPages) + + if assertOnlyOneEntityFound, entities.count > 1 { throw AsyncStoreError.moreThanOneEntityFound(entities.count) } + guard let entity = entities.first else { throw AsyncStoreError.noEntityFound } + + return entity + } + /// Produces a list of entities for the given UseCase. /// When the device is connected to the internet and there's no valid cache, it makes a request to the API and saves the response to the database. If there's valid cache, it returns it. /// By default it downloads all pages, and validates cache unless specificied differently. @@ -285,15 +308,20 @@ public struct AsyncStore { fetchRequest: NSFetchRequest, context: NSManagedObjectContext ) async throws -> [T] { - try await FetchedResultsPublisher(request: fetchRequest, context: context) - .asyncValue() + try await AsyncFetchedResults(request: fetchRequest, context: context) + .fetch() } private static func streamEntitiesFromDatabase( fetchRequest: NSFetchRequest, context: NSManagedObjectContext ) -> AsyncThrowingStream<[T], Error> { - FetchedResultsPublisher(request: fetchRequest, context: context) - .asyncStream() + AsyncFetchedResults(request: fetchRequest, context: context) + .stream() } } + +public enum AsyncStoreError: Error { + case noEntityFound + case moreThanOneEntityFound(Int) +} diff --git a/Core/Core/Common/CommonModels/Store/UseCase.swift b/Core/Core/Common/CommonModels/Store/UseCase.swift index 22ea62c89b..6ee93e720d 100644 --- a/Core/Core/Common/CommonModels/Store/UseCase.swift +++ b/Core/Core/Common/CommonModels/Store/UseCase.swift @@ -176,8 +176,11 @@ public extension UseCase { /// Async `fetch()` that returns both the API response and URLResponse. /// Use this when you need access to the API response data directly. /// The response is optional - it will be nil if the API returned no response body. + /// Handles task cancellation as it is not possible to propagate it down further. func fetchWithAPIResponse(environment: AppEnvironment = .shared) async throws -> (Response?, URLResponse?) { - try await withCheckedThrowingContinuation { continuation in + try Task.checkCancellation() + + return try await withCheckedThrowingContinuation { continuation in self.executeFetch(environment: environment) { result in continuation.resume(with: result) } diff --git a/Core/CoreTests/Common/CommonModels/Store/AsyncStoreTests.swift b/Core/CoreTests/Common/CommonModels/Store/AsyncStoreTests.swift index 127597acba..5482c89def 100644 --- a/Core/CoreTests/Common/CommonModels/Store/AsyncStoreTests.swift +++ b/Core/CoreTests/Common/CommonModels/Store/AsyncStoreTests.swift @@ -68,6 +68,44 @@ final class AsyncStoreTests: CoreTestCase { XCTAssertEqual(courses.map { $0.id }, ["1"]) } + func testFirstEntityIsReturned() async throws { + let useCase = TestUseCase(courses: [.make(id: "1")]) + let testee = createStore(useCase: useCase) + + let course = try await testee.getFirstEntity(ignoreCache: true) + XCTAssertEqual(course.id, "1") + } + + func testFirstEntityThrowsError() async throws { + let useCase = TestUseCase(courses: [.make(id: "1"), .make(id: "2")]) + let testee = createStore(useCase: useCase) + + do { + _ = try await testee.getFirstEntity(ignoreCache: true) + } catch AsyncStoreError.moreThanOneEntityFound(let count) { + XCTAssertEqual(2, count) + } // other errors cause the test to fail + } + + func testFirstEntityDoesNotThrowsErrorWithAssertionTurnedOff() async throws { + let useCase = TestUseCase(courses: [.make(id: "1"), .make(id: "2")]) + let testee = createStore(useCase: useCase) + + // We expect no error thrown + _ = try await testee.getFirstEntity(ignoreCache: true, assertOnlyOneEntityFound: false) + } + + func testFirstEntityThrowsErrorWhenNoEntities() async throws { + let useCase = TestUseCase(courses: []) + let testee = createStore(useCase: useCase) + + do { + _ = try await testee.getFirstEntity(ignoreCache: true) + } catch let error as AsyncStoreError { + XCTAssertEqual(error, AsyncStoreError.noEntityFound) + } // other errors cause the test to fail + } + // MARK: - Direct database calling func testObjectsAreReturnedFromDatabase() async throws { @@ -181,7 +219,6 @@ final class AsyncStoreTests: CoreTestCase { await fulfillment(of: [initialExpectation], timeout: 1) course.name = "updatedName" - try await Task.sleep(for: .seconds(1)) await fulfillment(of: [updatedItemExpectation], timeout: 1) task.cancel() } From 6d1c18ab9db2d08cfc3399a743d9342b409790d4 Mon Sep 17 00:00:00 2001 From: Petky Benedek Date: Thu, 29 Jan 2026 16:12:15 +0100 Subject: [PATCH 6/7] Add equatable conformance --- Core/Core/Common/CommonModels/Store/AsyncStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Core/Common/CommonModels/Store/AsyncStore.swift b/Core/Core/Common/CommonModels/Store/AsyncStore.swift index 10b3d26606..a20a3a4f70 100644 --- a/Core/Core/Common/CommonModels/Store/AsyncStore.swift +++ b/Core/Core/Common/CommonModels/Store/AsyncStore.swift @@ -321,7 +321,7 @@ public struct AsyncStore { } } -public enum AsyncStoreError: Error { +public enum AsyncStoreError: Error, Equatable { case noEntityFound case moreThanOneEntityFound(Int) } From 2126f833b126cd16d5011121aa365c5032d26392 Mon Sep 17 00:00:00 2001 From: Petky Benedek Date: Fri, 30 Jan 2026 17:42:53 +0100 Subject: [PATCH 7/7] Refactor --- .../Store/AsyncFetchedResults.swift | 17 +- .../CommonModels/Store/AsyncStore.swift | 219 ++++-------------- .../CommonModels/Store/AsyncStoreTests.swift | 8 +- 3 files changed, 56 insertions(+), 188 deletions(-) diff --git a/Core/Core/Common/CommonModels/Store/AsyncFetchedResults.swift b/Core/Core/Common/CommonModels/Store/AsyncFetchedResults.swift index 9e137a31b1..86b67429ca 100644 --- a/Core/Core/Common/CommonModels/Store/AsyncFetchedResults.swift +++ b/Core/Core/Common/CommonModels/Store/AsyncFetchedResults.swift @@ -17,9 +17,8 @@ // import Foundation -import CoreData +@preconcurrency import CoreData -@preconcurrency public final class AsyncFetchedResults { private let request: NSFetchRequest private let context: NSManagedObjectContext @@ -33,9 +32,7 @@ public final class AsyncFetchedResults { } public func fetch() async throws -> [ResultType] { - try await context.perform { - try self.context.fetch(self.request) - } + try await context.fetch(request) } public func stream() -> AsyncThrowingStream<[ResultType], Error> { @@ -82,7 +79,7 @@ private final class FetchedResultsObserver: NS try self.controller?.performFetch() self.sendElement() } catch { - continuation.finish(throwing: NSError.instructureError("Error while reading from Core Data")) + continuation.finish(throwing: error) } } } @@ -107,3 +104,11 @@ private final class FetchedResultsObserver: NS } } } + +extension NSManagedObjectContext { + public func fetch(_ request: NSFetchRequest) async throws -> [R] { + try await perform { + try self.fetch(request) + } + } +} diff --git a/Core/Core/Common/CommonModels/Store/AsyncStore.swift b/Core/Core/Common/CommonModels/Store/AsyncStore.swift index a20a3a4f70..b4ab8213d6 100644 --- a/Core/Core/Common/CommonModels/Store/AsyncStore.swift +++ b/Core/Core/Common/CommonModels/Store/AsyncStore.swift @@ -20,10 +20,11 @@ import Foundation import CoreData public struct AsyncStore { - fileprivate let offlineModeInteractor: OfflineModeInteractor? internal let useCase: U - fileprivate let context: NSManagedObjectContext - fileprivate let environment: AppEnvironment + private let offlineModeInteractor: OfflineModeInteractor? + private let context: NSManagedObjectContext + private let environment: AppEnvironment + private let request: NSFetchRequest public init( offlineModeInteractor: OfflineModeInteractor? = OfflineModeAssembly.make(), @@ -35,6 +36,11 @@ public struct AsyncStore { self.useCase = useCase.modified(for: environment) self.context = context self.environment = environment + + request = NSFetchRequest(entityName: String(describing: U.Model.self)) + let scope = useCase.scope + request.predicate = scope.predicate + request.sortDescriptors = scope.order } /// Produces one entity for the given UseCase. @@ -72,33 +78,15 @@ public struct AsyncStore { /// - loadAllPages: Tells the request if it should load all the pages or just the first one. Defaults to **true**. /// - Returns: A list of entities. public func getEntities(ignoreCache: Bool = false, loadAllPages: Bool = true) async throws -> [U.Model] { - let scope = useCase.scope - let request = NSFetchRequest(entityName: String(describing: U.Model.self)) - request.predicate = scope.predicate - request.sortDescriptors = scope.order - - return if offlineModeInteractor?.isOfflineModeEnabled() == true { - try await Self.fetchEntitiesFromDatabase( - fetchRequest: request, - context: context - ) + if offlineModeInteractor?.isOfflineModeEnabled() == true { + return try await fetchEntitiesFromDatabase() } else { - if ignoreCache { - try await Self.fetchEntitiesFromAPI( - useCase: useCase, - loadAllPages: loadAllPages, - fetchRequest: request, - context: context, - environment: environment - ) + let hasExpired = await useCase.hasCacheExpired(environment: environment) + + if ignoreCache || hasExpired { + return try await fetchEntitiesFromAPI(loadAllPages: loadAllPages) } else { - try await Self.fetchEntitiesFromCache( - useCase: useCase, - fetchRequest: request, - loadAllPages: loadAllPages, - context: context, - environment: environment - ) + return try await fetchEntitiesFromDatabase() } } } @@ -116,55 +104,27 @@ public struct AsyncStore { /// Defaults to **false**. /// - loadAllPages: Tells the request if it should load all the pages or just the first one. Defaults to **true**. /// - Returns: An async sequence of list of entities. - public func streamEntities(ignoreCache: Bool = false, loadAllPages: Bool = true) async throws -> AsyncThrowingStream<[U.Model], Error> { - let scope = useCase.scope - let request = NSFetchRequest(entityName: String(describing: U.Model.self)) - request.predicate = scope.predicate - request.sortDescriptors = scope.order - - return if offlineModeInteractor?.isOfflineModeEnabled() == true { - Self.streamEntitiesFromDatabase( - fetchRequest: request, - context: context - ) + public func updates(ignoreCache: Bool = false, loadAllPages: Bool = true) async throws -> AsyncThrowingStream<[U.Model], Error> { + if offlineModeInteractor?.isOfflineModeEnabled() == true { + return streamEntitiesFromDatabase() } else { - if ignoreCache { - try await Self.streamEntitiesFromAPI( - useCase: useCase, - loadAllPages: loadAllPages, - fetchRequest: request, - context: context, - environment: environment - ) - } else { - try await Self.streamEntitiesFromCache( - useCase: useCase, - fetchRequest: request, - loadAllPages: loadAllPages, - context: context, - environment: environment - ) + let hasExpired = await useCase.hasCacheExpired(environment: environment) + + if ignoreCache || hasExpired { + try await updateEntitiesFromAPI(loadAllPages: loadAllPages) } + + return streamEntitiesFromDatabase() } } public func getEntitiesFromDatabase() async throws -> [U.Model] { - let scope = useCase.scope - let request = NSFetchRequest(entityName: String(describing: U.Model.self)) - request.predicate = scope.predicate - request.sortDescriptors = scope.order - - return try await Self.fetchEntitiesFromDatabase(fetchRequest: request, context: context) + try await fetchEntitiesFromDatabase() } /// - Warning: This stream **does not terminate**. Ensure proper cancellation of its consuming task. - public func streamEntitiesFromDatabase() throws -> AsyncThrowingStream<[U.Model], Error> { - let scope = useCase.scope - let request = NSFetchRequest(entityName: String(describing: U.Model.self)) - request.predicate = scope.predicate - request.sortDescriptors = scope.order - - return Self.streamEntitiesFromDatabase(fetchRequest: request, context: context) + public func updatesFromDatabase() -> AsyncThrowingStream<[U.Model], Error> { + streamEntitiesFromDatabase() } /// Refreshes the entities by requesting the latest data from the API. @@ -172,58 +132,7 @@ public struct AsyncStore { _ = try? await getEntities(ignoreCache: true, loadAllPages: loadAllPages) } - private static func fetchEntitiesFromCache( - useCase: U, - fetchRequest: NSFetchRequest, - loadAllPages: Bool, - context: NSManagedObjectContext, - environment: AppEnvironment - ) async throws -> [T] { - let hasExpired = await useCase.hasCacheExpired(environment: environment) - - return if hasExpired { - try await Self.fetchEntitiesFromAPI( - useCase: useCase, - loadAllPages: loadAllPages, - fetchRequest: fetchRequest, - context: context, - environment: environment - ) - } else { - try await Self.fetchEntitiesFromDatabase(fetchRequest: fetchRequest, context: context) - } - } - - private static func streamEntitiesFromCache( - useCase: U, - fetchRequest: NSFetchRequest, - loadAllPages: Bool, - context: NSManagedObjectContext, - environment: AppEnvironment - ) async throws -> AsyncThrowingStream<[T], Error> { - let hasExpired = await useCase.hasCacheExpired(environment: environment) - - return if hasExpired { - try await Self.streamEntitiesFromAPI( - useCase: useCase, - loadAllPages: loadAllPages, - fetchRequest: fetchRequest, - context: context, - environment: environment - ) - } else { - Self.streamEntitiesFromDatabase(fetchRequest: fetchRequest, context: context) - } - } - - private static func fetchEntitiesFromAPI( - useCase: U, - getNextUseCase: GetNextUseCase? = nil, - loadAllPages: Bool, - fetchRequest: NSFetchRequest, - context: NSManagedObjectContext, - environment: AppEnvironment - ) async throws -> [T] { + private func fetchEntitiesFromAPI(getNextUseCase: GetNextUseCase? = nil, loadAllPages: Bool) async throws -> [U.Model] { let urlResponse = if let getNextUseCase { try await getNextUseCase.fetch(environment: environment) } else { @@ -231,26 +140,12 @@ public struct AsyncStore { } let nextResponse = urlResponse.flatMap { useCase.getNext(from: $0) } - try await Self.fetchAllPagesIfNeeded( - useCase: useCase, - loadAllPages: loadAllPages, - nextResponse: nextResponse, - fetchRequest: fetchRequest, - context: context, - environment: environment - ) + try await fetchAllPagesIfNeeded(loadAllPages: loadAllPages, nextResponse: nextResponse) - return try await Self.fetchEntitiesFromDatabase(fetchRequest: fetchRequest, context: context) + return try await fetchEntitiesFromDatabase() } - private static func streamEntitiesFromAPI( - useCase: U, - getNextUseCase: GetNextUseCase? = nil, - loadAllPages: Bool, - fetchRequest: NSFetchRequest, - context: NSManagedObjectContext, - environment: AppEnvironment - ) async throws -> AsyncThrowingStream<[T], Error> { + private func updateEntitiesFromAPI(getNextUseCase: GetNextUseCase? = nil, loadAllPages: Bool) async throws { let urlResponse = if let getNextUseCase { try await getNextUseCase.fetch(environment: environment) } else { @@ -258,45 +153,19 @@ public struct AsyncStore { } let nextResponse = urlResponse.flatMap { useCase.getNext(from: $0) } - try await Self.fetchAllPagesIfNeeded( - useCase: useCase, - loadAllPages: loadAllPages, - nextResponse: nextResponse, - fetchRequest: fetchRequest, - context: context, - environment: environment - ) - - return Self.streamEntitiesFromDatabase(fetchRequest: fetchRequest, context: context) + try await fetchAllPagesIfNeeded(loadAllPages: loadAllPages, nextResponse: nextResponse) } - private static func fetchAllPagesIfNeeded( - useCase: U, - loadAllPages: Bool, - nextResponse: GetNextRequest?, - fetchRequest: NSFetchRequest, - context: NSManagedObjectContext, - environment: AppEnvironment - ) async throws { + private func fetchAllPagesIfNeeded(loadAllPages: Bool, nextResponse: GetNextRequest?) async throws { guard loadAllPages else { return } - let nextPageUseCase = getNextPage(useCase: useCase, nextResponse: nextResponse) + let nextPageUseCase = getNextPage(nextResponse: nextResponse) if let nextPageUseCase { - _ = try await Self.fetchEntitiesFromAPI( - useCase: useCase, - getNextUseCase: nextPageUseCase, - loadAllPages: true, - fetchRequest: fetchRequest, - context: context, - environment: environment - ) + _ = try await fetchEntitiesFromAPI(getNextUseCase: nextPageUseCase, loadAllPages: true) } } - private static func getNextPage( - useCase: U, - nextResponse: GetNextRequest? - ) -> GetNextUseCase? { + private func getNextPage(nextResponse: GetNextRequest?) -> GetNextUseCase? { if let nextResponse { GetNextUseCase(parent: useCase, request: nextResponse) } else { @@ -304,19 +173,13 @@ public struct AsyncStore { } } - private static func fetchEntitiesFromDatabase( - fetchRequest: NSFetchRequest, - context: NSManagedObjectContext - ) async throws -> [T] { - try await AsyncFetchedResults(request: fetchRequest, context: context) + private func fetchEntitiesFromDatabase() async throws -> [U.Model] { + try await AsyncFetchedResults(request: request, context: context) .fetch() } - private static func streamEntitiesFromDatabase( - fetchRequest: NSFetchRequest, - context: NSManagedObjectContext - ) -> AsyncThrowingStream<[T], Error> { - AsyncFetchedResults(request: fetchRequest, context: context) + private func streamEntitiesFromDatabase() -> AsyncThrowingStream<[U.Model], Error> { + AsyncFetchedResults(request: request, context: context) .stream() } } diff --git a/Core/CoreTests/Common/CommonModels/Store/AsyncStoreTests.swift b/Core/CoreTests/Common/CommonModels/Store/AsyncStoreTests.swift index 5482c89def..6520139822 100644 --- a/Core/CoreTests/Common/CommonModels/Store/AsyncStoreTests.swift +++ b/Core/CoreTests/Common/CommonModels/Store/AsyncStoreTests.swift @@ -140,7 +140,7 @@ final class AsyncStoreTests: CoreTestCase { let expectation2 = expectation(description: "Second iteration") var iterationCount = 0 let task = Task { - let stream = try await testee.streamEntities(ignoreCache: false) + let stream = try await testee.updates(ignoreCache: false) for try await courses in stream { iterationCount += 1 @@ -171,7 +171,7 @@ final class AsyncStoreTests: CoreTestCase { var iterationCount = 0 let task = Task { - let stream = try await testee.streamEntities() + let stream = try await testee.updates() for try await courses in stream { iterationCount += 1 @@ -202,7 +202,7 @@ final class AsyncStoreTests: CoreTestCase { let updatedItemExpectation = expectation(description: "Iteration with updated item") var iterationCount = 0 let task = Task { - let stream = try await testee.streamEntities(ignoreCache: false) + let stream = try await testee.updates(ignoreCache: false) for try await courses in stream { iterationCount += 1 @@ -232,7 +232,7 @@ final class AsyncStoreTests: CoreTestCase { let deletedItemExpectation = expectation(description: "Iteration with deleted item") var iterationCount = 0 let task = Task { - let stream = try await store.streamEntities() + let stream = try await store.updates() for try await courses in stream { iterationCount += 1