diff --git a/Sources/ApolloPagination/AnyAsyncGraphQLQueryPager.swift b/Sources/ApolloPagination/AnyAsyncGraphQLQueryPager.swift new file mode 100644 index 000000000..3f76ce184 --- /dev/null +++ b/Sources/ApolloPagination/AnyAsyncGraphQLQueryPager.swift @@ -0,0 +1,154 @@ +import Apollo +import ApolloAPI +import Combine + +/// Type-erases a query pager, transforming data from a generic type to a specific type, often a view model or array of view models. +public class AnyAsyncGraphQLQueryPager { + public typealias Output = Result<(Model, UpdateSource), Error> + private let _subject: CurrentValueSubject = .init(nil) + public var publisher: AnyPublisher { _subject.compactMap({ $0 }).eraseToAnyPublisher() } + public var cancellables = [AnyCancellable]() + public let pager: any AsyncPagerType + + public var canLoadNext: Bool { get async { await pager.canLoadNext } } + public var canLoadPrevious: Bool { get async { await pager.canLoadPrevious } } + + /// Type-erases a given pager, transforming data to a model as pagination receives new results. + /// - Parameters: + /// - pager: Pager to type-erase. + /// - transform: Transformation from an initial page and array of paginated pages to a given view model. + public init, InitialQuery, NextQuery>( + pager: Pager, + transform: @escaping ([NextQuery.Data], InitialQuery.Data, [NextQuery.Data]) throws -> Model + ) async { + self.pager = pager + await pager.subscribe { [weak self] result in + guard let self else { return } + let returnValue: Output + + switch result { + case let .success(output): + do { + let transformedModels = try transform(output.previousPages, output.initialPage, output.nextPages) + returnValue = .success((transformedModels, output.updateSource)) + } catch { + returnValue = .failure(error) + } + case let .failure(error): + returnValue = .failure(error) + } + + _subject.send(returnValue) + }.store(in: &cancellables) + } + + /// Type-erases a given pager, transforming the initial page to an array of models, and the + /// subsequent pagination to an additional array of models, concatenating the results of each into one array. + /// - Parameters: + /// - pager: Pager to type-erase. + /// - initialTransform: Initial transformation from the initial page to an array of models. + /// - nextPageTransform: Transformation to execute on each subseqent page to an array of models. + public convenience init< + Pager: AsyncGraphQLQueryPager, + InitialQuery, + NextQuery, + Element + >( + pager: Pager, + initialTransform: @escaping (InitialQuery.Data) throws -> Model, + pageTransform: @escaping (NextQuery.Data) throws -> Model + ) async where Model: RangeReplaceableCollection, Model.Element == Element { + await self.init( + pager: pager, + transform: { previousData, initialData, nextData in + let previous = try previousData.flatMap { try pageTransform($0) } + let initial = try initialTransform(initialData) + let next = try nextData.flatMap { try pageTransform($0) } + return previous + initial + next + } + ) + } + + /// Subscribe to the results of the pager, with the management of the subscriber being stored internally to the `AnyGraphQLQueryPager`. + /// - Parameter completion: The closure to trigger when new values come in. + public func subscribe(completion: @MainActor @escaping (Output) -> Void) { + publisher.sink { result in + Task { await completion(result) } + }.store(in: &cancellables) + } + + /// Load the next page, if available. + /// - Parameters: + /// - cachePolicy: The Apollo `CachePolicy` to use. Defaults to `returnCacheDataAndFetch`. + public func loadNext( + cachePolicy: CachePolicy = .returnCacheDataAndFetch + ) async throws { + try await pager.loadNext(cachePolicy: cachePolicy) + } + + /// Load the previous page, if available. + /// - Parameters: + /// - cachePolicy: The Apollo `CachePolicy` to use. Defaults to `returnCacheDataAndFetch`. + public func loadPrevious( + cachePolicy: CachePolicy = .returnCacheDataAndFetch + ) async throws { + try await pager.loadPrevious(cachePolicy: cachePolicy) + } + + /// Loads all pages. + /// - Parameters: + /// - fetchFromInitialPage: Pass true to begin loading from the initial page; otherwise pass false. Defaults to `true`. **NOTE**: Loading all pages with this value set to `false` requires that the initial page has already been loaded previously. + public func loadAll( + fetchFromInitialPage: Bool = true + ) async throws { + try await pager.loadAll(fetchFromInitialPage: fetchFromInitialPage) + } + + /// Discards pagination state and fetches the first page from scratch. + /// - Parameter cachePolicy: The apollo cache policy to trigger the first fetch with. Defaults to `fetchIgnoringCacheData`. + public func refetch(cachePolicy: CachePolicy = .fetchIgnoringCacheData) async { + await pager.refetch(cachePolicy: cachePolicy) + } + + /// Fetches the first page. + public func fetch() async { + await pager.fetch() + } + + /// Resets pagination state and cancels further updates from the pager. + public func cancel() async { + await pager.cancel() + } +} + +extension AsyncGraphQLQueryPager { + nonisolated func eraseToAnyPager( + transform: @escaping ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> T + ) async -> AnyAsyncGraphQLQueryPager { + await AnyAsyncGraphQLQueryPager( + pager: self, + transform: transform + ) + } + + nonisolated func eraseToAnyPager( + initialTransform: @escaping (InitialQuery.Data) throws -> S, + pageTransform: @escaping (PaginatedQuery.Data) throws -> S + ) async -> AnyAsyncGraphQLQueryPager where T == S.Element { + await AnyAsyncGraphQLQueryPager( + pager: self, + initialTransform: initialTransform, + pageTransform: pageTransform + ) + } + + nonisolated func eraseToAnyPager( + transform: @escaping (InitialQuery.Data) throws -> S + ) async -> AnyAsyncGraphQLQueryPager where InitialQuery == PaginatedQuery, T == S.Element { + await AnyAsyncGraphQLQueryPager( + pager: self, + initialTransform: transform, + pageTransform: transform + ) + } +} diff --git a/Sources/ApolloPagination/AnyGraphQLPager.swift b/Sources/ApolloPagination/AnyGraphQLPager.swift index 4c6f2de2a..87d717a9d 100644 --- a/Sources/ApolloPagination/AnyGraphQLPager.swift +++ b/Sources/ApolloPagination/AnyGraphQLPager.swift @@ -1,15 +1,20 @@ import Apollo import ApolloAPI import Combine +import Dispatch /// Type-erases a query pager, transforming data from a generic type to a specific type, often a view model or array of view models. public class AnyGraphQLQueryPager { public typealias Output = Result<(Model, UpdateSource), Error> - public var canLoadNext: Bool { pager.canLoadNext } + private let _subject: CurrentValueSubject = .init(nil) + + /// The `publisher` is the intended access point for using the pager as a `Combine` stream. + public var publisher: AnyPublisher { _subject.compactMap { $0 }.eraseToAnyPublisher() } + public var cancellables = [AnyCancellable]() + public let pager: any PagerType - private var _subject: CurrentValueSubject? = .init(nil) - private var cancellables = [AnyCancellable]() - private var pager: any PagerType + public var canLoadNext: Bool { pager.canLoadNext } + public var canLoadPrevious: Bool { pager.canLoadPrevious } /// Type-erases a given pager, transforming data to a model as pagination receives new results. /// - Parameters: @@ -17,18 +22,18 @@ public class AnyGraphQLQueryPager { /// - transform: Transformation from an initial page and array of paginated pages to a given view model. public init, InitialQuery, NextQuery>( pager: Pager, - transform: @escaping (InitialQuery.Data, [NextQuery.Data]) throws -> Model + transform: @escaping ([NextQuery.Data], InitialQuery.Data, [NextQuery.Data]) throws -> Model ) { self.pager = pager - pager.subscribe { result in + pager.subscribe { [weak self] result in + guard let self else { return } let returnValue: Output switch result { - case let .success(value): - let (initial, next, updateSource) = value + case let .success(output): do { - let transformedModels = try transform(initial, next) - returnValue = .success((transformedModels, updateSource)) + let transformedModels = try transform(output.previousPages, output.initialPage, output.nextPages) + returnValue = .success((transformedModels, output.updateSource)) } catch { returnValue = .failure(error) } @@ -36,7 +41,7 @@ public class AnyGraphQLQueryPager { returnValue = .failure(error) } - self._subject?.send(returnValue) + _subject.send(returnValue) } } @@ -54,85 +59,90 @@ public class AnyGraphQLQueryPager { >( pager: Pager, initialTransform: @escaping (InitialQuery.Data) throws -> Model, - nextPageTransform: @escaping (NextQuery.Data) throws -> Model + pageTransform: @escaping (NextQuery.Data) throws -> Model ) where Model: RangeReplaceableCollection, Model.Element == Element { self.init( pager: pager, - transform: { initialData, nextData in + transform: { previousData, initialData, nextData in + let previous = try previousData.flatMap { try pageTransform($0) } let initial = try initialTransform(initialData) - let next = try nextData.flatMap { try nextPageTransform($0) } - return initial + next + let next = try nextData.flatMap { try pageTransform($0) } + return previous + initial + next } ) } - /// Subscribe to new pagination `Output`s. - /// - Parameter completion: Receives a new `Output` for the consumer of the API. - /// - Returns: A `Combine` `AnyCancellable`, such that the caller can manage its own susbcription. - @discardableResult public func subscribe(completion: @escaping (Output) -> Void) -> AnyCancellable { - guard let _subject else { return AnyCancellable({ }) } - let cancellable = _subject.compactMap({ $0 }).sink { result in - completion(result) - } - cancellable.store(in: &cancellables) - return cancellable + deinit { + pager.cancel() + } + + /// Subscribe to the results of the pager, with the management of the subscriber being stored internally to the `AnyGraphQLQueryPager`. + /// - Parameter completion: The closure to trigger when new values come in. Guaranteed to run on the main thread. + public func subscribe(completion: @escaping @MainActor (Output) -> Void) { + publisher.sink { result in + Task { await completion(result) } + }.store(in: &cancellables) + } + + /// Load the next page, if available. + /// - Parameters: + /// - cachePolicy: The Apollo `CachePolicy` to use. Defaults to `returnCacheDataAndFetch`. + /// - callbackQueue: The `DispatchQueue` that the `completion` fires on. Defaults to `main`. + /// - completion: A completion block that will always trigger after the execution of this operation. Passes an optional error, of type `PaginationError`, if there was an internal error related to pagination. Does not surface network errors. Defaults to `nil`. + public func loadNext( + cachePolicy: CachePolicy = .returnCacheDataAndFetch, + callbackQueue: DispatchQueue = .main, + completion: ((PaginationError?) -> Void)? = nil + ) { + pager.loadNext(cachePolicy: cachePolicy, callbackQueue: callbackQueue, completion: completion) } - public func loadMore( + /// Load the previous page, if available. + /// - Parameters: + /// - cachePolicy: The Apollo `CachePolicy` to use. Defaults to `returnCacheDataAndFetch`. + /// - callbackQueue: The `DispatchQueue` that the `completion` fires on. Defaults to `main`. + /// - completion: A completion block that will always trigger after the execution of this operation. Passes an optional error, of type `PaginationError`, if there was an internal error related to pagination. Does not surface network errors. Defaults to `nil`. + public func loadPrevious( cachePolicy: CachePolicy = .returnCacheDataAndFetch, - completion: (@MainActor () -> Void)? = nil - ) throws { - try pager.loadMore(cachePolicy: cachePolicy, completion: completion) + callbackQueue: DispatchQueue = .main, + completion: ((PaginationError?) -> Void)? = nil + ) { + pager.loadPrevious(cachePolicy: cachePolicy, callbackQueue: callbackQueue, completion: completion) + } + + /// Loads all pages. + /// - Parameters: + /// - fetchFromInitialPage: Pass true to begin loading from the initial page; otherwise pass false. Defaults to `true`. **NOTE**: Loading all pages with this value set to `false` requires that the initial page has already been loaded previously. + /// - callbackQueue: The `DispatchQueue` that the `completion` fires on. Defaults to `main`. + /// - completion: A completion block that will always trigger after the execution of this operation. Passes an optional error, of type `PaginationError`, if there was an internal error related to pagination. Does not surface network errors. Defaults to `nil`. + public func loadAll( + fetchFromInitialPage: Bool = true, + callbackQueue: DispatchQueue = .main, + completion: ((PaginationError?) -> Void)? = nil + ) { + pager.loadAll(fetchFromInitialPage: fetchFromInitialPage, callbackQueue: callbackQueue, completion: completion) } + /// Discards pagination state and fetches the first page from scratch. + /// - Parameter cachePolicy: The apollo cache policy to trigger the first fetch with. Defaults to `fetchIgnoringCacheData`. public func refetch(cachePolicy: CachePolicy = .fetchIgnoringCacheData) { pager.refetch(cachePolicy: cachePolicy) } + /// Fetches the first page. public func fetch() { pager.fetch() } + /// Resets pagination state and cancels further updates from the pager. public func cancel() { pager.cancel() } } -extension GraphQLQueryPager.Actor { - nonisolated func eraseToAnyPager( - transform: @escaping (InitialQuery.Data, [PaginatedQuery.Data]) throws -> T - ) -> AnyGraphQLQueryPager { - AnyGraphQLQueryPager( - pager: GraphQLQueryPager(pager: self), - transform: transform - ) - } - - nonisolated func eraseToAnyPager( - initialTransform: @escaping (InitialQuery.Data) throws -> S, - nextPageTransform: @escaping (PaginatedQuery.Data) throws -> S - ) -> AnyGraphQLQueryPager where T == S.Element { - AnyGraphQLQueryPager( - pager: GraphQLQueryPager(pager: self), - initialTransform: initialTransform, - nextPageTransform: nextPageTransform - ) - } - - nonisolated func eraseToAnyPager( - transform: @escaping (InitialQuery.Data) throws -> S - ) -> AnyGraphQLQueryPager where InitialQuery == PaginatedQuery, T == S.Element { - AnyGraphQLQueryPager( - pager: GraphQLQueryPager(pager: self), - initialTransform: transform, - nextPageTransform: transform - ) - } -} - public extension GraphQLQueryPager { func eraseToAnyPager( - transform: @escaping (InitialQuery.Data, [PaginatedQuery.Data]) throws -> T + transform: @escaping ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> T ) -> AnyGraphQLQueryPager { AnyGraphQLQueryPager(pager: self, transform: transform) } @@ -144,7 +154,7 @@ public extension GraphQLQueryPager { AnyGraphQLQueryPager( pager: self, initialTransform: initialTransform, - nextPageTransform: nextPageTransform + pageTransform: nextPageTransform ) } @@ -154,7 +164,7 @@ public extension GraphQLQueryPager { AnyGraphQLQueryPager( pager: self, initialTransform: transform, - nextPageTransform: transform + pageTransform: transform ) } } diff --git a/Sources/ApolloPagination/AsyncGraphQLQueryPager.swift b/Sources/ApolloPagination/AsyncGraphQLQueryPager.swift new file mode 100644 index 000000000..04f00a4c2 --- /dev/null +++ b/Sources/ApolloPagination/AsyncGraphQLQueryPager.swift @@ -0,0 +1,453 @@ +import Apollo +import ApolloAPI +import Combine +import Foundation +import OrderedCollections + +public protocol AsyncPagerType { + associatedtype InitialQuery: GraphQLQuery + associatedtype PaginatedQuery: GraphQLQuery + var canLoadNext: Bool { get async } + var canLoadPrevious: Bool { get async } + func cancel() async + func loadPrevious(cachePolicy: CachePolicy) async throws + func loadNext(cachePolicy: CachePolicy) async throws + func loadAll(fetchFromInitialPage: Bool) async throws + func refetch(cachePolicy: CachePolicy) async + func fetch() async +} + +public actor AsyncGraphQLQueryPager: AsyncPagerType { + private let client: any ApolloClientProtocol + private var firstPageWatcher: GraphQLQueryWatcher? + private var nextPageWatchers: [GraphQLQueryWatcher] = [] + let initialQuery: InitialQuery + var isLoadingAll: Bool = false + var isFetching: Bool = false + let nextPageResolver: (PaginationInfo) -> PaginatedQuery? + let previousPageResolver: (PaginationInfo) -> PaginatedQuery? + let extractPageInfo: (PageExtractionData) -> PaginationInfo + var nextPageInfo: PaginationInfo? { nextPageTransformation() } + var previousPageInfo: PaginationInfo? { previousPageTransformation() } + + var canLoadPages: (next: Bool, previous: Bool) { + (canLoadNext, canLoadPrevious) + } + + var publishers: ( + previousPageVarMap: Published>.Publisher, + initialPageResult: Published.Publisher, + nextPageVarMap: Published>.Publisher + ) { + return ($previousPageVarMap, $initialPageResult, $nextPageVarMap) + } + + @Published var currentValue: Result, Error>? + private var queuedValue: Result, Error>? + + @Published var initialPageResult: InitialQuery.Data? + var latest: (previous: [PaginatedQuery.Data], initial: InitialQuery.Data, next: [PaginatedQuery.Data])? { + guard let initialPageResult else { return nil } + return ( + Array(previousPageVarMap.values).reversed(), + initialPageResult, + Array(nextPageVarMap.values) + ) + } + + /// Maps each query variable set to latest results from internal watchers. + @Published var nextPageVarMap: OrderedDictionary = [:] + @Published var previousPageVarMap: OrderedDictionary = [:] + private var tasks: Set> = [] + private var taskGroup: ThrowingTaskGroup? + private var watcherCallbackQueue: DispatchQueue + + /// Designated Initializer + /// - Parameters: + /// - client: Apollo Client + /// - initialQuery: The initial query that is being watched + /// - extractPageInfo: The `PageInfo` derived from `PageExtractionData` + /// - nextPageResolver: The resolver that can derive the query for loading more. This can be a different query than the `initialQuery`. + /// - onError: The callback when there is an error. + public init( + client: ApolloClientProtocol, + initialQuery: InitialQuery, + watcherDispatchQueue: DispatchQueue = .main, + extractPageInfo: @escaping (PageExtractionData) -> P, + pageResolver: ((P, PaginationDirection) -> PaginatedQuery?)? + ) { + self.client = client + self.initialQuery = initialQuery + self.extractPageInfo = extractPageInfo + self.watcherCallbackQueue = watcherDispatchQueue + self.nextPageResolver = { page in + guard let page = page as? P else { return nil } + return pageResolver?(page, .next) + } + self.previousPageResolver = { page in + guard let page = page as? P else { return nil } + return pageResolver?(page, .previous) + } + } + + deinit { + nextPageWatchers.forEach { $0.cancel() } + firstPageWatcher?.cancel() + taskGroup?.cancelAll() + tasks.forEach { $0.cancel() } + tasks.removeAll() + } + + // MARK: - Public API + + public func loadAll(fetchFromInitialPage: Bool = true) async throws { + return try await withThrowingTaskGroup(of: Void.self) { group in + taskGroup = group + func appendJobs() { + if nextPageInfo?.canLoadNext ?? false { + group.addTask { [weak self] in + try await self?.loadNext() + } + } else if previousPageInfo?.canLoadPrevious ?? false { + group.addTask { [weak self] in + try await self?.loadPrevious() + } + } + } + + // We begin by setting the initial state. The group needs some job to perform or it will perform nothing. + if fetchFromInitialPage { + // If we are fetching from an initial page, then we will want to reset state and then add a task for the initial load. + cancel() + isLoadingAll = true + group.addTask { [weak self] in + await self?.fetch(cachePolicy: .fetchIgnoringCacheData) + } + } else if initialPageResult == nil { + // Otherwise, we have to make sure that we have an `initialPageResult` + throw PaginationError.missingInitialPage + } else { + isLoadingAll = true + appendJobs() + } + + // We only have one job in the group per execution. + // Calling `next()` will either throw or give the next result (irrespective of order added into the queue). + // Upon cancellation, the error is propogated to the task group and all remaining child tasks in the group are cancelled. + while try await group.next() != nil { + appendJobs() + } + + // Setup return state + isLoadingAll = false + if let queuedValue { + currentValue = queuedValue + } + queuedValue = nil + taskGroup = nil + } + } + + public func loadPrevious( + cachePolicy: CachePolicy = .fetchIgnoringCacheData + ) async throws { + try await paginationFetch(direction: .previous, cachePolicy: cachePolicy) + } + + /// Loads the next page, using the currently saved pagination information to do so. + /// Thread-safe, and supports multiple subscribers calling from multiple threads. + /// **NOTE**: Requires having already called `fetch` or `refetch` prior to this call. + /// - Parameters: + /// - cachePolicy: Preferred cache policy for fetching subsequent pages. Defaults to `fetchIgnoringCacheData`. + public func loadNext( + cachePolicy: CachePolicy = .fetchIgnoringCacheData + ) async throws { + try await paginationFetch(direction: .next, cachePolicy: cachePolicy) + } + + public func subscribe( + onUpdate: @escaping (Result, Error>) -> Void + ) -> AnyCancellable { + $currentValue.compactMap({ $0 }) + .sink { [weak self] result in + Task { [weak self] in + guard let self else { return } + let isLoadingAll = await self.isLoadingAll + guard !isLoadingAll else { return } + onUpdate(result) + } + } + } + + /// Reloads all data, starting at the first query, resetting pagination state. + /// - Parameter cachePolicy: Preferred cache policy for first-page fetches. Defaults to `returnCacheDataAndFetch` + public func refetch(cachePolicy: CachePolicy = .fetchIgnoringCacheData) async { + assert(firstPageWatcher != nil, "To create consistent product behaviors, calling `fetch` before calling `refetch` will use cached data while still refreshing.") + cancel() + await fetch(cachePolicy: cachePolicy) + } + + public func fetch() async { + cancel() + await fetch(cachePolicy: .returnCacheDataAndFetch) + } + + /// Cancel any in progress fetching operations and unsubscribe from the store. + public func cancel() { + nextPageWatchers.forEach { $0.cancel() } + nextPageWatchers = [] + firstPageWatcher?.cancel() + firstPageWatcher = nil + previousPageVarMap = [:] + nextPageVarMap = [:] + initialPageResult = nil + + // Ensure any active networking operations are halted. + taskGroup?.cancelAll() + tasks.forEach { $0.cancel() } + tasks.removeAll() + isFetching = false + isLoadingAll = false + } + + /// Whether or not we can load more information based on the current page. + public var canLoadNext: Bool { + nextPageInfo?.canLoadNext ?? false + } + + public var canLoadPrevious: Bool { + previousPageInfo?.canLoadPrevious ?? false + } + + // MARK: - Private + + private func fetch(cachePolicy: CachePolicy = .returnCacheDataAndFetch) async { + await execute { [weak self] publisher in + guard let self else { return } + if await self.firstPageWatcher == nil { + let watcher = GraphQLQueryWatcher(client: client, query: initialQuery, callbackQueue: await watcherCallbackQueue) { [weak self] result in + Task { [weak self] in + await self?.onFetch( + fetchType: .initial, + cachePolicy: cachePolicy, + result: result, + publisher: publisher + ) + } + } + await self.setFirstPageWatcher(watcher: watcher) + } + await self.firstPageWatcher?.refetch(cachePolicy: cachePolicy) + } + } + + private func paginationFetch( + direction: PaginationDirection, + cachePolicy: CachePolicy + ) async throws { + // Access to `isFetching` is mutually exclusive, so these checks and modifications will prevent + // other attempts to call this function in rapid succession. + if isFetching { throw PaginationError.loadInProgress } + isFetching = true + defer { isFetching = false } + + // Determine the query based on whether we are paginating forward or backwards + let pageQuery: PaginatedQuery? + switch direction { + case .previous: + guard let previousPageInfo else { throw PaginationError.missingInitialPage } + guard previousPageInfo.canLoadPrevious else { throw PaginationError.pageHasNoMoreContent } + pageQuery = previousPageResolver(previousPageInfo) + case .next: + guard let nextPageInfo else { throw PaginationError.missingInitialPage } + guard nextPageInfo.canLoadNext else { throw PaginationError.pageHasNoMoreContent } + pageQuery = nextPageResolver(nextPageInfo) + } + guard let pageQuery else { throw PaginationError.noQuery } + + await execute { [weak self] publisher in + guard let self else { return } + let watcher = GraphQLQueryWatcher(client: self.client, query: pageQuery, callbackQueue: await watcherCallbackQueue) { [weak self] result in + Task { [weak self] in + await self?.onFetch( + fetchType: .paginated(direction, pageQuery), + cachePolicy: cachePolicy, + result: result, + publisher: publisher + ) + } + } + await self.appendPaginationWatcher(watcher: watcher) + watcher.refetch(cachePolicy: cachePolicy) + } + } + + private func onFetch( + fetchType: FetchType, + cachePolicy: CachePolicy, + result: Result, Error>, + publisher: CurrentValueSubject + ) { + switch result { + case .failure(let error): + if isLoadingAll { + queuedValue = .failure(error) + } else { + currentValue = .failure(error) + } + publisher.send(completion: .finished) + case .success(let data): + guard let pageData = data.data else { + publisher.send(completion: .finished) + return + } + + let shouldUpdate: Bool + if cachePolicy == .returnCacheDataAndFetch && data.source == .cache { + shouldUpdate = false + } else { + shouldUpdate = true + } + + var value: Result, Error>? + var output: ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data])? + switch fetchType { + case .initial: + guard let pageData = pageData as? InitialQuery.Data else { return } + initialPageResult = pageData + if let latest { + output = (latest.previous, pageData, latest.next) + } + case .paginated(let direction, let query): + guard let pageData = pageData as? PaginatedQuery.Data else { return } + + let variables = query.__variables?.underlyingJson ?? [] + switch direction { + case .next: + nextPageVarMap[variables] = pageData + case .previous: + previousPageVarMap[variables] = pageData + } + + if let latest { + output = latest + } + } + + value = output.flatMap { previousPages, initialPage, nextPages in + Result.success(PaginationOutput( + previousPages: previousPages, + initialPage: initialPage, + nextPages: nextPages, + updateSource: data.source == .cache ? .cache : .fetch + )) + } + + if let value { + if isLoadingAll { + queuedValue = value + } else { + currentValue = value + } + } + if shouldUpdate { + publisher.send(completion: .finished) + } + } + } + + private func nextPageTransformation() -> PaginationInfo? { + guard let last = nextPageVarMap.values.last else { + return initialPageResult.flatMap { extractPageInfo(.initial($0)) } + } + return extractPageInfo(.paginated(last)) + } + + private func previousPageTransformation() -> PaginationInfo? { + guard let first = previousPageVarMap.values.last else { + return initialPageResult.flatMap { extractPageInfo(.initial($0)) } + } + return extractPageInfo(.paginated(first)) + } + + private func execute(operation: @escaping (CurrentValueSubject) async throws -> Void) async { + let tasksCopy = tasks + await withCheckedContinuation { continuation in + let task = Task { + let fetchContainer = FetchContainer() + let publisher = CurrentValueSubject(()) + let subscriber = publisher.sink(receiveCompletion: { _ in + Task { await fetchContainer.cancel() } + }, receiveValue: { }) + await fetchContainer.setValues(subscriber: subscriber, continuation: continuation) + try await withTaskCancellationHandler { + try Task.checkCancellation() + try await operation(publisher) + } onCancel: { + Task { + await fetchContainer.cancel() + } + } + } + tasks.insert(task) + } + let remainder = tasks.subtracting(tasksCopy) + remainder.forEach { task in + tasks.remove(task) + } + } + + private func appendPaginationWatcher(watcher: GraphQLQueryWatcher) { + nextPageWatchers.append(watcher) + } + + private func setFirstPageWatcher(watcher: GraphQLQueryWatcher) { + firstPageWatcher = watcher + } +} + +private actor FetchContainer { + var subscriber: AnyCancellable? { + willSet { subscriber?.cancel() } + } + var continuation: CheckedContinuation? { + willSet { continuation?.resume() } + } + + init( + subscriber: AnyCancellable? = nil, + continuation: CheckedContinuation? = nil + ) { + self.subscriber = subscriber + self.continuation = continuation + } + + deinit { + continuation?.resume() + } + + func cancel() { + subscriber = nil + continuation = nil + } + + func setValues( + subscriber: AnyCancellable?, + continuation: CheckedContinuation? + ) { + self.subscriber = subscriber + self.continuation = continuation + } +} +private extension AsyncGraphQLQueryPager { + enum FetchType { + case initial + case paginated(PaginationDirection, PaginatedQuery) + } +} + +private extension GraphQLOperation.Variables { + var underlyingJson: [JSONValue] { + values.compactMap { $0._jsonEncodableValue?._jsonValue } + } +} diff --git a/Sources/ApolloPagination/CursorBasedPagination/BidirectionalPagination.swift b/Sources/ApolloPagination/CursorBasedPagination/BidirectionalPagination.swift new file mode 100644 index 000000000..d1d26c8b4 --- /dev/null +++ b/Sources/ApolloPagination/CursorBasedPagination/BidirectionalPagination.swift @@ -0,0 +1,24 @@ +extension CursorBasedPagination { + /// A cursor based pagination strategy that can support fetching previous and next pages. + public struct Bidirectional: PaginationInfo, Hashable { + public let hasNext: Bool + public let endCursor: String? + public let hasPrevious: Bool + public let startCursor: String? + + public var canLoadNext: Bool { hasNext } + public var canLoadPrevious: Bool { hasPrevious } + + public init( + hasNext: Bool, + endCursor: String?, + hasPrevious: Bool, + startCursor: String? + ) { + self.hasNext = hasNext + self.endCursor = endCursor + self.hasPrevious = hasPrevious + self.startCursor = startCursor + } + } +} diff --git a/Sources/ApolloPagination/CursorBasedPagination/CursorBasedPagination.swift b/Sources/ApolloPagination/CursorBasedPagination/CursorBasedPagination.swift index baa915315..2d920e72b 100644 --- a/Sources/ApolloPagination/CursorBasedPagination/CursorBasedPagination.swift +++ b/Sources/ApolloPagination/CursorBasedPagination/CursorBasedPagination.swift @@ -1 +1,3 @@ +/// A namespace to handle cursor based pagination strategies. +/// For more information on cursor based pagination strategies, see: https://www.apollographql.com/docs/react/pagination/cursor-based/ public enum CursorBasedPagination { } diff --git a/Sources/ApolloPagination/CursorBasedPagination/ForwardPagination.swift b/Sources/ApolloPagination/CursorBasedPagination/ForwardPagination.swift index f42d7b9e5..c39dae1ef 100644 --- a/Sources/ApolloPagination/CursorBasedPagination/ForwardPagination.swift +++ b/Sources/ApolloPagination/CursorBasedPagination/ForwardPagination.swift @@ -1,14 +1,15 @@ extension CursorBasedPagination { - public struct ForwardPagination: PaginationInfo, Hashable { - public let hasNext: Bool - public let endCursor: String? + /// A cursor based pagination strategy that supports forward pagination; fetching the next page. + public struct Forward: PaginationInfo, Hashable { + public let hasNext: Bool + public let endCursor: String? - public var canLoadMore: Bool { hasNext } + public var canLoadNext: Bool { hasNext } + public var canLoadPrevious: Bool { false } - public init(hasNext: Bool, endCursor: String?) { - self.hasNext = hasNext - self.endCursor = endCursor - } + public init(hasNext: Bool, endCursor: String?) { + self.hasNext = hasNext + self.endCursor = endCursor } - + } } diff --git a/Sources/ApolloPagination/CursorBasedPagination/ReversePagination.swift b/Sources/ApolloPagination/CursorBasedPagination/ReversePagination.swift index 01b6db9d4..a8435815f 100644 --- a/Sources/ApolloPagination/CursorBasedPagination/ReversePagination.swift +++ b/Sources/ApolloPagination/CursorBasedPagination/ReversePagination.swift @@ -1,14 +1,15 @@ extension CursorBasedPagination { - public struct ReversePagination: PaginationInfo, Hashable { - public let hasPrevious: Bool - public let startCursor: String? + /// A cursor-basd pagination strategy that can support fetching previous pages. + public struct Reverse: PaginationInfo, Hashable { + public let hasPrevious: Bool + public let startCursor: String? - public var canLoadMore: Bool { hasPrevious } + public var canLoadNext: Bool { false } + public var canLoadPrevious: Bool { hasPrevious } - public init(hasPrevious: Bool, startCursor: String?) { - self.hasPrevious = hasPrevious - self.startCursor = startCursor - } + public init(hasPrevious: Bool, startCursor: String?) { + self.hasPrevious = hasPrevious + self.startCursor = startCursor } - + } } diff --git a/Sources/ApolloPagination/GraphQLQueryPager+Convenience.swift b/Sources/ApolloPagination/GraphQLQueryPager+Convenience.swift index b825f3cdf..e82667c30 100644 --- a/Sources/ApolloPagination/GraphQLQueryPager+Convenience.swift +++ b/Sources/ApolloPagination/GraphQLQueryPager+Convenience.swift @@ -1,180 +1,275 @@ import Apollo import ApolloAPI +import Foundation -extension GraphQLQueryPager.Actor { - static func makeQueryPager( +public extension GraphQLQueryPager { + static func makeForwardCursorQueryPager( client: ApolloClientProtocol, - queryProvider: @escaping (P?) -> InitialQuery, - extractPageInfo: @escaping (InitialQuery.Data) -> P - ) -> GraphQLQueryPager.Actor where InitialQuery == PaginatedQuery { + watcherDispatchQueue: DispatchQueue = .main, + queryProvider: @escaping (CursorBasedPagination.Forward?) -> InitialQuery, + extractPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.Forward + ) -> GraphQLQueryPager where InitialQuery == PaginatedQuery { .init( client: client, initialQuery: queryProvider(nil), + watcherDispatchQueue: watcherDispatchQueue, extractPageInfo: pageExtraction(transform: extractPageInfo), - nextPageResolver: queryProvider + pageResolver: { page, direction in + guard direction == .next else { return nil } + return queryProvider(page) + } ) } - static func makeQueryPager( + static func makeForwardCursorQueryPager( client: ApolloClientProtocol, initialQuery: InitialQuery, - extractInitialPageInfo: @escaping (InitialQuery.Data) -> P, - extractNextPageInfo: @escaping (PaginatedQuery.Data) -> P, - nextPageResolver: @escaping (P) -> PaginatedQuery - ) -> GraphQLQueryPager.Actor { + watcherDispatchQueue: DispatchQueue = .main, + extractInitialPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.Forward, + extractNextPageInfo: @escaping (PaginatedQuery.Data) -> CursorBasedPagination.Forward, + nextPageResolver: @escaping (CursorBasedPagination.Forward) -> PaginatedQuery + ) -> GraphQLQueryPager { .init( client: client, initialQuery: initialQuery, + watcherDispatchQueue: watcherDispatchQueue, extractPageInfo: pageExtraction( initialTransfom: extractInitialPageInfo, paginatedTransform: extractNextPageInfo ), - nextPageResolver: nextPageResolver + pageResolver: { page, direction in + guard direction == .next else { return nil } + return nextPageResolver(page) + } ) } - static func makeForwardCursorQueryPager( + static func makeReverseCursorQueryPager( client: ApolloClientProtocol, - queryProvider: @escaping (CursorBasedPagination.ForwardPagination?) -> InitialQuery, - extractPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.ForwardPagination - ) -> GraphQLQueryPager.Actor where InitialQuery == PaginatedQuery { - .makeQueryPager( + watcherDispatchQueue: DispatchQueue = .main, + queryProvider: @escaping (CursorBasedPagination.Reverse?) -> InitialQuery, + extractPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.Reverse + ) -> GraphQLQueryPager where InitialQuery == PaginatedQuery { + .init( client: client, - queryProvider: queryProvider, - extractPageInfo: extractPageInfo + initialQuery: queryProvider(nil), + watcherDispatchQueue: watcherDispatchQueue, + extractPageInfo: pageExtraction(transform: extractPageInfo), + pageResolver: { page, direction in + guard direction == .previous else { return nil } + return queryProvider(page) + } ) } - static func makeForwardCursorQueryPager( + static func makeReverseCursorQueryPager( client: ApolloClientProtocol, initialQuery: InitialQuery, - extractInitialPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.ForwardPagination, - extractNextPageInfo: @escaping (PaginatedQuery.Data) -> CursorBasedPagination.ForwardPagination, - nextPageResolver: @escaping (CursorBasedPagination.ForwardPagination) -> PaginatedQuery - ) -> GraphQLQueryPager.Actor { - makeQueryPager( + watcherDispatchQueue: DispatchQueue = .main, + extractInitialPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.Reverse, + extractPreviousPageInfo: @escaping (PaginatedQuery.Data) -> CursorBasedPagination.Reverse, + previousPageResolver: @escaping (CursorBasedPagination.Reverse) -> PaginatedQuery + ) -> GraphQLQueryPager { + .init( client: client, initialQuery: initialQuery, - extractInitialPageInfo: extractInitialPageInfo, - extractNextPageInfo: extractNextPageInfo, - nextPageResolver: nextPageResolver + watcherDispatchQueue: watcherDispatchQueue, + extractPageInfo: pageExtraction( + initialTransfom: extractInitialPageInfo, + paginatedTransform: extractPreviousPageInfo + ), + pageResolver: { page, direction in + guard direction == .previous else { return nil } + return previousPageResolver(page) + } ) } - static func makeReverseCursorQueryPager( + static func makeBidirectionalCursorQueryPager( client: ApolloClientProtocol, - queryProvider: @escaping (CursorBasedPagination.ReversePagination?) -> InitialQuery, - extractPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.ReversePagination - ) -> GraphQLQueryPager.Actor where InitialQuery == PaginatedQuery { - .makeQueryPager( + initialQuery: InitialQuery, + watcherDispatchQueue: DispatchQueue = .main, + queryProvider: @escaping (CursorBasedPagination.Bidirectional?) -> PaginatedQuery, + previousQueryProvider: @escaping (CursorBasedPagination.Bidirectional?) -> PaginatedQuery, + extractInitialPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.Bidirectional, + extractPaginatedPageInfo: @escaping (PaginatedQuery.Data) -> CursorBasedPagination.Bidirectional + ) -> GraphQLQueryPager { + .init( client: client, - queryProvider: queryProvider, - extractPageInfo: extractPageInfo + initialQuery: initialQuery, + watcherDispatchQueue: watcherDispatchQueue, + extractPageInfo: pageExtraction( + initialTransfom: extractInitialPageInfo, + paginatedTransform: extractPaginatedPageInfo + ), + pageResolver: { page, direction in + switch direction { + case .next: + return queryProvider(page) + case .previous: + return previousQueryProvider(page) + } + } ) } - static func makeReverseCursorQueryPager( + static func makeBidirectionalCursorQueryPager( client: ApolloClientProtocol, - initialQuery: InitialQuery, - extractInitialPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.ReversePagination, - extractNextPageInfo: @escaping (PaginatedQuery.Data) -> CursorBasedPagination.ReversePagination, - nextPageResolver: @escaping (CursorBasedPagination.ReversePagination) -> PaginatedQuery - ) -> GraphQLQueryPager.Actor { - makeQueryPager( + start: CursorBasedPagination.Bidirectional?, + watcherDispatchQueue: DispatchQueue = .main, + queryProvider: @escaping (CursorBasedPagination.Bidirectional?) -> InitialQuery, + previousQueryProvider: @escaping (CursorBasedPagination.Bidirectional?) -> InitialQuery, + extractPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.Bidirectional + ) -> GraphQLQueryPager where InitialQuery == PaginatedQuery { + .init( client: client, - initialQuery: initialQuery, - extractInitialPageInfo: extractInitialPageInfo, - extractNextPageInfo: extractNextPageInfo, - nextPageResolver: nextPageResolver + initialQuery: queryProvider(start), + watcherDispatchQueue: watcherDispatchQueue, + extractPageInfo: pageExtraction(transform: extractPageInfo), + pageResolver: { page, direction in + switch direction { + case .next: + return queryProvider(page) + case .previous: + return previousQueryProvider(page) + } + } ) } } -public extension GraphQLQueryPager { - static func makeQueryPager( +public extension AsyncGraphQLQueryPager { + static func makeForwardCursorQueryPager( client: ApolloClientProtocol, - queryProvider: @escaping (P?) -> InitialQuery, - extractPageInfo: @escaping (InitialQuery.Data) -> P - ) -> GraphQLQueryPager where InitialQuery == PaginatedQuery { + watcherDispatchQueue: DispatchQueue = .main, + queryProvider: @escaping (CursorBasedPagination.Forward?) -> InitialQuery, + extractPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.Forward + ) -> AsyncGraphQLQueryPager where InitialQuery == PaginatedQuery { .init( client: client, initialQuery: queryProvider(nil), + watcherDispatchQueue: watcherDispatchQueue, extractPageInfo: pageExtraction(transform: extractPageInfo), - nextPageResolver: queryProvider + pageResolver: { page, direction in + guard direction == .next else { return nil } + return queryProvider(page) + } ) } - static func makeQueryPager( + static func makeForwardCursorQueryPager( client: ApolloClientProtocol, initialQuery: InitialQuery, - extractInitialPageInfo: @escaping (InitialQuery.Data) -> P, - extractNextPageInfo: @escaping (PaginatedQuery.Data) -> P, - nextPageResolver: @escaping (P) -> PaginatedQuery - ) -> GraphQLQueryPager { + watcherDispatchQueue: DispatchQueue = .main, + extractInitialPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.Forward, + extractNextPageInfo: @escaping (PaginatedQuery.Data) -> CursorBasedPagination.Forward, + nextPageResolver: @escaping (CursorBasedPagination.Forward) -> PaginatedQuery + ) -> AsyncGraphQLQueryPager { .init( client: client, initialQuery: initialQuery, + watcherDispatchQueue: watcherDispatchQueue, extractPageInfo: pageExtraction( initialTransfom: extractInitialPageInfo, paginatedTransform: extractNextPageInfo ), - nextPageResolver: nextPageResolver + pageResolver: { page, direction in + guard direction == .next else { return nil } + return nextPageResolver(page) + } ) } - static func makeForwardCursorQueryPager( + static func makeReverseCursorQueryPager( client: ApolloClientProtocol, - queryProvider: @escaping (CursorBasedPagination.ForwardPagination?) -> InitialQuery, - extractPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.ForwardPagination - ) -> GraphQLQueryPager where InitialQuery == PaginatedQuery { - .makeQueryPager( + watcherDispatchQueue: DispatchQueue = .main, + queryProvider: @escaping (CursorBasedPagination.Reverse?) -> InitialQuery, + extractPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.Reverse + ) -> AsyncGraphQLQueryPager where InitialQuery == PaginatedQuery { + .init( client: client, - queryProvider: queryProvider, - extractPageInfo: extractPageInfo + initialQuery: queryProvider(nil), + watcherDispatchQueue: watcherDispatchQueue, + extractPageInfo: pageExtraction(transform: extractPageInfo), + pageResolver: { page, direction in + guard direction == .previous else { return nil } + return queryProvider(page) + } ) } - static func makeForwardCursorQueryPager( + static func makeReverseCursorQueryPager( client: ApolloClientProtocol, initialQuery: InitialQuery, - extractInitialPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.ForwardPagination, - extractNextPageInfo: @escaping (PaginatedQuery.Data) -> CursorBasedPagination.ForwardPagination, - nextPageResolver: @escaping (CursorBasedPagination.ForwardPagination) -> PaginatedQuery - ) -> GraphQLQueryPager { - makeQueryPager( + watcherDispatchQueue: DispatchQueue = .main, + extractInitialPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.Reverse, + extractPreviousPageInfo: @escaping (PaginatedQuery.Data) -> CursorBasedPagination.Reverse, + previousPageResolver: @escaping (CursorBasedPagination.Reverse) -> PaginatedQuery + ) -> AsyncGraphQLQueryPager { + .init( client: client, initialQuery: initialQuery, - extractInitialPageInfo: extractInitialPageInfo, - extractNextPageInfo: extractNextPageInfo, - nextPageResolver: nextPageResolver + watcherDispatchQueue: watcherDispatchQueue, + extractPageInfo: pageExtraction( + initialTransfom: extractInitialPageInfo, + paginatedTransform: extractPreviousPageInfo + ), + pageResolver: { page, direction in + guard direction == .previous else { return nil } + return previousPageResolver(page) + } ) } - static func makeReverseCursorQueryPager( + static func makeBidirectionalCursorQueryPager( client: ApolloClientProtocol, - queryProvider: @escaping (CursorBasedPagination.ReversePagination?) -> InitialQuery, - extractPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.ReversePagination - ) -> GraphQLQueryPager where InitialQuery == PaginatedQuery { - .makeQueryPager( + initialQuery: InitialQuery, + watcherDispatchQueue: DispatchQueue = .main, + queryProvider: @escaping (CursorBasedPagination.Bidirectional?) -> PaginatedQuery, + previousQueryProvider: @escaping (CursorBasedPagination.Bidirectional?) -> PaginatedQuery, + extractInitialPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.Bidirectional, + extractPaginatedPageInfo: @escaping (PaginatedQuery.Data) -> CursorBasedPagination.Bidirectional + ) -> AsyncGraphQLQueryPager { + .init( client: client, - queryProvider: queryProvider, - extractPageInfo: extractPageInfo + initialQuery: initialQuery, + watcherDispatchQueue: watcherDispatchQueue, + extractPageInfo: pageExtraction( + initialTransfom: extractInitialPageInfo, + paginatedTransform: extractPaginatedPageInfo + ), + pageResolver: { page, direction in + switch direction { + case .next: + return queryProvider(page) + case .previous: + return previousQueryProvider(page) + } + } ) } - static func makeReverseCursorQueryPager( + static func makeBidirectionalCursorQueryPager( client: ApolloClientProtocol, - initialQuery: InitialQuery, - extractInitialPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.ReversePagination, - extractNextPageInfo: @escaping (PaginatedQuery.Data) -> CursorBasedPagination.ReversePagination, - nextPageResolver: @escaping (CursorBasedPagination.ReversePagination) -> PaginatedQuery - ) -> GraphQLQueryPager { - makeQueryPager( + start: CursorBasedPagination.Bidirectional?, + watcherDispatchQueue: DispatchQueue = .main, + queryProvider: @escaping (CursorBasedPagination.Bidirectional?) -> InitialQuery, + previousQueryProvider: @escaping (CursorBasedPagination.Bidirectional?) -> InitialQuery, + extractPageInfo: @escaping (InitialQuery.Data) -> CursorBasedPagination.Bidirectional + ) -> AsyncGraphQLQueryPager where InitialQuery == PaginatedQuery { + .init( client: client, - initialQuery: initialQuery, - extractInitialPageInfo: extractInitialPageInfo, - extractNextPageInfo: extractNextPageInfo, - nextPageResolver: nextPageResolver + initialQuery: queryProvider(start), + watcherDispatchQueue: watcherDispatchQueue, + extractPageInfo: pageExtraction(transform: extractPageInfo), + pageResolver: { page, direction in + switch direction { + case .next: + return queryProvider(page) + case .previous: + return previousQueryProvider(page) + } + } ) } } @@ -182,7 +277,7 @@ public extension GraphQLQueryPager { private func pageExtraction( initialTransfom: @escaping (InitialQuery.Data) -> P, paginatedTransform: @escaping (NextQuery.Data) -> P -) -> (GraphQLQueryPager.PageExtractionData) -> P { +) -> (PageExtractionData) -> P { { extractionData in switch extractionData { case .initial(let value): @@ -195,7 +290,7 @@ private func pageExtraction( transform: @escaping (InitialQuery.Data) -> P -) -> (GraphQLQueryPager.PageExtractionData) -> P { +) -> (PageExtractionData) -> P { { extractionData in switch extractionData { case .initial(let value), .paginated(let value): diff --git a/Sources/ApolloPagination/GraphQLQueryPager.swift b/Sources/ApolloPagination/GraphQLQueryPager.swift index 679042df5..9acf449fb 100644 --- a/Sources/ApolloPagination/GraphQLQueryPager.swift +++ b/Sources/ApolloPagination/GraphQLQueryPager.swift @@ -2,341 +2,227 @@ import Apollo import ApolloAPI import Combine import Foundation -import OrderedCollections public protocol PagerType { associatedtype InitialQuery: GraphQLQuery associatedtype PaginatedQuery: GraphQLQuery - typealias Output = (InitialQuery.Data, [PaginatedQuery.Data], UpdateSource) var canLoadNext: Bool { get } + var canLoadPrevious: Bool { get } func cancel() - func loadMore( + func loadPrevious( cachePolicy: CachePolicy, - completion: (@MainActor () -> Void)? - ) throws + callbackQueue: DispatchQueue, + completion: ((PaginationError?) -> Void)? + ) + func loadNext( + cachePolicy: CachePolicy, + callbackQueue: DispatchQueue, + completion: ((PaginationError?) -> Void)? + ) + func loadAll( + fetchFromInitialPage: Bool, + callbackQueue: DispatchQueue, + completion: ((PaginationError?) -> Void)? + ) func refetch(cachePolicy: CachePolicy) func fetch() } /// Handles pagination in the queue by managing multiple query watchers. public class GraphQLQueryPager: PagerType { - public typealias Output = (InitialQuery.Data, [PaginatedQuery.Data], UpdateSource) - - private let pager: Actor - private var cancellables: [AnyCancellable] = [] - private var canLoadNextSubject: CurrentValueSubject = .init(false) + let pager: AsyncGraphQLQueryPager + private var subscriptions = Subscriptions() + private var completionManager = CompletionManager() - /// The result of either the initial query or the paginated query, for the purpose of extracting a `PageInfo` from it. - public enum PageExtractionData { - case initial(InitialQuery.Data) - case paginated(PaginatedQuery.Data) + public var publisher: AnyPublisher, Error>, Never> { + get async { await pager.$currentValue.compactMap { $0 }.eraseToAnyPublisher() } } public init( client: ApolloClientProtocol, initialQuery: InitialQuery, - extractPageInfo: @escaping (PageExtractionData) -> P, - nextPageResolver: @escaping (P) -> PaginatedQuery + watcherDispatchQueue: DispatchQueue = .main, + extractPageInfo: @escaping (PageExtractionData) -> P, + pageResolver: ((P, PaginationDirection) -> PaginatedQuery?)? ) { pager = .init( client: client, initialQuery: initialQuery, + watcherDispatchQueue: watcherDispatchQueue, extractPageInfo: extractPageInfo, - nextPageResolver: nextPageResolver + pageResolver: pageResolver ) - Task { - let varMapPublisher = await pager.$varMap - let initialPublisher = await pager.$initialPageResult - varMapPublisher.combineLatest(initialPublisher).sink { [weak self] _ in - guard let self else { return } - Task { - let value = await self.pager.pageTransformation() - self.canLoadNextSubject.send(value?.canLoadMore ?? false) + Task { [weak self] in + guard let self else { return } + let (previousPageVarMapPublisher, initialPublisher, nextPageVarMapPublisher) = await pager.publishers + let publishSubscriber = previousPageVarMapPublisher.combineLatest( + initialPublisher, + nextPageVarMapPublisher + ).sink { [weak self] _ in + guard !Task.isCancelled else { return } + Task { [weak self] in + guard let self else { return } + let (canLoadNext, canLoadPrevious) = await self.pager.canLoadPages + self.canLoadNext = canLoadNext + self.canLoadPrevious = canLoadPrevious } - }.store(in: &cancellables) + } + await subscriptions.store(subscription: publishSubscriber) } } - init(pager: Actor) { + /// Convenience initializer + /// - Parameter pager: An `AsyncGraphQLQueryPager`. + public init(pager: AsyncGraphQLQueryPager) { self.pager = pager } - deinit { - cancellables.forEach { $0.cancel() } - } - - public func subscribe(onUpdate: @MainActor @escaping (Result) -> Void) { - Task { - await pager.subscribe(onUpdate: onUpdate) - .store(in: &cancellables) + /// Allows the caller to subscribe to new pagination results. + /// - Parameter onUpdate: A closure which provides the most recent pagination result. Execution may be on any thread. + public func subscribe(onUpdate: @escaping (Result, Error>) -> Void) { + Task { [weak self] in + guard let self else { return } + let subscription = await self.pager.subscribe(onUpdate: onUpdate) + await subscriptions.store(subscription: subscription) } } - public var canLoadNext: Bool { canLoadNextSubject.value } + /// Whether or not we can load the next page. Initializes with a `false` value that is updated after the initial fetch. + public var canLoadNext: Bool = false + /// Whether or not we can load the previous page. Initializes with a `false` value that is updated after the initial fetch. + public var canLoadPrevious: Bool = false + /// Reset all pagination state and cancel all in-flight requests. public func cancel() { - Task { - await pager.cancel() + Task { [weak self] in + guard let self else { return } + for completion in await self.completionManager.completions { + completion.execute(error: PaginationError.cancellation) + } + await self.completionManager.reset() + await self.pager.cancel() } } - public func loadMore( + /// Loads the previous page, if we can. + /// - Parameters: + /// - cachePolicy: The Apollo `CachePolicy` to use. Defaults to `fetchIgnoringCacheData`. + /// - callbackQueue: The `DispatchQueue` that the `completion` fires on. Defaults to `main`. + /// - completion: A completion block that will always trigger after the execution of this operation. Passes an optional error, of type `PaginationError`, if there was an internal error related to pagination. Does not surface network errors. Defaults to `nil`. + public func loadPrevious( cachePolicy: CachePolicy = .fetchIgnoringCacheData, - completion: (@MainActor () -> Void)? = nil - ) throws { - Task { - try await pager.loadMore(cachePolicy: cachePolicy) - await completion?() + callbackQueue: DispatchQueue = .main, + completion: ((PaginationError?) -> Void)? = nil + ) { + execute(callbackQueue: callbackQueue, completion: completion) { [weak self] in + try await self?.pager.loadPrevious(cachePolicy: cachePolicy) + } + } + + /// Loads the next page, if we can. + /// - Parameters: + /// - cachePolicy: The Apollo `CachePolicy` to use. Defaults to `fetchIgnoringCacheData`. + /// - callbackQueue: The `DispatchQueue` that the `completion` fires on. Defaults to `main`. + /// - completion: A completion block that will always trigger after the execution of this operation. Passes an optional error, of type `PaginationError`, if there was an internal error related to pagination. Does not surface network errors. Defaults to `nil`. + public func loadNext( + cachePolicy: CachePolicy = .fetchIgnoringCacheData, + callbackQueue: DispatchQueue = .main, + completion: ((PaginationError?) -> Void)? = nil + ) { + execute(callbackQueue: callbackQueue, completion: completion) { [weak self] in + try await self?.pager.loadNext(cachePolicy: cachePolicy) } } + /// Loads all pages. + /// - Parameters: + /// - fetchFromInitialPage: Pass true to begin loading from the initial page; otherwise pass false. Defaults to `true`. **NOTE**: Loading all pages with this value set to `false` requires that the initial page has already been loaded previously. + /// - callbackQueue: The `DispatchQueue` that the `completion` fires on. Defaults to `main`. + /// - completion: A completion block that will always trigger after the execution of this operation. Passes an optional error, of type `PaginationError`, if there was an internal error related to pagination. Does not surface network errors. Defaults to `nil`. + public func loadAll( + fetchFromInitialPage: Bool = true, + callbackQueue: DispatchQueue = .main, + completion: ((PaginationError?) -> Void)? = nil + ) { + execute(callbackQueue: callbackQueue, completion: completion) { [weak self] in + try await self?.pager.loadAll(fetchFromInitialPage: fetchFromInitialPage) + } + } + + /// Discards pagination state and fetches the first page from scratch. + /// - Parameter cachePolicy: The apollo cache policy to trigger the first fetch with. Defaults to `fetchIgnoringCacheData`. public func refetch(cachePolicy: CachePolicy = .fetchIgnoringCacheData) { Task { + for completion in await self.completionManager.completions { + completion.execute(error: PaginationError.cancellation) + } await pager.refetch(cachePolicy: cachePolicy) } } + /// Fetches the first page. public func fetch() { Task { await pager.fetch() } } -} - -extension GraphQLQueryPager { - actor Actor { - private let client: any ApolloClientProtocol - private var firstPageWatcher: GraphQLQueryWatcher? - private var nextPageWatchers: [GraphQLQueryWatcher] = [] - private let initialQuery: InitialQuery - let nextPageResolver: (PaginationInfo) -> PaginatedQuery? - let extractPageInfo: (PageExtractionData) -> PaginationInfo - var currentPageInfo: PaginationInfo? { - pageTransformation() - } - - @Published var currentValue: Result? - private var subscribers: [AnyCancellable] = [] - - @Published var initialPageResult: InitialQuery.Data? - var latest: (InitialQuery.Data, [PaginatedQuery.Data])? { - guard let initialPageResult else { return nil } - return (initialPageResult, Array(varMap.values)) - } - - /// Maps each query variable set to latest results from internal watchers. - @Published var varMap: OrderedDictionary = [:] - - private var activeTask: Task? - - /// Designated Initializer - /// - Parameters: - /// - client: Apollo Client - /// - initialQuery: The initial query that is being watched - /// - extractPageInfo: The `PageInfo` derived from `PageExtractionData` - /// - nextPageResolver: The resolver that can derive the query for loading more. This can be a different query than the `initialQuery`. - /// - onError: The callback when there is an error. - public init( - client: ApolloClientProtocol, - initialQuery: InitialQuery, - extractPageInfo: @escaping (PageExtractionData) -> P, - nextPageResolver: @escaping (P) -> PaginatedQuery - ) { - self.client = client - self.initialQuery = initialQuery - self.extractPageInfo = extractPageInfo - self.nextPageResolver = { page in - guard let page = page as? P else { return nil } - return nextPageResolver(page) - } - } - - deinit { - nextPageWatchers.forEach { $0.cancel() } - firstPageWatcher?.cancel() - subscribers.forEach { $0.cancel() } - } - - // MARK: - Public API - - /// A convenience wrapper around the asynchronous `loadMore` function. - public func loadMore( - cachePolicy: CachePolicy = .fetchIgnoringCacheData, - completion: (() -> Void)? = nil - ) throws { - Task { - try await loadMore(cachePolicy: cachePolicy) - completion?() - } - } - /// Loads the next page, using the currently saved pagination information to do so. - /// Thread-safe, and supports multiple subscribers calling from multiple threads. - /// **NOTE**: Requires having already called `fetch` or `refetch` prior to this call. - /// - Parameters: - /// - cachePolicy: Preferred cache policy for fetching subsequent pages. Defaults to `fetchIgnoringCacheData`. - public func loadMore( - cachePolicy: CachePolicy = .fetchIgnoringCacheData - ) async throws { - guard let currentPageInfo else { - assertionFailure("No page info detected -- are you calling `loadMore` prior to calling the initial fetch?") - throw PaginationError.missingInitialPage + private func execute(callbackQueue: DispatchQueue, completion: ((PaginationError?) -> Void)?, operation: @escaping () async throws -> Void) { + Task<_, Never> { [weak self] in + let completionHandler = Completion(callbackQueue: callbackQueue, completion: completion) + await self?.completionManager.append(completion: completionHandler) + do { + try await operation() + await self?.completionManager.execute(completion: completionHandler, with: nil) + } catch { + await self?.completionManager.execute(completion: completionHandler, with: error as? PaginationError ?? .unknown(error)) } - guard let nextPageQuery = nextPageResolver(currentPageInfo), - currentPageInfo.canLoadMore - else { throw PaginationError.pageHasNoMoreContent } - guard activeTask == nil else { - throw PaginationError.loadInProgress - } - - activeTask = Task { - let publisher = CurrentValueSubject(()) - await withCheckedContinuation { continuation in - let watcher = GraphQLQueryWatcher(client: client, query: nextPageQuery) { [weak self] result in - guard let self else { return } - Task { - await self.onSubsequentFetch( - cachePolicy: cachePolicy, - result: result, - publisher: publisher, - query: nextPageQuery - ) - } - } - nextPageWatchers.append(watcher) - publisher.sink(receiveCompletion: { [weak self] _ in - continuation.resume(with: .success(())) - guard let self else { return } - Task { await self.onTaskCancellation() } - }, receiveValue: { }) - .store(in: &subscribers) - watcher.refetch(cachePolicy: cachePolicy) - } - } - await activeTask?.value - } - - public func subscribe(onUpdate: @MainActor @escaping (Result) -> Void) -> AnyCancellable { - $currentValue.compactMap({ $0 }).sink { result in - Task { - await onUpdate(result) - } - } - } - - /// Reloads all data, starting at the first query, resetting pagination state. - /// - Parameter cachePolicy: Preferred cache policy for first-page fetches. Defaults to `returnCacheDataAndFetch` - public func refetch(cachePolicy: CachePolicy = .fetchIgnoringCacheData) { - assert(firstPageWatcher != nil, "To create consistent product behaviors, calling `fetch` before calling `refetch` will use cached data while still refreshing.") - cancel() - fetch(cachePolicy: cachePolicy) } + } +} - public func fetch() { - cancel() - fetch(cachePolicy: .returnCacheDataAndFetch) - } - - /// Cancel any in progress fetching operations and unsubscribe from the store. - public func cancel() { - nextPageWatchers.forEach { $0.cancel() } - nextPageWatchers = [] - firstPageWatcher?.cancel() - firstPageWatcher = nil - - varMap = [:] - initialPageResult = nil - activeTask?.cancel() - activeTask = nil - subscribers.forEach { $0.cancel() } - subscribers.removeAll() - } +private actor Subscriptions { + var subscriptions: Set = [] - /// Whether or not we can load more information based on the current page. - public var canLoadNext: Bool { - currentPageInfo?.canLoadMore ?? false - } + func store(subscription: AnyCancellable) { + subscriptions.insert(subscription) + } +} - // MARK: - Private +private class Completion { + var completion: ((PaginationError?) -> Void)? + var callbackQueue: DispatchQueue - private func fetch(cachePolicy: CachePolicy = .returnCacheDataAndFetch) { - if firstPageWatcher == nil { - firstPageWatcher = GraphQLQueryWatcher( - client: client, - query: initialQuery, - resultHandler: { [weak self] result in - guard let self else { return } - Task { - await self.onInitialFetch(result: result) - } - } - ) - } - firstPageWatcher?.refetch(cachePolicy: cachePolicy) - } + init(callbackQueue: DispatchQueue, completion: ((PaginationError?) -> Void)?) { + self.completion = completion + self.callbackQueue = callbackQueue + } - private func onInitialFetch(result: Result, Error>) { - switch result { - case .success(let data): - initialPageResult = data.data - guard let firstPageData = data.data else { return } - if let latest { - let (_, nextPage) = latest - currentValue = .success((firstPageData, nextPage, data.source == .cache ? .cache : .fetch)) - } - case .failure(let error): - currentValue = .failure(error) - } + func execute(error: PaginationError?) { + callbackQueue.async { [weak self] in + self?.completion?(error) + self?.completion = nil } + } +} - private func onSubsequentFetch( - cachePolicy: CachePolicy, - result: Result, Error>, - publisher: CurrentValueSubject, - query: PaginatedQuery - ) { - switch result { - case .success(let data): - guard let nextPageData = data.data else { - publisher.send(completion: .finished) - return - } +private actor CompletionManager { + var completions: [Completion] = [] - let shouldUpdate: Bool - if cachePolicy == .returnCacheDataAndFetch && data.source == .cache { - shouldUpdate = false - } else { - shouldUpdate = true - } - let variables = query.__variables?.values.compactMap { $0._jsonEncodableValue?._jsonValue } ?? [] - if shouldUpdate { - publisher.send(completion: .finished) - } - varMap[variables] = nextPageData + func append(completion: Completion) { + completions.append(completion) + } - if let latest { - let (firstPage, nextPage) = latest - currentValue = .success((firstPage, nextPage, data.source == .cache ? .cache : .fetch)) - } - case .failure(let error): - currentValue = .failure(error) - publisher.send(completion: .finished) - } - } + func reset() { + completions.removeAll() + } - private func onTaskCancellation() { - activeTask?.cancel() - activeTask = nil - subscribers.forEach { $0.cancel() } - subscribers = [] - } + func execute(completion: Completion, with error: PaginationError?) { + completion.execute(error: error) + } - fileprivate func pageTransformation() -> PaginationInfo? { - guard let last = varMap.values.last else { - return initialPageResult.flatMap { extractPageInfo(.initial($0)) } - } - return extractPageInfo(.paginated(last)) - } + deinit { + completions.forEach { $0.completion?(PaginationError.cancellation) } } } diff --git a/Sources/ApolloPagination/GraphQLQueryPagerOutput.swift b/Sources/ApolloPagination/GraphQLQueryPagerOutput.swift new file mode 100644 index 000000000..3c884e3d1 --- /dev/null +++ b/Sources/ApolloPagination/GraphQLQueryPagerOutput.swift @@ -0,0 +1,30 @@ +import ApolloAPI +import Foundation + +/// A struct which contains the outputs of pagination +public struct PaginationOutput: Hashable { + /// An array of previous pages, in pagination order + /// Earlier pages come first in the array. + public let previousPages: [PaginatedQuery.Data] + + /// The initial page that we fetched. + public let initialPage: InitialQuery.Data + + /// An array of pages after the initial page. + public let nextPages: [PaginatedQuery.Data] + + /// The source of the most recent `Output`: either from the cache or server. + public let updateSource: UpdateSource + + public init( + previousPages: [PaginatedQuery.Data], + initialPage: InitialQuery.Data, + nextPages: [PaginatedQuery.Data], + updateSource: UpdateSource + ) { + self.previousPages = previousPages + self.initialPage = initialPage + self.nextPages = nextPages + self.updateSource = updateSource + } +} diff --git a/Sources/ApolloPagination/OffsetBasedPagination/OffsetPagination.swift b/Sources/ApolloPagination/OffsetBasedPagination/OffsetPagination.swift index 2be054bd8..0e7f35bea 100644 --- a/Sources/ApolloPagination/OffsetBasedPagination/OffsetPagination.swift +++ b/Sources/ApolloPagination/OffsetBasedPagination/OffsetPagination.swift @@ -1,9 +1,10 @@ public struct OffsetPagination: PaginationInfo, Hashable { public let offset: Int - public let canLoadMore: Bool + public let canLoadNext: Bool + public var canLoadPrevious: Bool { false } - public init(offset: Int, canLoadMore: Bool) { + public init(offset: Int, canLoadNext: Bool) { self.offset = offset - self.canLoadMore = canLoadMore + self.canLoadNext = canLoadNext } } diff --git a/Sources/ApolloPagination/PageExtractionData.swift b/Sources/ApolloPagination/PageExtractionData.swift new file mode 100644 index 000000000..8b7b82e49 --- /dev/null +++ b/Sources/ApolloPagination/PageExtractionData.swift @@ -0,0 +1,7 @@ +import ApolloAPI + +/// The result of either the initial query or the paginated query, for the purpose of extracting a `PageInfo` from it. +public enum PageExtractionData { + case initial(InitialQuery.Data) + case paginated(PaginatedQuery.Data) +} diff --git a/Sources/ApolloPagination/PaginationDirection.swift b/Sources/ApolloPagination/PaginationDirection.swift new file mode 100644 index 000000000..b530f2cc2 --- /dev/null +++ b/Sources/ApolloPagination/PaginationDirection.swift @@ -0,0 +1,7 @@ +import ApolloAPI + +/// An enumeration that can determine whether we are paginating forward or backwards. +public enum PaginationDirection: Hashable { + case next + case previous +} diff --git a/Sources/ApolloPagination/PaginationError.swift b/Sources/ApolloPagination/PaginationError.swift index e510c50c3..1aa93e526 100644 --- a/Sources/ApolloPagination/PaginationError.swift +++ b/Sources/ApolloPagination/PaginationError.swift @@ -2,4 +2,8 @@ public enum PaginationError: Error { case missingInitialPage case pageHasNoMoreContent case loadInProgress + case noQuery + case cancellation + // Workaround for https://github.com/apple/swift-evolution/blob/f0128e6ed3cbea226c66c8ac630e216dd4140a69/proposals/0413-typed-throws.md + case unknown(Error) } diff --git a/Sources/ApolloPagination/PaginationInfo.swift b/Sources/ApolloPagination/PaginationInfo.swift index bb676e0a3..22b54bcdd 100644 --- a/Sources/ApolloPagination/PaginationInfo.swift +++ b/Sources/ApolloPagination/PaginationInfo.swift @@ -1,3 +1,4 @@ public protocol PaginationInfo: Sendable { - var canLoadMore: Bool { get } + var canLoadNext: Bool { get } + var canLoadPrevious: Bool { get } }