diff --git a/Package.swift b/Package.swift index db7da8aad..99f0db844 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,7 @@ let package = Package( .iOS(.v13), .macOS(.v10_15), .tvOS(.v13), - .watchOS(.v6) + .watchOS(.v6), ], products: [ .library(name: "ApolloPagination", targets: ["ApolloPagination"]), diff --git a/Sources/ApolloPagination/AsyncGraphQLQueryPager.swift b/Sources/ApolloPagination/AsyncGraphQLQueryPager.swift index eeea21506..52d07378f 100644 --- a/Sources/ApolloPagination/AsyncGraphQLQueryPager.swift +++ b/Sources/ApolloPagination/AsyncGraphQLQueryPager.swift @@ -6,7 +6,7 @@ import Foundation /// 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 AsyncGraphQLQueryPager: Publisher { public typealias Failure = Never - public typealias Output = Result<(Model, UpdateSource), any Error> + public typealias Output = Result let _subject: CurrentValueSubject = .init(nil) var publisher: AnyPublisher { _subject.compactMap({ $0 }).eraseToAnyPublisher() } @Atomic public var cancellables: Set = [] @@ -17,7 +17,7 @@ public class AsyncGraphQLQueryPager: Publisher { init, InitialQuery, PaginatedQuery>( pager: Pager, - transform: @escaping ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> Model + transform: @escaping (PaginationOutput) throws -> Model ) { self.pager = pager Task { @@ -26,10 +26,10 @@ public class AsyncGraphQLQueryPager: Publisher { let returnValue: Output switch result { - case let .success((output, source)): + case let .success(output): do { - let transformedModels = try transform(output.previousPages, output.initialPage, output.nextPages) - returnValue = .success((transformedModels, source)) + let transformedModels = try transform(output) + returnValue = .success(transformedModels) } catch { returnValue = .failure(error) } @@ -50,89 +50,19 @@ public class AsyncGraphQLQueryPager: Publisher { Task { let cancellable = await pager.subscribe { [weak self] result in guard let self else { return } - let returnValue: Output - - switch result { - case let .success((output, source)): - returnValue = .success((output, source)) - case let .failure(error): - returnValue = .failure(error) - } - - _subject.send(returnValue) + _subject.send(result) } _ = $cancellables.mutate { $0.insert(cancellable) } } } - convenience init< - Pager: AsyncGraphQLQueryPagerCoordinator, - InitialQuery, - PaginatedQuery, - Element - >( - pager: Pager, - initialTransform: @escaping (InitialQuery.Data) throws -> Model, - pageTransform: @escaping (PaginatedQuery.Data) throws -> Model - ) where Model: RangeReplaceableCollection, Model.Element == Element { - 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 - } - ) - } - - public convenience init< - P: PaginationInfo, - InitialQuery: GraphQLQuery, - PaginatedQuery: GraphQLQuery, - Element - >( - client: any ApolloClientProtocol, - initialQuery: InitialQuery, - watcherDispatchQueue: DispatchQueue = .main, - extractPageInfo: @escaping (PageExtractionData) -> P, - pageResolver: ((P, PaginationDirection) -> PaginatedQuery?)?, - initialTransform: @escaping (InitialQuery.Data) throws -> Model, - pageTransform: @escaping (PaginatedQuery.Data) throws -> Model - ) where Model: RangeReplaceableCollection, Model.Element == Element { - let pager = AsyncGraphQLQueryPagerCoordinator( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: { data in - switch data { - case .initial(let data, let output): - return extractPageInfo(.initial(data, convertOutput(result: output))) - case .paginated(let data, let output): - return extractPageInfo(.paginated(data, convertOutput(result: output))) - } - }, - pageResolver: pageResolver - ) - self.init( - pager: pager, - initialTransform: initialTransform, - pageTransform: pageTransform - ) - - func convertOutput(result: PaginationOutput?) -> Model? { - guard let result else { return nil } - - let transform: ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> Model = { 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 - } - return try? transform(result.previousPages, result.initialPage, result.nextPages) - } - } - + /// Initialize an `AsyncGraphQLQueryPager` that outputs a `PaginationOutput`. + /// - Parameters: + /// - client: Apollo client type + /// - initialQuery: The query to call for the first page of pagination. May be a separate type of query than the pagination query. + /// - watcherDispatchQueue: The queue that the underlying `GraphQLQueryWatcher`s respond on. Defaults to `main`. + /// - extractPageInfo: A user-input closure that instructs the pager on how to extract `P`, a `PaginationInfo` type, from the `Data` of either the `InitialQuery` or `PaginatedQuery`. + /// - pageResolver: A user-input closure that instructs the pager on how to create a new `PaginatedQuery` given a `PaginationInfo` and a `PaginationDirection`. public convenience init< P: PaginationInfo, InitialQuery: GraphQLQuery, @@ -151,11 +81,17 @@ public class AsyncGraphQLQueryPager: Publisher { extractPageInfo: extractPageInfo, pageResolver: pageResolver ) - self.init( - pager: pager - ) + self.init(pager: pager) } + /// Initialize an `AsyncGraphQLQueryPager` that outputs a user-defined `Model`, the result of the `transform` argument. + /// - Parameters: + /// - client: Apollo client type + /// - initialQuery: The query to call for the first page of pagination. May be a separate type of query than the pagination query. + /// - watcherDispatchQueue: The queue that the underlying `GraphQLQueryWatcher`s respond on. Defaults to `main`. + /// - extractPageInfo: A user-input closure that instructs the pager on how to extract `P`, a `PaginationInfo` type, from the `Data` of either the `InitialQuery` or `PaginatedQuery`. + /// - pageResolver: A user-input closure that instructs the pager on how to create a new `PaginatedQuery` given a `PaginationInfo` and a `PaginationDirection`. + /// - transform: Transforms the `PaginationOutput` into a `Model` type. public convenience init< P: PaginationInfo, InitialQuery: GraphQLQuery, @@ -164,38 +100,23 @@ public class AsyncGraphQLQueryPager: Publisher { client: any ApolloClientProtocol, initialQuery: InitialQuery, watcherDispatchQueue: DispatchQueue = .main, - extractPageInfo: @escaping (PageExtractionData) -> P, + extractPageInfo: @escaping (PageExtractionData?>) -> P, pageResolver: ((P, PaginationDirection) -> PaginatedQuery?)?, - transform: @escaping ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> Model + transform: @escaping (PaginationOutput) throws -> Model ) { let pager = AsyncGraphQLQueryPagerCoordinator( client: client, initialQuery: initialQuery, watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: { data in - switch data { - case .initial(let data, let output): - return extractPageInfo(.initial(data, convertOutput(result: output))) - case .paginated(let data, let output): - return extractPageInfo(.paginated(data, convertOutput(result: output))) - } - }, + extractPageInfo: extractPageInfo, pageResolver: pageResolver ) - self.init( - pager: pager, - transform: transform - ) - - func convertOutput(result: PaginationOutput?) -> Model? { - guard let result else { return nil } - return try? transform(result.previousPages, result.initialPage, result.nextPages) - } + self.init(pager: pager, transform: transform) } - /// 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. + @available(*, deprecated, message: "Will be removed in a future version of ApolloPagination. Use the `Combine` publishers instead. If you need to dispatch to the main thread, make sure to use a `.receive(on: RunLoop.main)` as part of your `Combine` operation.") public func subscribe(completion: @MainActor @escaping (Output) -> Void) { let cancellable = publisher.sink { result in Task { await completion(result) } @@ -221,7 +142,7 @@ public class AsyncGraphQLQueryPager: Publisher { try await pager.loadPrevious(cachePolicy: cachePolicy) } - /// Loads all pages. + /// Loads all pages. Does not output a value until all pages have loaded. /// - 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( @@ -243,30 +164,12 @@ public class AsyncGraphQLQueryPager: Publisher { /// Resets pagination state and cancels in-flight updates from the pager. public func reset() async { - await pager.reset() + await pager.reset() } public func receive( subscriber: S - ) where S: Subscriber, Never == S.Failure, Result<(Model, UpdateSource), any Error> == S.Input { + ) where S: Subscriber, Never == S.Failure, Result == S.Input { publisher.subscribe(subscriber) } } - -extension AsyncGraphQLQueryPager: Equatable where Model: Equatable { - public static func == (lhs: AsyncGraphQLQueryPager, rhs: AsyncGraphQLQueryPager) -> Bool { - let left = lhs._subject.value - let right = rhs._subject.value - - switch (left, right) { - case (.success((let leftValue, let leftSource)), .success((let rightValue, let rightSource))): - return leftValue == rightValue && leftSource == rightSource - case (.failure(let leftError), .failure(let rightError)): - return leftError.localizedDescription == rightError.localizedDescription - case (.none, .none): - return true - default: - return false - } - } -} diff --git a/Sources/ApolloPagination/AsyncGraphQLQueryPagerCoordinator.swift b/Sources/ApolloPagination/AsyncGraphQLQueryPagerCoordinator.swift index a6ddf8d06..c919bcbd4 100644 --- a/Sources/ApolloPagination/AsyncGraphQLQueryPagerCoordinator.swift +++ b/Sources/ApolloPagination/AsyncGraphQLQueryPagerCoordinator.swift @@ -35,20 +35,20 @@ actor AsyncGraphQLQueryPagerCoordinator, PaginatedQuery.Data>>.Publisher, - initialPageResult: Published.Publisher, - nextPageVarMap: Published, PaginatedQuery.Data>>.Publisher + previousPageVarMap: Published, GraphQLResult>>.Publisher, + initialPageResult: Published?>.Publisher, + nextPageVarMap: Published, GraphQLResult>>.Publisher ) { return ($previousPageVarMap, $initialPageResult, $nextPageVarMap) } - typealias ResultType = Result<(PaginationOutput, UpdateSource), any Error> + typealias ResultType = Result, any Error> @Published var currentValue: ResultType? private var queuedValue: ResultType? - @Published var initialPageResult: InitialQuery.Data? - var latest: (previous: [PaginatedQuery.Data], initial: InitialQuery.Data, next: [PaginatedQuery.Data])? { + @Published var initialPageResult: GraphQLResult? + var latest: (previous: [GraphQLResult], initial: GraphQLResult, next: [GraphQLResult])? { guard let initialPageResult else { return nil } return ( Array(previousPageVarMap.values).reversed(), @@ -58,8 +58,8 @@ actor AsyncGraphQLQueryPagerCoordinator, PaginatedQuery.Data> = [:] - @Published var previousPageVarMap: OrderedDictionary, PaginatedQuery.Data> = [:] + @Published var nextPageVarMap: OrderedDictionary, GraphQLResult> = [:] + @Published var previousPageVarMap: OrderedDictionary, GraphQLResult> = [:] private var tasks: Set> = [] private var taskGroup: ThrowingTaskGroup? private var watcherCallbackQueue: DispatchQueue @@ -136,7 +136,7 @@ actor AsyncGraphQLQueryPagerCoordinator, UpdateSource), any Error>) -> Void + onUpdate: @escaping (Result, any 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) + .flatMap { [weak self] result in + Future, any Error>?, Never> { [weak self] promise in + Task { [weak self] in + guard let self else { return } + let isLoadingAll = await self.isLoadingAll + guard !isLoadingAll else { return promise(.success(nil)) } + promise(.success(result)) + } } } + .sink { (result: Result, any Error>?) in + result.flatMap(onUpdate) + } } /// Reloads all data, starting at the first query, resetting pagination state. @@ -298,11 +303,6 @@ actor AsyncGraphQLQueryPagerCoordinator, UpdateSource), any Error>? - var output: ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data])? + var value: Result, any Error>? + var output: PaginationOutput? + var didFail = false switch fetchType { case .initial: - guard let pageData = pageData as? InitialQuery.Data else { return } - initialPageResult = pageData - if let latest { - output = (latest.previous, pageData, latest.next) + initialPageResult = data as? GraphQLResult + output = initialPageResult.flatMap { result in + .init( + previousPages: latest?.previous ?? [], + initialPage: latest?.initial, + nextPages: latest?.next ?? [], + lastUpdatedPage: .initial(result) + ) + } + if initialPageResult?.data == nil { + didFail = true } case .paginated(let direction, let query): - guard let pageData = pageData as? PaginatedQuery.Data else { return } - let variables = Set(query.__variables?.underlyingJson ?? []) + let underlyingData = data.data as? PaginatedQuery.Data switch direction { case .next: - nextPageVarMap[variables] = pageData + nextPageVarMap[variables] = data as? GraphQLResult case .previous: - previousPageVarMap[variables] = pageData + previousPageVarMap[variables] = data as? GraphQLResult } - if let latest { - output = latest + if let latest, let paginatedResult = data as? GraphQLResult { + output = .init( + previousPages: latest.previous, + initialPage: latest.initial, + nextPages: latest.next, + lastUpdatedPage: .paginated(paginatedResult) + ) + } + if underlyingData == nil { + didFail = true } } - value = output.flatMap { previousPages, initialPage, nextPages in - Result.success(( - PaginationOutput( - previousPages: previousPages, - initialPage: initialPage, - nextPages: nextPages - ), - data.source == .cache ? .cache : .fetch - )) + value = output.flatMap { paginationOutput in + Result.success(paginationOutput) } if let value { @@ -353,24 +361,27 @@ actor AsyncGraphQLQueryPagerCoordinator (any PaginationInfo)? { - let currentValue = try? currentValue?.get().0 - guard let last = nextPageVarMap.values.last else { - return initialPageResult.flatMap { extractPageInfo(.initial($0, currentValue)) } + let currentValue = try? currentValue?.get() + guard let last = nextPageVarMap.values.last?.data else { + return initialPageResult?.data.flatMap { extractPageInfo(.initial($0, currentValue)) } } return extractPageInfo(.paginated(last, currentValue)) } private func previousPageTransformation() -> (any PaginationInfo)? { - let currentValue = try? currentValue?.get().0 - guard let first = previousPageVarMap.values.last else { - return initialPageResult.flatMap { extractPageInfo(.initial($0, currentValue)) } + let currentValue = try? currentValue?.get() + guard let first = previousPageVarMap.values.last?.data else { + return initialPageResult?.data.flatMap { extractPageInfo(.initial($0, currentValue)) } } return extractPageInfo(.paginated(first, currentValue)) } @@ -456,3 +467,9 @@ private extension GraphQLOperation.Variables { values.compactMap { $0._jsonEncodableValue?._jsonValue } } } + +internal extension GraphQLResult { + var updateSource: UpdateSource { + source == .cache ? .cache : .server + } +} diff --git a/Sources/ApolloPagination/GraphQLQueryPager+Convenience.swift b/Sources/ApolloPagination/GraphQLQueryPager+Convenience.swift index c879defda..ee7193bd2 100644 --- a/Sources/ApolloPagination/GraphQLQueryPager+Convenience.swift +++ b/Sources/ApolloPagination/GraphQLQueryPager+Convenience.swift @@ -25,51 +25,6 @@ public extension GraphQLQueryPager { )) } - /// Convenience initializer for creating a pager that has a single query and - /// transforms output responses. - convenience init( - client: any ApolloClientProtocol, - watcherDispatchQueue: DispatchQueue = .main, - initialQuery: InitialQuery, - extractPageInfo: @escaping (InitialQuery.Data) -> P, - pageResolver: @escaping (P, PaginationDirection) -> InitialQuery?, - transform: @escaping ([InitialQuery.Data], InitialQuery.Data, [InitialQuery.Data]) throws -> Model - ) { - self.init( - pager: GraphQLQueryPagerCoordinator( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: pageExtraction(transform: extractPageInfo), - pageResolver: pageResolver - ), - transform: transform - ) - } - - /// Convenience initializer for creating a pager that has a single query and - /// transforms output responses into a collection. - convenience init( - client: any ApolloClientProtocol, - watcherDispatchQueue: DispatchQueue = .main, - initialQuery: InitialQuery, - extractPageInfo: @escaping (InitialQuery.Data) -> P, - pageResolver: @escaping (P, PaginationDirection) -> InitialQuery?, - transform: @escaping (InitialQuery.Data) throws -> Model - ) where Model: RangeReplaceableCollection, T == Model.Element { - self.init( - pager: GraphQLQueryPagerCoordinator( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: pageExtraction(transform: extractPageInfo), - pageResolver: pageResolver - ), - initialTransform: transform, - pageTransform: transform - ) - } - /// Convenience initializer for creating a multi-query pager that does not /// transform output responses. convenience init( @@ -93,59 +48,6 @@ public extension GraphQLQueryPager { ) ) } - - /// Convenience initializer for creating a multi-query pager that transforms output responses. - convenience init( - client: any ApolloClientProtocol, - initialQuery: InitialQuery, - watcherDispatchQueue: DispatchQueue = .main, - extractInitialPageInfo: @escaping (InitialQuery.Data) -> P, - extractNextPageInfo: @escaping (PaginatedQuery.Data) -> P, - pageResolver: @escaping (P, PaginationDirection) -> PaginatedQuery?, - transform: @escaping ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> Model - ) where Model == PaginationOutput { - self.init( - pager: .init( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: pageExtraction( - initialTransfom: extractInitialPageInfo, - paginatedTransform: extractNextPageInfo - ), - pageResolver: pageResolver - ), - transform: transform - ) - } - - /// Convenience initializer for creating a multi-query pager that - /// transforms output responses into collections - convenience init( - client: any ApolloClientProtocol, - initialQuery: InitialQuery, - watcherDispatchQueue: DispatchQueue = .main, - extractInitialPageInfo: @escaping (InitialQuery.Data) -> P, - extractNextPageInfo: @escaping (PaginatedQuery.Data) -> P, - pageResolver: @escaping (P, PaginationDirection) -> PaginatedQuery?, - initialTransform: @escaping (InitialQuery.Data) throws -> Model, - pageTransform: @escaping (PaginatedQuery.Data) throws -> Model - ) where Model: RangeReplaceableCollection, T == Model.Element { - self.init( - pager: .init( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: pageExtraction( - initialTransfom: extractInitialPageInfo, - paginatedTransform: extractNextPageInfo - ), - pageResolver: pageResolver - ), - initialTransform: initialTransform, - pageTransform: pageTransform - ) - } } // MARK: - AsyncGraphQLQueryPager Convenience Functions @@ -170,51 +72,6 @@ public extension AsyncGraphQLQueryPager { )) } - /// Convenience initializer for creating a pager that has a single query and - /// transforms output responses. - convenience init( - client: any ApolloClientProtocol, - watcherDispatchQueue: DispatchQueue = .main, - initialQuery: InitialQuery, - extractPageInfo: @escaping (InitialQuery.Data) -> P, - pageResolver: @escaping (P, PaginationDirection) -> InitialQuery?, - transform: @escaping ([InitialQuery.Data], InitialQuery.Data, [InitialQuery.Data]) throws -> Model - ) { - self.init( - pager: AsyncGraphQLQueryPagerCoordinator( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: pageExtraction(transform: extractPageInfo), - pageResolver: pageResolver - ), - transform: transform - ) - } - - /// Convenience initializer for creating a pager that has a single query and - /// transforms output responses into a collection. - convenience init( - client: any ApolloClientProtocol, - watcherDispatchQueue: DispatchQueue = .main, - initialQuery: InitialQuery, - extractPageInfo: @escaping (InitialQuery.Data) -> P, - pageResolver: @escaping (P, PaginationDirection) -> InitialQuery?, - transform: @escaping (InitialQuery.Data) throws -> Model - ) where Model: RangeReplaceableCollection, T == Model.Element { - self.init( - pager: AsyncGraphQLQueryPagerCoordinator( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: pageExtraction(transform: extractPageInfo), - pageResolver: pageResolver - ), - initialTransform: transform, - pageTransform: transform - ) - } - /// Convenience initializer for creating a multi-query pager that does not /// transform output responses. convenience init( @@ -238,60 +95,6 @@ public extension AsyncGraphQLQueryPager { ) ) } - - /// Convenience initializer for creating a multi-query pager that - /// transforms output responses. - convenience init( - client: any ApolloClientProtocol, - initialQuery: InitialQuery, - watcherDispatchQueue: DispatchQueue = .main, - extractInitialPageInfo: @escaping (InitialQuery.Data) -> P, - extractNextPageInfo: @escaping (PaginatedQuery.Data) -> P, - pageResolver: @escaping (P, PaginationDirection) -> PaginatedQuery?, - transform: @escaping ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> Model - ) where Model == PaginationOutput { - self.init( - pager: .init( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: pageExtraction( - initialTransfom: extractInitialPageInfo, - paginatedTransform: extractNextPageInfo - ), - pageResolver: pageResolver - ), - transform: transform - ) - } - - /// Convenience initializer for creating a multi-query pager that - /// transforms output responses into collections - convenience init( - client: any ApolloClientProtocol, - initialQuery: InitialQuery, - watcherDispatchQueue: DispatchQueue = .main, - extractInitialPageInfo: @escaping (InitialQuery.Data) -> P, - extractNextPageInfo: @escaping (PaginatedQuery.Data) -> P, - pageResolver: @escaping (P, PaginationDirection) -> PaginatedQuery?, - initialTransform: @escaping (InitialQuery.Data) throws -> Model, - pageTransform: @escaping (PaginatedQuery.Data) throws -> Model - ) where Model: RangeReplaceableCollection, T == Model.Element { - self.init( - pager: .init( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: pageExtraction( - initialTransfom: extractInitialPageInfo, - paginatedTransform: extractNextPageInfo - ), - pageResolver: pageResolver - ), - initialTransform: initialTransform, - pageTransform: pageTransform - ) - } } private func pageExtraction( diff --git a/Sources/ApolloPagination/GraphQLQueryPager.swift b/Sources/ApolloPagination/GraphQLQueryPager.swift index dcd47afa8..ffc604577 100644 --- a/Sources/ApolloPagination/GraphQLQueryPager.swift +++ b/Sources/ApolloPagination/GraphQLQueryPager.swift @@ -6,7 +6,7 @@ import Foundation /// 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 GraphQLQueryPager: Publisher { public typealias Failure = Never - public typealias Output = Result<(Model, UpdateSource), any Error> + public typealias Output = Result let _subject: CurrentValueSubject = .init(nil) var publisher: AnyPublisher { _subject.compactMap { $0 }.eraseToAnyPublisher() } public var cancellables: Set = [] @@ -17,7 +17,7 @@ public class GraphQLQueryPager: Publisher { init, InitialQuery, PaginatedQuery>( pager: Pager, - transform: @escaping ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> Model + transform: @escaping (PaginationOutput) throws -> Model ) { self.pager = pager pager.subscribe { [weak self] result in @@ -25,10 +25,10 @@ public class GraphQLQueryPager: Publisher { let returnValue: Output switch result { - case let .success((output, source)): + case let .success(output): do { - let transformedModels = try transform(output.previousPages, output.initialPage, output.nextPages) - returnValue = .success((transformedModels, source)) + let transformedModels = try transform(output) + returnValue = .success(transformedModels) } catch { returnValue = .failure(error) } @@ -50,27 +50,13 @@ public class GraphQLQueryPager: Publisher { } } - convenience init< - Pager: GraphQLQueryPagerCoordinator, - InitialQuery, - PaginatedQuery, - Element - >( - pager: Pager, - initialTransform: @escaping (InitialQuery.Data) throws -> Model, - pageTransform: @escaping (PaginatedQuery.Data) throws -> Model - ) where Model: RangeReplaceableCollection, Model.Element == Element { - 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 - } - ) - } - + /// Initialize an `GraphQLQueryPager` that outputs a `PaginationOutput`. + /// - Parameters: + /// - client: Apollo client type + /// - initialQuery: The query to call for the first page of pagination. May be a separate type of query than the pagination query. + /// - watcherDispatchQueue: The queue that the underlying `GraphQLQueryWatcher`s respond on. Defaults to `main`. + /// - extractPageInfo: A user-input closure that instructs the pager on how to extract `P`, a `PaginationInfo` type, from the `Data` of either the `InitialQuery` or `PaginatedQuery`. + /// - pageResolver: A user-input closure that instructs the pager on how to create a new `PaginatedQuery` given a `PaginationInfo` and a `PaginationDirection`. public convenience init< P: PaginationInfo, InitialQuery: GraphQLQuery, @@ -92,88 +78,34 @@ public class GraphQLQueryPager: Publisher { self.init(pager: pager) } - public convenience init< - P: PaginationInfo, - InitialQuery: GraphQLQuery, - PaginatedQuery: GraphQLQuery, - Element - >( - client: any ApolloClientProtocol, - initialQuery: InitialQuery, - watcherDispatchQueue: DispatchQueue = .main, - extractPageInfo: @escaping (PageExtractionData) -> P, - pageResolver: ((P, PaginationDirection) -> PaginatedQuery?)?, - initialTransform: @escaping (InitialQuery.Data) throws -> Model, - pageTransform: @escaping (PaginatedQuery.Data) throws -> Model - ) where Model: RangeReplaceableCollection, Model.Element == Element { - let pager = GraphQLQueryPagerCoordinator( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: { data in - switch data { - case .initial(let data, let output): - return extractPageInfo(.initial(data, convertOutput(result: output))) - case .paginated(let data, let output): - return extractPageInfo(.paginated(data, convertOutput(result: output))) - } - }, - pageResolver: pageResolver - ) - self.init( - pager: pager, - initialTransform: initialTransform, - pageTransform: pageTransform - ) - - func convertOutput(result: PaginationOutput?) -> Model? { - guard let result else { return nil } - - let transform: ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> Model = { 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 - } - return try? transform(result.previousPages, result.initialPage, result.nextPages) - } - } - + /// Initialize an `GraphQLQueryPager` that outputs a user-defined `Model`, the result of the `transform` argument. + /// - Parameters: + /// - client: Apollo client type + /// - initialQuery: The query to call for the first page of pagination. May be a separate type of query than the pagination query. + /// - watcherDispatchQueue: The queue that the underlying `GraphQLQueryWatcher`s respond on. Defaults to `main`. + /// - extractPageInfo: A user-input closure that instructs the pager on how to extract `P`, a `PaginationInfo` type, from the `Data` of either the `InitialQuery` or `PaginatedQuery`. + /// - pageResolver: A user-input closure that instructs the pager on how to create a new `PaginatedQuery` given a `PaginationInfo` and a `PaginationDirection`. + /// - transform: Transforms the `PaginationOutput` into a `Model` type. public convenience init< P: PaginationInfo, InitialQuery: GraphQLQuery, PaginatedQuery: GraphQLQuery >( client: any ApolloClientProtocol, - initialQuery: InitialQuery, watcherDispatchQueue: DispatchQueue = .main, - extractPageInfo: @escaping (PageExtractionData) -> P, + initialQuery: InitialQuery, + extractPageInfo: @escaping (PageExtractionData?>) -> P, pageResolver: ((P, PaginationDirection) -> PaginatedQuery?)?, - transform: @escaping ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> Model + transform: @escaping (PaginationOutput) throws -> Model ) { let pager = GraphQLQueryPagerCoordinator( client: client, initialQuery: initialQuery, watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: { data in - switch data { - case .initial(let data, let output): - return extractPageInfo(.initial(data, convertOutput(result: output))) - case .paginated(let data, let output): - return extractPageInfo(.paginated(data, convertOutput(result: output))) - } - }, + extractPageInfo: extractPageInfo, pageResolver: pageResolver ) - self.init( - pager: pager, - transform: transform - ) - - func convertOutput(result: PaginationOutput?) -> Model? { - guard let result else { return nil } - return try? transform(result.previousPages, result.initialPage, result.nextPages) - } + self.init(pager: pager, transform: transform) } deinit { @@ -182,6 +114,7 @@ public class GraphQLQueryPager: Publisher { /// 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. + @available(*, deprecated, message: "Will be removed in a future version of ApolloPagination. Use the `Combine` publishers instead. If you need to dispatch to the main thread, make sure to use a `.receive(on: RunLoop.main)` as part of your `Combine` operation.") public func subscribe(completion: @escaping @MainActor (Output) -> Void) { publisher.sink { result in Task { await completion(result) } @@ -214,7 +147,7 @@ public class GraphQLQueryPager: Publisher { pager.loadPrevious(cachePolicy: cachePolicy, callbackQueue: callbackQueue, completion: completion) } - /// Loads all pages. + /// Loads all pages. Does not output a value until all pages have loaded. /// - 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`. @@ -258,25 +191,7 @@ public class GraphQLQueryPager: Publisher { public func receive( subscriber: S - ) where S: Subscriber, Never == S.Failure, Result<(Model, UpdateSource), any Error> == S.Input { + ) where S: Subscriber, Never == S.Failure, Result == S.Input { publisher.subscribe(subscriber) } } - -extension GraphQLQueryPager: Equatable where Model: Equatable { - public static func == (lhs: GraphQLQueryPager, rhs: GraphQLQueryPager) -> Bool { - let left = lhs._subject.value - let right = rhs._subject.value - - switch (left, right) { - case (.success((let leftValue, let leftSource)), .success((let rightValue, let rightSource))): - return leftValue == rightValue && leftSource == rightSource - case (.failure(let leftError), .failure(let rightError)): - return leftError.localizedDescription == rightError.localizedDescription - case (.none, .none): - return true - default: - return false - } - } -} diff --git a/Sources/ApolloPagination/GraphQLQueryPagerCoordinator.swift b/Sources/ApolloPagination/GraphQLQueryPagerCoordinator.swift index 900a5912d..8f9d081d7 100644 --- a/Sources/ApolloPagination/GraphQLQueryPagerCoordinator.swift +++ b/Sources/ApolloPagination/GraphQLQueryPagerCoordinator.swift @@ -42,7 +42,7 @@ class GraphQLQueryPagerCoordinator, UpdateSource), any Error>, Never> { + var publisher: AnyPublisher, any Error>, Never> { get async { await pager.$currentValue.compactMap { $0 }.eraseToAnyPublisher() } } @@ -87,7 +87,7 @@ class GraphQLQueryPagerCoordinator, UpdateSource), any Error>) -> Void) { + func subscribe(onUpdate: @escaping (Result, any Error>) -> Void) { Task { [weak self] in guard let self else { return } let subscription = await self.pager.subscribe(onUpdate: onUpdate) @@ -167,7 +167,7 @@ class GraphQLQueryPagerCoordinator Void)? = nil ) { - execute(callbackQueue: callbackQueue, completion: { _ in completion?() }) { [weak self] in + execute(callbackQueue: callbackQueue) { _ in completion?() } operation: { [weak self] in guard let self else { return } for completion in await self.completionManager.completions { completion.execute(error: PaginationError.cancellation) @@ -184,7 +184,7 @@ class GraphQLQueryPagerCoordinator Void)? = nil ) { - execute(callbackQueue: callbackQueue, completion: { _ in completion?() }) { [weak self] in + execute(callbackQueue: callbackQueue) { _ in completion?() } operation: { [weak self] in await self?.pager.fetch() } } diff --git a/Sources/ApolloPagination/GraphQLQueryPagerOutput.swift b/Sources/ApolloPagination/GraphQLQueryPagerOutput.swift index e1eaa3dac..dc70cf2d0 100644 --- a/Sources/ApolloPagination/GraphQLQueryPagerOutput.swift +++ b/Sources/ApolloPagination/GraphQLQueryPagerOutput.swift @@ -1,3 +1,4 @@ +import Apollo import ApolloAPI import Foundation @@ -5,21 +6,77 @@ import Foundation public struct PaginationOutput: Hashable { /// An array of previous pages, in pagination order /// Earlier pages come first in the array. - public let previousPages: [PaginatedQuery.Data] + public let previousPages: [GraphQLResult] /// The initial page that we fetched. - public let initialPage: InitialQuery.Data + public let initialPage: GraphQLResult? /// An array of pages after the initial page. - public let nextPages: [PaginatedQuery.Data] + public let nextPages: [GraphQLResult] + + public let lastUpdatedPage: QueryWrapper public init( - previousPages: [PaginatedQuery.Data], - initialPage: InitialQuery.Data, - nextPages: [PaginatedQuery.Data] + previousPages: [GraphQLResult], + initialPage: GraphQLResult?, + nextPages: [GraphQLResult], + lastUpdatedPage: QueryWrapper ) { self.previousPages = previousPages self.initialPage = initialPage self.nextPages = nextPages + self.lastUpdatedPage = lastUpdatedPage + } + + public var allErrors: [GraphQLError] { + (previousPages.compactMap(\.errors) + [initialPage?.errors].compactMap { $0 } + nextPages.compactMap(\.errors)).flatMap { $0 } + } +} + +extension PaginationOutput { + public enum QueryWrapper: Hashable { + case initial(GraphQLResult) + case paginated(GraphQLResult) + } +} + +extension PaginationOutput.QueryWrapper { + public var errors: [GraphQLError]? { + switch self { + case .initial(let result): + result.errors + case .paginated(let result): + result.errors + } + } + + public var source: UpdateSource { + switch self { + case .initial(let result): + result.updateSource + case .paginated(let result): + result.updateSource + } + } +} + +extension PaginationOutput.QueryWrapper where InitialQuery == PaginatedQuery { + public var data: InitialQuery.Data? { + switch self { + case .initial(let result): + result.data + case .paginated(let result): + result.data + } + } +} + +extension PaginationOutput where InitialQuery == PaginatedQuery { + public var allData: [InitialQuery.Data] { + previousPages.compactMap(\.data) + [initialPage?.data].compactMap { $0 } + nextPages.compactMap(\.data) + } + + public var allPages: [GraphQLResult] { + previousPages + [initialPage].compactMap { $0 } + nextPages } } diff --git a/Sources/ApolloPagination/UpdateSource.swift b/Sources/ApolloPagination/UpdateSource.swift index ba947caf1..8acecbfea 100644 --- a/Sources/ApolloPagination/UpdateSource.swift +++ b/Sources/ApolloPagination/UpdateSource.swift @@ -1,3 +1,3 @@ public enum UpdateSource: Hashable { - case fetch, cache + case server, cache }