From 3af5cdf543e86012ce439d61d6d1dbbb880abfa0 Mon Sep 17 00:00:00 2001 From: Hesham Salman Date: Wed, 24 Jan 2024 15:14:06 -0500 Subject: [PATCH] [Apollo Pagination] Improve ReversePagination support, implement `loadAll` support, Bidirectional Pagination (#115) Co-authored-by: Jerrad Thramer <887225+JThramer@users.noreply.github.com> Co-authored-by: Anthony Miller --- .../MockGraphQLServer.swift | 50 +- .../MockNetworkTransport.swift | 13 +- .../AnyAsyncGraphQLQueryPagerTests.swift | 152 ++++++ .../AnyGraphQLQueryPagerTests.swift | 197 ++++++-- .../AsyncGraphQLQueryPagerTests.swift | 223 +++++++++ .../BidirectionalPaginationTests.swift | 310 ++++++++++++ .../ConcurrencyTest.swift | 140 +++--- .../ForwardPaginationTests.swift | 254 +++++++--- .../FriendsQuery+TestHelpers.swift | 238 ++++++++- .../GraphQLQueryPagerTests.swift | 149 ++++++ Tests/ApolloPaginationTests/Mocks.swift | 121 +++++ .../PaginationError+Test.swift | 19 + .../ReversePaginationTests.swift | 140 ++++++ .../SubscribeTests.swift | 25 +- .../Target+ApolloPaginationTests.swift | 2 - .../AnyAsyncGraphQLQueryPager.swift | 154 ++++++ .../ApolloPagination/AnyGraphQLPager.swift | 138 +++--- .../AsyncGraphQLQueryPager.swift | 453 ++++++++++++++++++ .../BidirectionalPagination.swift | 24 + .../CursorBasedPagination.swift | 2 + .../ForwardPagination.swift | 19 +- .../ReversePagination.swift | 19 +- .../GraphQLQueryPager+Convenience.swift | 283 +++++++---- .../ApolloPagination/GraphQLQueryPager.swift | 418 ++++++---------- .../GraphQLQueryPagerOutput.swift | 30 ++ .../OffsetPagination.swift | 7 +- .../ApolloPagination/PageExtractionData.swift | 7 + .../PaginationDirection.swift | 7 + .../ApolloPagination/PaginationError.swift | 4 + .../ApolloPagination/PaginationInfo.swift | 3 +- 30 files changed, 2931 insertions(+), 670 deletions(-) create mode 100644 Tests/ApolloPaginationTests/AnyAsyncGraphQLQueryPagerTests.swift create mode 100644 Tests/ApolloPaginationTests/AsyncGraphQLQueryPagerTests.swift create mode 100644 Tests/ApolloPaginationTests/BidirectionalPaginationTests.swift create mode 100644 Tests/ApolloPaginationTests/GraphQLQueryPagerTests.swift create mode 100644 Tests/ApolloPaginationTests/PaginationError+Test.swift create mode 100644 Tests/ApolloPaginationTests/ReversePaginationTests.swift create mode 100644 apollo-ios-pagination/Sources/ApolloPagination/AnyAsyncGraphQLQueryPager.swift create mode 100644 apollo-ios-pagination/Sources/ApolloPagination/AsyncGraphQLQueryPager.swift create mode 100644 apollo-ios-pagination/Sources/ApolloPagination/CursorBasedPagination/BidirectionalPagination.swift create mode 100644 apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPagerOutput.swift create mode 100644 apollo-ios-pagination/Sources/ApolloPagination/PageExtractionData.swift create mode 100644 apollo-ios-pagination/Sources/ApolloPagination/PaginationDirection.swift diff --git a/Tests/ApolloInternalTestHelpers/MockGraphQLServer.swift b/Tests/ApolloInternalTestHelpers/MockGraphQLServer.swift index bbfcd6c95..29500913e 100644 --- a/Tests/ApolloInternalTestHelpers/MockGraphQLServer.swift +++ b/Tests/ApolloInternalTestHelpers/MockGraphQLServer.swift @@ -32,7 +32,7 @@ import XCTest public class MockGraphQLServer { enum ServerError: Error, CustomStringConvertible { case unexpectedRequest(String) - + public var description: String { switch self { case .unexpectedRequest(let requestDescription): @@ -40,14 +40,15 @@ public class MockGraphQLServer { } } } - + + public var customDelay: DispatchTimeInterval? public typealias RequestHandler = (HTTPRequest) -> JSONObject - + private class RequestExpectation: XCTestExpectation { let file: StaticString let line: UInt let handler: RequestHandler - + init(description: String, file: StaticString = #filePath, line: UInt = #line, handler: @escaping RequestHandler) { self.file = file self.line = line @@ -56,43 +57,64 @@ public class MockGraphQLServer { super.init(description: description) } } - + private let queue = DispatchQueue(label: "com.apollographql.MockGraphQLServer") public init() { } - + // Since RequestExpectation is generic over a specific GraphQLOperation, we can't store these in the dictionary // directly. Moreover, there is no way to specify the type relationship that holds between the key and value. // To work around this, we store values as Any and use a generic subscript as a type-safe way to access them. private var requestExpectations: [AnyHashable: Any] = [:] - + private subscript(_ operationType: Operation.Type) -> RequestExpectation? { get { requestExpectations[ObjectIdentifier(operationType)] as! RequestExpectation? } - + set { requestExpectations[ObjectIdentifier(operationType)] = newValue } } - + + private subscript(_ operationType: Operation) -> RequestExpectation? { + get { + requestExpectations[operationType] as! RequestExpectation? + } + + set { + requestExpectations[operationType] = newValue + } + } + public func expect(_ operationType: Operation.Type, file: StaticString = #filePath, line: UInt = #line, requestHandler: @escaping (HTTPRequest) -> JSONObject) -> XCTestExpectation { return queue.sync { let expectation = RequestExpectation(description: "Served request for \(String(describing: operationType))", file: file, line: line, handler: requestHandler) expectation.assertForOverFulfill = true - + self[operationType] = expectation - + return expectation } } - + + public func expect(_ operation: Operation, file: StaticString = #filePath, line: UInt = #line, requestHandler: @escaping (HTTPRequest) -> JSONObject) -> XCTestExpectation { + return queue.sync { + let expectation = RequestExpectation(description: "Served request for \(String(describing: operation.self))", file: file, line: line, handler: requestHandler) + expectation.assertForOverFulfill = true + + self[operation] = expectation + + return expectation + } + } + func serve(request: HTTPRequest, completionHandler: @escaping (Result) -> Void) where Operation: GraphQLOperation { let operationType = type(of: request.operation) - if let expectation = self[operationType] { + if let expectation = self[request.operation] ?? self[operationType] { // Dispatch after a small random delay to spread out concurrent requests and simulate somewhat real-world conditions. - queue.asyncAfter(deadline: .now() + .milliseconds(Int.random(in: 10...50))) { + queue.asyncAfter(deadline: .now() + (customDelay ?? .milliseconds(Int.random(in: 10...50)))) { completionHandler(.success(expectation.handler(request))) expectation.fulfill() } diff --git a/Tests/ApolloInternalTestHelpers/MockNetworkTransport.swift b/Tests/ApolloInternalTestHelpers/MockNetworkTransport.swift index 45bd8bfdc..59390e32b 100644 --- a/Tests/ApolloInternalTestHelpers/MockNetworkTransport.swift +++ b/Tests/ApolloInternalTestHelpers/MockNetworkTransport.swift @@ -13,12 +13,12 @@ public final class MockNetworkTransport: RequestChainNetworkTransport { endpointURL: TestURL.mockServer.url) self.clientName = clientName self.clientVersion = clientVersion - } - + } + struct TestInterceptorProvider: InterceptorProvider { let store: ApolloStore let server: MockGraphQLServer - + func interceptors( for operation: Operation ) -> [any ApolloInterceptor] where Operation: GraphQLOperation { @@ -45,18 +45,18 @@ private class MockGraphQLServerInterceptor: ApolloInterceptor { let server: MockGraphQLServer public var id: String = UUID().uuidString - + init(server: MockGraphQLServer) { self.server = server } - + public func interceptAsync(chain: RequestChain, request: HTTPRequest, response: HTTPResponse?, completion: @escaping (Result, Error>) -> Void) where Operation: GraphQLOperation { server.serve(request: request) { result in let httpResponse = HTTPURLResponse(url: TestURL.mockServer.url, statusCode: 200, httpVersion: nil, headerFields: nil)! - + switch result { case .failure(let error): chain.handleErrorAsync(error, @@ -68,6 +68,7 @@ private class MockGraphQLServerInterceptor: ApolloInterceptor { let response = HTTPResponse(response: httpResponse, rawData: data, parsedResponse: nil) + guard !chain.isCancelled else { return } chain.proceedAsync(request: request, response: response, interceptor: self, diff --git a/Tests/ApolloPaginationTests/AnyAsyncGraphQLQueryPagerTests.swift b/Tests/ApolloPaginationTests/AnyAsyncGraphQLQueryPagerTests.swift new file mode 100644 index 000000000..0ecd85c62 --- /dev/null +++ b/Tests/ApolloPaginationTests/AnyAsyncGraphQLQueryPagerTests.swift @@ -0,0 +1,152 @@ +import Apollo +import ApolloAPI +import ApolloInternalTestHelpers +import XCTest + +@testable import ApolloPagination + +final class AnyAsyncGraphQLQueryPagerTests: XCTestCase { + private typealias Query = MockQuery + + private var store: ApolloStore! + private var server: MockGraphQLServer! + private var networkTransport: MockNetworkTransport! + private var client: ApolloClient! + + override func setUp() { + super.setUp() + store = ApolloStore(cache: InMemoryNormalizedCache()) + server = MockGraphQLServer() + networkTransport = MockNetworkTransport(server: server, store: store) + client = ApolloClient(networkTransport: networkTransport, store: store) + } + + func test_concatenatesPages_matchingInitialAndPaginated() async throws { + struct ViewModel { + let name: String + } + + let anyPager = await createPager().eraseToAnyPager { data in + data.hero.friendsConnection.friends.map { + ViewModel(name: $0.name) + } + } + + let fetchExpectation = expectation(description: "Initial Fetch") + fetchExpectation.assertForOverFulfill = false + let subscriptionExpectation = expectation(description: "Subscription") + subscriptionExpectation.expectedFulfillmentCount = 2 + var expectedViewModels: [ViewModel]? + anyPager.subscribe { (result: Result<([ViewModel], UpdateSource), Error>) in + switch result { + case .success((let viewModels, _)): + expectedViewModels = viewModels + fetchExpectation.fulfill() + subscriptionExpectation.fulfill() + default: + XCTFail("Failed to get view models from pager.") + } + } + + await fetchFirstPage(pager: anyPager) + await fulfillment(of: [fetchExpectation], timeout: 1) + try await fetchSecondPage(pager: anyPager) + + await fulfillment(of: [subscriptionExpectation], timeout: 1.0) + let results = try XCTUnwrap(expectedViewModels) + XCTAssertEqual(results.count, 3) + XCTAssertEqual(results.map(\.name), ["Luke Skywalker", "Han Solo", "Leia Organa"]) + } + + func test_passesBackSeparateData() async throws { + let anyPager = await createPager().eraseToAnyPager { _, initial, next in + if let latestPage = next.last { + return latestPage.hero.friendsConnection.friends.last?.name + } + return initial.hero.friendsConnection.friends.last?.name + } + + let initialExpectation = expectation(description: "Initial") + let secondExpectation = expectation(description: "Second") + var expectedViewModel: String? + anyPager.subscribe { (result: Result<(String?, UpdateSource), Error>) in + switch result { + case .success((let viewModel, _)): + let oldValue = expectedViewModel + expectedViewModel = viewModel + if oldValue == nil { + initialExpectation.fulfill() + } else { + secondExpectation.fulfill() + } + default: + XCTFail("Failed to get view models from pager.") + } + } + + await fetchFirstPage(pager: anyPager) + await fulfillment(of: [initialExpectation], timeout: 1.0) + XCTAssertEqual(expectedViewModel, "Han Solo") + + try await fetchSecondPage(pager: anyPager) + await fulfillment(of: [secondExpectation], timeout: 1.0) + XCTAssertEqual(expectedViewModel, "Leia Organa") + } + + func test_loadAll() async throws { + let pager = createPager() + + let firstPageExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) + let lastPageExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) + let loadAllExpectation = expectation(description: "Load all pages") + let subscriber = await pager.subscribe { _ in + loadAllExpectation.fulfill() + } + try await pager.loadAll() + await fulfillment(of: [firstPageExpectation, lastPageExpectation, loadAllExpectation], timeout: 5) + subscriber.cancel() + } + + // MARK: - Test helpers + + private func createPager() -> AsyncGraphQLQueryPager { + let initialQuery = Query() + initialQuery.__variables = ["id": "2001", "first": 2, "after": GraphQLNullable.null] + return AsyncGraphQLQueryPager( + client: client, + initialQuery: initialQuery, + watcherDispatchQueue: .main, + extractPageInfo: { data in + switch data { + case .initial(let data), .paginated(let data): + return CursorBasedPagination.Forward( + hasNext: data.hero.friendsConnection.pageInfo.hasNextPage, + endCursor: data.hero.friendsConnection.pageInfo.endCursor + ) + } + }, + pageResolver: { pageInfo, direction in + guard direction == .next else { return nil } + let nextQuery = Query() + nextQuery.__variables = [ + "id": "2001", + "first": 2, + "after": pageInfo.endCursor, + ] + return nextQuery + } + ) + } + + private func fetchFirstPage(pager: AnyAsyncGraphQLQueryPager) async { + let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) + await pager.fetch() + await fulfillment(of: [serverExpectation], timeout: 1.0) + } + + private func fetchSecondPage(pager: AnyAsyncGraphQLQueryPager) async throws { + let serverExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) + try await pager.loadNext() + await fulfillment(of: [serverExpectation], timeout: 1.0) + } +} diff --git a/Tests/ApolloPaginationTests/AnyGraphQLQueryPagerTests.swift b/Tests/ApolloPaginationTests/AnyGraphQLQueryPagerTests.swift index 6eb10c42d..5c68c1cb6 100644 --- a/Tests/ApolloPaginationTests/AnyGraphQLQueryPagerTests.swift +++ b/Tests/ApolloPaginationTests/AnyGraphQLQueryPagerTests.swift @@ -7,6 +7,7 @@ import XCTest final class AnyGraphQLQueryPagerTests: XCTestCase { private typealias Query = MockQuery + private typealias ReverseQuery = MockQuery private var store: ApolloStore! private var server: MockGraphQLServer! @@ -21,6 +22,102 @@ final class AnyGraphQLQueryPagerTests: XCTestCase { client = ApolloClient(networkTransport: networkTransport, store: store) } + // MARK: - Test helpers + + private func createPager() -> GraphQLQueryPager { + let initialQuery = Query() + initialQuery.__variables = ["id": "2001", "first": 2, "after": GraphQLNullable.null] + return GraphQLQueryPager( + client: client, + initialQuery: initialQuery, + watcherDispatchQueue: .main, + extractPageInfo: { data in + switch data { + case .initial(let data), .paginated(let data): + return CursorBasedPagination.Forward( + hasNext: data.hero.friendsConnection.pageInfo.hasNextPage, + endCursor: data.hero.friendsConnection.pageInfo.endCursor + ) + } + }, + pageResolver: { pageInfo, direction in + guard direction == .next else { return nil } + let nextQuery = Query() + nextQuery.__variables = [ + "id": "2001", + "first": 2, + "after": pageInfo.endCursor, + ] + return nextQuery + } + ) + } + + private func createReversePager() -> GraphQLQueryPager { + let initialQuery = ReverseQuery() + initialQuery.__variables = ["id": "2001", "first": 2, "before": "Y3Vyc29yMw=="] + return GraphQLQueryPager( + client: client, + initialQuery: initialQuery, + watcherDispatchQueue: .main, + extractPageInfo: { data in + switch data { + case .initial(let data), .paginated(let data): + return CursorBasedPagination.Reverse( + hasPrevious: data.hero.friendsConnection.pageInfo.hasPreviousPage, + startCursor: data.hero.friendsConnection.pageInfo.startCursor + ) + } + }, + pageResolver: { pageInfo, direction in + guard direction == .previous else { return nil } + let nextQuery = ReverseQuery() + nextQuery.__variables = [ + "id": "2001", + "first": 2, + "before": pageInfo.startCursor, + ] + return nextQuery + } + ) + + } + + // This is due to a timing issue in unit tests only wherein we deinit immediately after waiting for expectations + private func ignoringCancellations(error: Error?) { + if PaginationError.isCancellation(error: error as? PaginationError) { + return + } else { + XCTAssertNil(error) + } + } + + private func fetchFirstPage(pager: AnyGraphQLQueryPager) { + let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) + pager.fetch() + wait(for: [serverExpectation], timeout: 1.0) + } + + private func fetchSecondPage(pager: AnyGraphQLQueryPager) throws { + let serverExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) + pager.loadNext(completion: ignoringCancellations(error:)) + wait(for: [serverExpectation], timeout: 1.0) + } + + private func reverseFetchLastPage(pager: AnyGraphQLQueryPager) { + let serverExpectation = Mocks.Hero.ReverseFriendsQuery.expectationForLastItem(server: server) + pager.fetch() + wait(for: [serverExpectation], timeout: 1.0) + } + + private func reverseFetchPreviousPage(pager: AnyGraphQLQueryPager) throws { + let serverExpectation = Mocks.Hero.ReverseFriendsQuery.expectationForPreviousItem(server: server) + pager.loadPrevious(completion: ignoringCancellations(error:)) + wait(for: [serverExpectation], timeout: 1.0) + } + + // MARK: - Tests + func test_concatenatesPages_matchingInitialAndPaginated() throws { struct ViewModel { let name: String @@ -32,6 +129,8 @@ final class AnyGraphQLQueryPagerTests: XCTestCase { } } + let fetchExpectation = expectation(description: "Initial Fetch") + fetchExpectation.assertForOverFulfill = false let subscriptionExpectation = expectation(description: "Subscription") subscriptionExpectation.expectedFulfillmentCount = 2 var expectedViewModels: [ViewModel]? @@ -39,6 +138,7 @@ final class AnyGraphQLQueryPagerTests: XCTestCase { switch result { case .success((let viewModels, _)): expectedViewModels = viewModels + fetchExpectation.fulfill() subscriptionExpectation.fulfill() default: XCTFail("Failed to get view models from pager.") @@ -46,6 +146,7 @@ final class AnyGraphQLQueryPagerTests: XCTestCase { } fetchFirstPage(pager: anyPager) + wait(for: [fetchExpectation], timeout: 1) try fetchSecondPage(pager: anyPager) wait(for: [subscriptionExpectation], timeout: 1.0) @@ -55,7 +156,7 @@ final class AnyGraphQLQueryPagerTests: XCTestCase { } func test_passesBackSeparateData() throws { - let anyPager = createPager().eraseToAnyPager { initial, next in + let anyPager = createPager().eraseToAnyPager { _, initial, next in if let latestPage = next.last { return latestPage.hero.friendsConnection.friends.last?.name } @@ -83,50 +184,76 @@ final class AnyGraphQLQueryPagerTests: XCTestCase { fetchFirstPage(pager: anyPager) wait(for: [initialExpectation], timeout: 1.0) XCTAssertEqual(expectedViewModel, "Han Solo") + XCTAssertTrue(anyPager.canLoadNext) + XCTAssertFalse(anyPager.canLoadPrevious) try fetchSecondPage(pager: anyPager) wait(for: [secondExpectation], timeout: 1.0) XCTAssertEqual(expectedViewModel, "Leia Organa") + XCTAssertFalse(anyPager.canLoadNext) + XCTAssertFalse(anyPager.canLoadPrevious) } - // MARK: - Test helpers - - private func createPager() -> GraphQLQueryPager { - let initialQuery = Query() - initialQuery.__variables = ["id": "2001", "first": 2, "after": GraphQLNullable.null] - return GraphQLQueryPager( - client: client, - initialQuery: initialQuery, - extractPageInfo: { data in - switch data { - case .initial(let data), .paginated(let data): - return CursorBasedPagination.ForwardPagination( - hasNext: data.hero.friendsConnection.pageInfo.hasNextPage, - endCursor: data.hero.friendsConnection.pageInfo.endCursor - ) - } - }, - nextPageResolver: { pageInfo in - let nextQuery = Query() - nextQuery.__variables = [ - "id": "2001", - "first": 2, - "after": pageInfo.endCursor, - ] - return nextQuery + @available(iOS 16.0, macOS 13.0, *) + func test_pager_cancellation_calls_callback() async throws { + server.customDelay = .milliseconds(1) + let pager = createPager().eraseToAnyPager { _, initial, next in + if let latestPage = next.last { + return latestPage.hero.friendsConnection.friends.last?.name } - ) - } - - private func fetchFirstPage(pager: AnyGraphQLQueryPager) { + return initial.hero.friendsConnection.friends.last?.name + } let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) + pager.fetch() - wait(for: [serverExpectation], timeout: 1.0) + await fulfillment(of: [serverExpectation], timeout: 1) + server.customDelay = .milliseconds(200) + let secondPageExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) + let callbackExpectation = expectation(description: "Callback") + pager.loadNext(completion: { _ in + callbackExpectation.fulfill() + }) + try await Task.sleep(for: .milliseconds(50)) + pager.cancel() + await fulfillment(of: [callbackExpectation, secondPageExpectation], timeout: 1) } - private func fetchSecondPage(pager: AnyGraphQLQueryPager) throws { - let serverExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) - try pager.loadMore() - wait(for: [serverExpectation], timeout: 1.0) + func test_reversePager_loadPrevious() throws { + let anyPager = createReversePager().eraseToAnyPager { previous, initial, _ in + if let latestPage = previous.last { + return latestPage.hero.friendsConnection.friends.first?.name + } + return initial.hero.friendsConnection.friends.first?.name + } + + let initialExpectation = expectation(description: "Initial") + let secondExpectation = expectation(description: "Second") + var expectedViewModel: String? + anyPager.subscribe { (result: Result<(String?, UpdateSource), Error>) in + switch result { + case .success((let viewModel, _)): + let oldValue = expectedViewModel + expectedViewModel = viewModel + if oldValue == nil { + initialExpectation.fulfill() + } else { + secondExpectation.fulfill() + } + default: + XCTFail("Failed to get view models from pager.") + } + } + + reverseFetchLastPage(pager: anyPager) + wait(for: [initialExpectation], timeout: 1.0) + XCTAssertEqual(expectedViewModel, "Han Solo") + XCTAssertFalse(anyPager.canLoadNext) + XCTAssertTrue(anyPager.canLoadPrevious) + + try reverseFetchPreviousPage(pager: anyPager) + wait(for: [secondExpectation], timeout: 1.0) + XCTAssertEqual(expectedViewModel, "Luke Skywalker") + XCTAssertFalse(anyPager.canLoadNext) + XCTAssertFalse(anyPager.canLoadPrevious) } } diff --git a/Tests/ApolloPaginationTests/AsyncGraphQLQueryPagerTests.swift b/Tests/ApolloPaginationTests/AsyncGraphQLQueryPagerTests.swift new file mode 100644 index 000000000..154ae9d8b --- /dev/null +++ b/Tests/ApolloPaginationTests/AsyncGraphQLQueryPagerTests.swift @@ -0,0 +1,223 @@ +import Apollo +import ApolloAPI +import ApolloInternalTestHelpers +import Combine +import XCTest + +@testable import ApolloPagination + +final class AsyncGraphQLQueryPagerTests: XCTestCase, CacheDependentTesting { + private typealias ReverseQuery = MockQuery + private typealias ForwardQuery = MockQuery + + var cacheType: TestCacheProvider.Type { + InMemoryTestCacheProvider.self + } + + var cache: NormalizedCache! + var server: MockGraphQLServer! + var client: ApolloClient! + var cancellables: [AnyCancellable] = [] + + override func setUpWithError() throws { + try super.setUpWithError() + + cache = try makeNormalizedCache() + let store = ApolloStore(cache: cache) + + server = MockGraphQLServer() + let networkTransport = MockNetworkTransport(server: server, store: store) + + client = ApolloClient(networkTransport: networkTransport, store: store) + MockSchemaMetadata.stub_cacheKeyInfoForType_Object = IDCacheKeyProvider.resolver + } + + override func tearDownWithError() throws { + cache = nil + server = nil + client = nil + cancellables.forEach { $0.cancel() } + cancellables = [] + + try super.tearDownWithError() + } + + func test_canLoadMore() async throws { + let pager = createForwardPager() + + let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) + + await pager.fetch() + await fulfillment(of: [serverExpectation]) + + var canLoadMore = await pager.canLoadNext + XCTAssertTrue(canLoadMore) + + let secondPageExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) + let secondPageFetch = expectation(description: "Second Page") + secondPageFetch.expectedFulfillmentCount = 2 + let subscription = await pager.subscribe(onUpdate: { _ in + secondPageFetch.fulfill() + }) + try await pager.loadNext() + await fulfillment(of: [secondPageExpectation, secondPageFetch]) + subscription.cancel() + canLoadMore = await pager.canLoadNext + XCTAssertFalse(canLoadMore) + } + + func test_canLoadPrevious() async throws { + let pager = createReversePager() + + let serverExpectation = Mocks.Hero.ReverseFriendsQuery.expectationForLastItem(server: server) + + await pager.fetch() + await fulfillment(of: [serverExpectation]) + + var canLoadMore = await pager.canLoadPrevious + XCTAssertTrue(canLoadMore) + + let secondPageExpectation = Mocks.Hero.ReverseFriendsQuery.expectationForPreviousItem(server: server) + let secondPageFetch = expectation(description: "Second Page") + secondPageFetch.expectedFulfillmentCount = 2 + let subscription = await pager.subscribe(onUpdate: { _ in + secondPageFetch.fulfill() + }) + try await pager.loadPrevious() + await fulfillment(of: [secondPageExpectation, secondPageFetch]) + subscription.cancel() + canLoadMore = await pager.canLoadPrevious + XCTAssertFalse(canLoadMore) + } + + @available(iOS 16.0, macOS 13.0, *) + func test_actor_canCancelMidflight() async throws { + server.customDelay = .milliseconds(150) + let pager = createForwardPager() + let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) + + await pager.subscribe(onUpdate: { _ in + XCTFail("We should never get results back") + }).store(in: &cancellables) + + Task { + try await pager.loadAll() + } + + Task { + try? await Task.sleep(for: .milliseconds(10)) + await pager.cancel() + } + + await fulfillment(of: [serverExpectation], timeout: 1.0) + } + + @available(iOS 16.0, macOS 13.0, *) + func test_actor_cancellation_loadingState() async throws { + server.customDelay = .milliseconds(150) + let pager = createForwardPager() + let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) + + await pager.subscribe(onUpdate: { _ in + XCTFail("We should never get results back") + }).store(in: &cancellables) + + Task { + try await pager.loadAll() + } + + Task { + try? await Task.sleep(for: .milliseconds(10)) + await pager.cancel() + } + + await fulfillment(of: [serverExpectation], timeout: 1.0) + async let isLoadingAll = pager.isLoadingAll + async let isFetching = pager.isFetching + let loadingStates = await [isFetching, isLoadingAll] + loadingStates.forEach { XCTAssertFalse($0) } + } + + @available(iOS 16.0, macOS 13.0, *) + func test_actor_cancellationState_midflight() async throws { + server.customDelay = .milliseconds(1) + let pager = createForwardPager() + let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) + + await pager.fetch() + await fulfillment(of: [serverExpectation], timeout: 1.0) + + server.customDelay = .seconds(3) + Task { + try? await pager.loadNext() + } + let cancellationExpectation = expectation(description: "finished cancellation") + Task { + try? await Task.sleep(for: .milliseconds(50)) + await pager.cancel() + cancellationExpectation.fulfill() + } + + await fulfillment(of: [cancellationExpectation]) + let isFetching = await pager.isFetching + XCTAssertFalse(isFetching) + } + + private func createReversePager() -> AsyncGraphQLQueryPager { + let initialQuery = ReverseQuery() + initialQuery.__variables = ["id": "2001", "first": 2, "before": "Y3Vyc29yMw=="] + return AsyncGraphQLQueryPager( + client: client, + initialQuery: initialQuery, + watcherDispatchQueue: .main, + extractPageInfo: { data in + switch data { + case .initial(let data), .paginated(let data): + return CursorBasedPagination.Reverse( + hasPrevious: data.hero.friendsConnection.pageInfo.hasPreviousPage, + startCursor: data.hero.friendsConnection.pageInfo.startCursor + ) + } + }, + pageResolver: { pageInfo, direction in + guard direction == .previous else { return nil } + let nextQuery = ReverseQuery() + nextQuery.__variables = [ + "id": "2001", + "first": 2, + "before": pageInfo.startCursor, + ] + return nextQuery + } + ) + } + + private func createForwardPager() -> AsyncGraphQLQueryPager { + let initialQuery = ForwardQuery() + initialQuery.__variables = ["id": "2001", "first": 2, "after": GraphQLNullable.null] + return AsyncGraphQLQueryPager( + client: client, + initialQuery: initialQuery, + watcherDispatchQueue: .main, + extractPageInfo: { data in + switch data { + case .initial(let data), .paginated(let data): + return CursorBasedPagination.Forward( + hasNext: data.hero.friendsConnection.pageInfo.hasNextPage, + endCursor: data.hero.friendsConnection.pageInfo.endCursor + ) + } + }, + pageResolver: { pageInfo, direction in + guard direction == .next else { return nil } + let nextQuery = ForwardQuery() + nextQuery.__variables = [ + "id": "2001", + "first": 2, + "after": pageInfo.endCursor, + ] + return nextQuery + } + ) + } +} diff --git a/Tests/ApolloPaginationTests/BidirectionalPaginationTests.swift b/Tests/ApolloPaginationTests/BidirectionalPaginationTests.swift new file mode 100644 index 000000000..0d5a8828e --- /dev/null +++ b/Tests/ApolloPaginationTests/BidirectionalPaginationTests.swift @@ -0,0 +1,310 @@ +import Apollo +import ApolloAPI +import ApolloInternalTestHelpers +import Combine +import XCTest + +@testable import ApolloPagination + +final class BidirectionalPaginationTests: XCTestCase, CacheDependentTesting { + + private typealias Query = MockQuery + + var cacheType: TestCacheProvider.Type { + InMemoryTestCacheProvider.self + } + + var cache: NormalizedCache! + var server: MockGraphQLServer! + var client: ApolloClient! + var cancellables: [AnyCancellable] = [] + + override func setUpWithError() throws { + try super.setUpWithError() + + cache = try makeNormalizedCache() + let store = ApolloStore(cache: cache) + + server = MockGraphQLServer() + let networkTransport = MockNetworkTransport(server: server, store: store) + + client = ApolloClient(networkTransport: networkTransport, store: store) + MockSchemaMetadata.stub_cacheKeyInfoForType_Object = IDCacheKeyProvider.resolver + } + + override func tearDownWithError() throws { + cache = nil + server = nil + client = nil + cancellables.removeAll() + + try super.tearDownWithError() + } + + // MARK: - Test Helpers + + private func createPager() -> AsyncGraphQLQueryPager { + let initialQuery = Query() + initialQuery.__variables = ["id": "2001", "first": 1, "after": "Y3Vyc29yMw==", "before": GraphQLNullable.null] + return AsyncGraphQLQueryPager( + client: client, + initialQuery: initialQuery, + watcherDispatchQueue: .main, + extractPageInfo: { data in + switch data { + case .initial(let data), .paginated(let data): + return CursorBasedPagination.Bidirectional( + hasNext: data.hero.friendsConnection.pageInfo.hasNextPage, + endCursor: data.hero.friendsConnection.pageInfo.endCursor, + hasPrevious: data.hero.friendsConnection.pageInfo.hasPreviousPage, + startCursor: data.hero.friendsConnection.pageInfo.startCursor + ) + } + }, + pageResolver: { pageInfo, direction in + switch direction { + case .next: + let nextQuery = Query() + nextQuery.__variables = [ + "id": "2001", + "first": 1, + "after": pageInfo.endCursor, + "before": GraphQLNullable.null, + ] + return nextQuery + case .previous: + let previousQuery = Query() + previousQuery.__variables = [ + "id": "2001", + "first": 1, + "before": pageInfo.startCursor, + "after": GraphQLNullable.null, + ] + return previousQuery + } + } + ) + } + + // MARK: - AsyncGraphQLQueryPager tests + + func test_fetchMultiplePages_async() async throws { + let pager = createPager() + let serverExpectation = Mocks.Hero.BidirectionalFriendsQuery.expectationForFirstFetchInMiddleOfList(server: server) + + var results: [Result, Error>] = [] + let firstPageExpectation = expectation(description: "First page") + var subscription = await pager.subscribe(onUpdate: { _ in + firstPageExpectation.fulfill() + }) + await pager.fetch() + await fulfillment(of: [serverExpectation, firstPageExpectation], timeout: 1) + subscription.cancel() + var result = try await XCTUnwrapping(await pager.currentValue) + results.append(result) + XCTAssertSuccessResult(result) { output in + XCTAssertTrue(output.nextPages.isEmpty) + XCTAssertEqual(output.initialPage.hero.friendsConnection.friends.count, 1) + XCTAssertEqual(output.initialPage.hero.friendsConnection.totalCount, 3) + XCTAssertEqual(output.updateSource, .fetch) + } + + let secondPageExpectation = Mocks.Hero.BidirectionalFriendsQuery.expectationForLastPage(server: server) + let secondPageFetch = expectation(description: "Second Page") + secondPageFetch.expectedFulfillmentCount = 2 + subscription = await pager.subscribe(onUpdate: { _ in + secondPageFetch.fulfill() + }) + + try await pager.loadNext() + await fulfillment(of: [secondPageExpectation, secondPageFetch], timeout: 1) + subscription.cancel() + + result = try await XCTUnwrapping(await pager.currentValue) + results.append(result) + + try XCTAssertSuccessResult(result) { output in + // Assert first page is unchanged + XCTAssertEqual(try? results.first?.get().initialPage, try? results.last?.get().initialPage) + + XCTAssertFalse(output.nextPages.isEmpty) + XCTAssertEqual(output.nextPages.count, 1) + XCTAssertTrue(output.previousPages.isEmpty) + XCTAssertEqual(output.previousPages.count, 0) + let page = try XCTUnwrap(output.nextPages.first) + XCTAssertEqual(page.hero.friendsConnection.friends.count, 1) + XCTAssertEqual(output.updateSource, .fetch) + } + var previousCount = await pager.previousPageVarMap.values.count + XCTAssertEqual(previousCount, 0) + var nextCount = await pager.nextPageVarMap.values.count + XCTAssertEqual(nextCount, 1) + + let previousPageExpectation = Mocks.Hero.BidirectionalFriendsQuery.expectationForPreviousPage(server: server) + let previousPageFetch = expectation(description: "Previous Page") + previousPageFetch.assertForOverFulfill = false + previousPageFetch.expectedFulfillmentCount = 2 + subscription = await pager.subscribe(onUpdate: { _ in + previousPageFetch.fulfill() + }) + + try await pager.loadPrevious() + await fulfillment(of: [previousPageExpectation, previousPageFetch], timeout: 1) + subscription.cancel() + + result = try await XCTUnwrapping(await pager.currentValue) + results.append(result) + + try XCTAssertSuccessResult(result) { output in + // Assert first page is unchanged + XCTAssertEqual(try? results.first?.get().initialPage, try? results.last?.get().initialPage) + + XCTAssertFalse(output.nextPages.isEmpty) + XCTAssertEqual(output.nextPages.count, 1) + XCTAssertFalse(output.previousPages.isEmpty) + XCTAssertEqual(output.previousPages.count, 1) + let page = try XCTUnwrap(output.previousPages.first) + XCTAssertEqual(page.hero.friendsConnection.friends.count, 1) + XCTAssertEqual(output.updateSource, .fetch) + } + previousCount = await pager.previousPageVarMap.values.count + XCTAssertEqual(previousCount, 1) + nextCount = await pager.nextPageVarMap.values.count + XCTAssertEqual(nextCount, 1) + } + + func test_loadAll_async() async throws { + let pager = createPager() + + let firstPageExpectation = Mocks.Hero.BidirectionalFriendsQuery.expectationForFirstFetchInMiddleOfList(server: server) + let previousPageExpectation = Mocks.Hero.BidirectionalFriendsQuery.expectationForPreviousPage(server: server) + let lastPageExpectation = Mocks.Hero.BidirectionalFriendsQuery.expectationForLastPage(server: server) + + let loadAllExpectation = expectation(description: "Load all pages") + await pager.subscribe(onUpdate: { _ in + loadAllExpectation.fulfill() + }).store(in: &cancellables) + try await pager.loadAll() + await fulfillment( + of: [firstPageExpectation, lastPageExpectation, previousPageExpectation, loadAllExpectation], + timeout: 5 + ) + + let result = try await XCTUnwrapping(try await pager.currentValue?.get()) + XCTAssertFalse(result.previousPages.isEmpty) + XCTAssertEqual(result.initialPage.hero.friendsConnection.friends.count, 1) + XCTAssertFalse(result.nextPages.isEmpty) + + let friends = (result.previousPages.first?.hero.friendsConnection.friends ?? []) + result.initialPage.hero.friendsConnection.friends + (result.nextPages.first?.hero.friendsConnection.friends ?? []) + + XCTAssertEqual(Set(friends).count, 3) + } + + // MARK: - GraphQLQueryPager tests + + func test_fetchMultiplePages() async throws { + let pager = GraphQLQueryPager(pager: createPager()) + let serverExpectation = Mocks.Hero.BidirectionalFriendsQuery.expectationForFirstFetchInMiddleOfList(server: server) + + var results: [Result, Error>] = [] + let firstPageExpectation = expectation(description: "First page") + var subscription = await pager.publisher.sink { _ in + firstPageExpectation.fulfill() + } + pager.fetch() + await fulfillment(of: [serverExpectation, firstPageExpectation], timeout: 1) + subscription.cancel() + var result = try await XCTUnwrapping(await pager.pager.currentValue) + results.append(result) + XCTAssertSuccessResult(result) { output in + XCTAssertTrue(output.nextPages.isEmpty) + XCTAssertEqual(output.initialPage.hero.friendsConnection.friends.count, 1) + XCTAssertEqual(output.initialPage.hero.friendsConnection.totalCount, 3) + XCTAssertEqual(output.updateSource, .fetch) + } + + let secondPageExpectation = Mocks.Hero.BidirectionalFriendsQuery.expectationForLastPage(server: server) + let secondPageFetch = expectation(description: "Second Page") + secondPageFetch.expectedFulfillmentCount = 2 + subscription = await pager.publisher.sink { _ in + secondPageFetch.fulfill() + } + + pager.loadNext() + await fulfillment(of: [secondPageExpectation, secondPageFetch], timeout: 1) + subscription.cancel() + + result = try await XCTUnwrapping(await pager.pager.currentValue) + results.append(result) + + try XCTAssertSuccessResult(result) { output in + // Assert first page is unchanged + XCTAssertEqual(try? results.first?.get().initialPage, try? results.last?.get().initialPage) + + XCTAssertFalse(output.nextPages.isEmpty) + XCTAssertEqual(output.nextPages.count, 1) + XCTAssertTrue(output.previousPages.isEmpty) + XCTAssertEqual(output.previousPages.count, 0) + let page = try XCTUnwrap(output.nextPages.first) + XCTAssertEqual(page.hero.friendsConnection.friends.count, 1) + XCTAssertEqual(output.updateSource, .fetch) + } + + let previousPageExpectation = Mocks.Hero.BidirectionalFriendsQuery.expectationForPreviousPage(server: server) + let previousPageFetch = expectation(description: "Previous Page") + previousPageFetch.assertForOverFulfill = false + previousPageFetch.expectedFulfillmentCount = 2 + subscription = await pager.publisher.sink { _ in + previousPageFetch.fulfill() + } + + pager.loadPrevious() + await fulfillment(of: [previousPageExpectation, previousPageFetch], timeout: 1) + subscription.cancel() + + result = try await XCTUnwrapping(await pager.pager.currentValue) + results.append(result) + + try XCTAssertSuccessResult(result) { output in + // Assert first page is unchanged + XCTAssertEqual(try? results.first?.get().initialPage, try? results.last?.get().initialPage) + + XCTAssertFalse(output.nextPages.isEmpty) + XCTAssertEqual(output.nextPages.count, 1) + XCTAssertFalse(output.previousPages.isEmpty) + XCTAssertEqual(output.previousPages.count, 1) + let page = try XCTUnwrap(output.previousPages.first) + XCTAssertEqual(page.hero.friendsConnection.friends.count, 1) + XCTAssertEqual(output.updateSource, .fetch) + } + } + + func test_loadAll() async throws { + let pager = GraphQLQueryPager(pager: createPager()) + + let firstPageExpectation = Mocks.Hero.BidirectionalFriendsQuery.expectationForFirstFetchInMiddleOfList(server: server) + let previousPageExpectation = Mocks.Hero.BidirectionalFriendsQuery.expectationForPreviousPage(server: server) + let lastPageExpectation = Mocks.Hero.BidirectionalFriendsQuery.expectationForLastPage(server: server) + + let loadAllExpectation = expectation(description: "Load all pages") + pager.subscribe(onUpdate: { _ in + loadAllExpectation.fulfill() + }) + pager.loadAll() + await fulfillment( + of: [firstPageExpectation, lastPageExpectation, previousPageExpectation, loadAllExpectation], + timeout: 5 + ) + + let result = try await XCTUnwrapping(try await pager.pager.currentValue?.get()) + XCTAssertFalse(result.previousPages.isEmpty) + XCTAssertEqual(result.initialPage.hero.friendsConnection.friends.count, 1) + XCTAssertFalse(result.nextPages.isEmpty) + + let friends = (result.previousPages.first?.hero.friendsConnection.friends ?? []) + + result.initialPage.hero.friendsConnection.friends + + (result.nextPages.first?.hero.friendsConnection.friends ?? []) + + XCTAssertEqual(Set(friends).count, 3) + } +} diff --git a/Tests/ApolloPaginationTests/ConcurrencyTest.swift b/Tests/ApolloPaginationTests/ConcurrencyTest.swift index 208f6038c..3e3dbb931 100644 --- a/Tests/ApolloPaginationTests/ConcurrencyTest.swift +++ b/Tests/ApolloPaginationTests/ConcurrencyTest.swift @@ -23,63 +23,19 @@ final class ConcurrencyTests: XCTestCase { client = ApolloClient(networkTransport: networkTransport, store: store) } - func test_concurrentFetches() async throws { - let pager = createPager() - var results: [Result<(Query.Data, [Query.Data], UpdateSource), Error>] = [] - let resultsExpectation = expectation(description: "Results arrival") - resultsExpectation.expectedFulfillmentCount = 2 - await pager.subscribe { result in - results.append(result) - resultsExpectation.fulfill() - }.store(in: &cancellables) - await fetchFirstPage(pager: pager) - await loadDataFromManyThreads(pager: pager, expectation: resultsExpectation) - - XCTAssertEqual(results.count, 2) - } - - func test_concurrentFetchesThrowsError() async throws { - let pager = createPager() - await fetchFirstPage(pager: pager) - await XCTAssertThrowsError(try await loadDataFromManyThreadsThrowing(pager: pager)) { error in - XCTAssertEqual(error as? PaginationError, PaginationError.loadInProgress) - } - } - - func test_concurrentFetches_nonisolated() throws { - let pager = createNonisolatedPager() - var results: [Result<(Query.Data, [Query.Data], UpdateSource), Error>] = [] - let initialExpectation = expectation(description: "Initial") - initialExpectation.assertForOverFulfill = false - let nextExpectation = expectation(description: "Next") - nextExpectation.expectedFulfillmentCount = 2 - pager.subscribe(onUpdate: { - results.append($0) - initialExpectation.fulfill() - nextExpectation.fulfill() - }) - let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) - pager.fetch() - wait(for: [serverExpectation, initialExpectation], timeout: 1.0) - - XCTAssertEqual(results.count, 1) - loadDataFromManyThreads(pager: pager) - wait(for: [nextExpectation], timeout: 1) - - XCTAssertEqual(results.count, 2) - } + // MARK: - Test helpers private func loadDataFromManyThreads( - pager: GraphQLQueryPager.Actor, + pager: AsyncGraphQLQueryPager, expectation: XCTestExpectation ) async { await withTaskGroup(of: Void.self) { group in let serverExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: self.server) - group.addTask { try? await pager.loadMore() } - group.addTask { try? await pager.loadMore() } - group.addTask { try? await pager.loadMore() } - group.addTask { try? await pager.loadMore() } - group.addTask { try? await pager.loadMore() } + group.addTask { try? await pager.loadNext() } + group.addTask { try? await pager.loadNext() } + group.addTask { try? await pager.loadNext() } + group.addTask { try? await pager.loadNext() } + group.addTask { try? await pager.loadNext() } group.addTask { await self.fulfillment(of: [serverExpectation, expectation], timeout: 100) } await group.waitForAll() @@ -87,15 +43,15 @@ final class ConcurrencyTests: XCTestCase { } private func loadDataFromManyThreadsThrowing( - pager: GraphQLQueryPager.Actor + pager: AsyncGraphQLQueryPager ) async throws { try await withThrowingTaskGroup(of: Void.self) { group in let serverExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: self.server) - group.addTask(priority: .userInitiated) { try await pager.loadMore() } - group.addTask { try await pager.loadMore() } - group.addTask { try await pager.loadMore() } - group.addTask { try await pager.loadMore() } - group.addTask { try await pager.loadMore() } + group.addTask { try await pager.loadNext() } + group.addTask { try await pager.loadNext() } + group.addTask { try await pager.loadNext() } + group.addTask { try await pager.loadNext() } + group.addTask { try await pager.loadNext() } group.addTask { await self.fulfillment(of: [serverExpectation], timeout: 100) } try await group.waitForAll() @@ -108,29 +64,29 @@ final class ConcurrencyTests: XCTestCase { let serverExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: self.server) (0..<5).forEach { _ in - try? pager.loadMore() + pager.loadNext() } wait(for: [serverExpectation], timeout: 1.0) } - // MARK: - Test helpers - - private func createPager() -> GraphQLQueryPager.Actor { + private func createPager() -> AsyncGraphQLQueryPager { let initialQuery = Query() initialQuery.__variables = ["id": "2001", "first": 2, "after": GraphQLNullable.null] - return GraphQLQueryPager.Actor( + return AsyncGraphQLQueryPager( client: client, initialQuery: initialQuery, + watcherDispatchQueue: .main, extractPageInfo: { data in switch data { case .initial(let data), .paginated(let data): - return CursorBasedPagination.ForwardPagination( + return CursorBasedPagination.Forward( hasNext: data.hero.friendsConnection.pageInfo.hasNextPage, endCursor: data.hero.friendsConnection.pageInfo.endCursor ) } }, - nextPageResolver: { pageInfo in + pageResolver: { pageInfo, direction in + guard direction == .next else { return nil } let nextQuery = Query() nextQuery.__variables = [ "id": "2001", @@ -148,16 +104,18 @@ final class ConcurrencyTests: XCTestCase { return GraphQLQueryPager( client: client, initialQuery: initialQuery, + watcherDispatchQueue: .main, extractPageInfo: { data in switch data { case .initial(let data), .paginated(let data): - return CursorBasedPagination.ForwardPagination( + return CursorBasedPagination.Forward( hasNext: data.hero.friendsConnection.pageInfo.hasNextPage, endCursor: data.hero.friendsConnection.pageInfo.endCursor ) } }, - nextPageResolver: { pageInfo in + pageResolver: { pageInfo, direction in + guard direction == .next else { return nil } let nextQuery = Query() nextQuery.__variables = [ "id": "2001", @@ -169,9 +127,57 @@ final class ConcurrencyTests: XCTestCase { ) } - private func fetchFirstPage(pager: GraphQLQueryPager.Actor) async { + private func fetchFirstPage(pager: AsyncGraphQLQueryPager) async { let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) await pager.fetch() await fulfillment(of: [serverExpectation], timeout: 1.0) } + + // MARK: - Tests + + func test_concurrentFetches() async throws { + let pager = createPager() + var results: [Result, Error>] = [] + let resultsExpectation = expectation(description: "Results arrival") + resultsExpectation.expectedFulfillmentCount = 2 + await pager.subscribe { result in + results.append(result) + resultsExpectation.fulfill() + }.store(in: &cancellables) + await fetchFirstPage(pager: pager) + await loadDataFromManyThreads(pager: pager, expectation: resultsExpectation) + + XCTAssertEqual(results.count, 2) + } + + func test_concurrentFetchesThrowsError() async throws { + let pager = createPager() + await fetchFirstPage(pager: pager) + await XCTAssertThrowsError(try await loadDataFromManyThreadsThrowing(pager: pager)) { error in + XCTAssertTrue(PaginationError.isLoadInProgress(error: error as? PaginationError)) + } + } + + func test_concurrentFetches_nonisolated() throws { + let pager = createNonisolatedPager() + var results: [Result, Error>] = [] + let initialExpectation = expectation(description: "Initial") + initialExpectation.assertForOverFulfill = false + let nextExpectation = expectation(description: "Next") + nextExpectation.expectedFulfillmentCount = 2 + pager.subscribe(onUpdate: { + results.append($0) + initialExpectation.fulfill() + nextExpectation.fulfill() + }) + let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) + pager.fetch() + wait(for: [serverExpectation, initialExpectation], timeout: 1.0) + + XCTAssertEqual(results.count, 1) + loadDataFromManyThreads(pager: pager) + wait(for: [nextExpectation], timeout: 1) + + XCTAssertEqual(results.count, 2) + } } diff --git a/Tests/ApolloPaginationTests/ForwardPaginationTests.swift b/Tests/ApolloPaginationTests/ForwardPaginationTests.swift index 68209f436..1d0f26d9d 100644 --- a/Tests/ApolloPaginationTests/ForwardPaginationTests.swift +++ b/Tests/ApolloPaginationTests/ForwardPaginationTests.swift @@ -47,7 +47,7 @@ final class ForwardPaginationTests: XCTestCase, CacheDependentTesting { let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) - var results: [Result<(Query.Data, [Query.Data], UpdateSource), Error>] = [] + var results: [Result, Error>] = [] let firstPageExpectation = expectation(description: "First page") var subscription = await pager.subscribe(onUpdate: { _ in firstPageExpectation.fulfill() @@ -57,12 +57,11 @@ final class ForwardPaginationTests: XCTestCase, CacheDependentTesting { subscription.cancel() var result = try await XCTUnwrapping(await pager.currentValue) results.append(result) - XCTAssertSuccessResult(result) { value in - let (first, next, source) = value - XCTAssertTrue(next.isEmpty) - XCTAssertEqual(first.hero.friendsConnection.friends.count, 2) - XCTAssertEqual(first.hero.friendsConnection.totalCount, 3) - XCTAssertEqual(source, .fetch) + XCTAssertSuccessResult(result) { output in + XCTAssertTrue(output.nextPages.isEmpty) + XCTAssertEqual(output.initialPage.hero.friendsConnection.friends.count, 2) + XCTAssertEqual(output.initialPage.hero.friendsConnection.totalCount, 3) + XCTAssertEqual(output.updateSource, .fetch) } let secondPageExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) @@ -72,26 +71,29 @@ final class ForwardPaginationTests: XCTestCase, CacheDependentTesting { secondPageFetch.fulfill() }) - try await pager.loadMore() + try await pager.loadNext() await fulfillment(of: [secondPageExpectation, secondPageFetch], timeout: 1) subscription.cancel() result = try await XCTUnwrapping(await pager.currentValue) results.append(result) - try XCTAssertSuccessResult(result) { value in - let (_, next, source) = value + try XCTAssertSuccessResult(result) { output in // Assert first page is unchanged - XCTAssertEqual(try? results.first?.get().0, try? results.last?.get().0) + XCTAssertEqual(try? results.first?.get().initialPage, try? results.last?.get().initialPage) - XCTAssertFalse(next.isEmpty) - XCTAssertEqual(next.count, 1) - let page = try XCTUnwrap(next.first) + XCTAssertFalse(output.nextPages.isEmpty) + XCTAssertEqual(output.nextPages.count, 1) + XCTAssertTrue(output.previousPages.isEmpty) + XCTAssertEqual(output.previousPages.count, 0) + let page = try XCTUnwrap(output.nextPages.first) XCTAssertEqual(page.hero.friendsConnection.friends.count, 1) - XCTAssertEqual(source, .fetch) + XCTAssertEqual(output.updateSource, .fetch) } - let count = await pager.varMap.values.count - XCTAssertEqual(count, 1) + let previousCount = await pager.previousPageVarMap.values.count + XCTAssertEqual(previousCount, 0) + let nextCount = await pager.nextPageVarMap.values.count + XCTAssertEqual(nextCount, 1) } func test_variableMapping() async throws { @@ -108,7 +110,7 @@ final class ForwardPaginationTests: XCTestCase, CacheDependentTesting { let subscription = await pager.subscribe(onUpdate: { _ in secondPageFetch.fulfill() }) - try await pager.loadMore(cachePolicy: .fetchIgnoringCacheData) + try await pager.loadNext(cachePolicy: .fetchIgnoringCacheData) await fulfillment(of: [secondPageExpectation, secondPageFetch]) subscription.cancel() @@ -122,8 +124,7 @@ final class ForwardPaginationTests: XCTestCase, CacheDependentTesting { ] let expectedVariables = nextQuery.__variables?.values.compactMap { $0._jsonEncodableValue?._jsonValue } ?? [] - let firstKey = await pager.varMap.keys.first as? [JSONValue] - let actualVariables = try XCTUnwrap(firstKey) + let actualVariables = try await XCTUnwrapping(await pager.nextPageVarMap.keys.first as? [JSONValue]) XCTAssertEqual(expectedVariables.count, actualVariables.count) XCTAssertEqual(expectedVariables.count, 3) @@ -132,41 +133,42 @@ final class ForwardPaginationTests: XCTestCase, CacheDependentTesting { } func test_paginationState() async throws { - let pager = createPager() - - var currentPageInfo = await pager.currentPageInfo - XCTAssertNil(currentPageInfo) - - let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) - - await pager.fetch() - await fulfillment(of: [serverExpectation]) - - currentPageInfo = try await XCTUnwrapping(await pager.currentPageInfo) - var page = try XCTUnwrap(currentPageInfo as? CursorBasedPagination.ForwardPagination) - let expectedFirstPage = CursorBasedPagination.ForwardPagination( - hasNext: true, - endCursor: "Y3Vyc29yMg==" - ) - XCTAssertEqual(page, expectedFirstPage) - - let secondPageExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) - let secondPageFetch = expectation(description: "Second Page") - secondPageFetch.expectedFulfillmentCount = 2 - let subscription = await pager.subscribe(onUpdate: { _ in - secondPageFetch.fulfill() - }) - try await pager.loadMore(cachePolicy: .fetchIgnoringCacheData) - await fulfillment(of: [secondPageExpectation, secondPageFetch]) - subscription.cancel() - - currentPageInfo = try await XCTUnwrapping(await pager.currentPageInfo) - page = try XCTUnwrap(currentPageInfo as? CursorBasedPagination.ForwardPagination) - let expectedSecondPage = CursorBasedPagination.ForwardPagination( - hasNext: false, - endCursor: "Y3Vyc29yMw==" - ) - XCTAssertEqual(page, expectedSecondPage) + let pager = createPager() + + var nextPageInfo = await pager.nextPageInfo + XCTAssertNil(nextPageInfo) + + let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) + + await pager.fetch() + await fulfillment(of: [serverExpectation]) + + nextPageInfo = try await XCTUnwrapping(await pager.nextPageInfo) + var page = try XCTUnwrap(nextPageInfo as? CursorBasedPagination.Forward) + let expectedFirstPage = CursorBasedPagination.Forward( + hasNext: true, + endCursor: "Y3Vyc29yMg==" + ) + XCTAssertEqual(page, expectedFirstPage) + + let secondPageExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) + let secondPageFetch = expectation(description: "Second Page") + secondPageFetch.expectedFulfillmentCount = 2 + let subscription = await pager.subscribe(onUpdate: { _ in + secondPageFetch.fulfill() + }) + try await pager.loadNext(cachePolicy: .fetchIgnoringCacheData) + await fulfillment(of: [secondPageExpectation, secondPageFetch]) + subscription.cancel() + + nextPageInfo = try await XCTUnwrapping(await pager.nextPageInfo) + page = try XCTUnwrap(nextPageInfo as? CursorBasedPagination.Forward) + let expectedSecondPage = CursorBasedPagination.Forward( + hasNext: false, + endCursor: "Y3Vyc29yMw==" + ) + + XCTAssertEqual(page, expectedSecondPage) } func test_fetchMultiplePages_mutateHero() async throws { @@ -181,12 +183,11 @@ final class ForwardPaginationTests: XCTestCase, CacheDependentTesting { await fulfillment(of: [serverExpectation, firstPageExpectation], timeout: 1) subscription.cancel() let result = try await XCTUnwrapping(await pager.currentValue) - XCTAssertSuccessResult(result) { value in - let (first, next, source) = value - XCTAssertTrue(next.isEmpty) - XCTAssertEqual(first.hero.friendsConnection.friends.count, 2) - XCTAssertEqual(first.hero.friendsConnection.totalCount, 3) - XCTAssertEqual(source, .fetch) + XCTAssertSuccessResult(result) { output in + XCTAssertTrue(output.nextPages.isEmpty) + XCTAssertEqual(output.initialPage.hero.friendsConnection.friends.count, 2) + XCTAssertEqual(output.initialPage.hero.friendsConnection.totalCount, 3) + XCTAssertEqual(output.updateSource, .fetch) } let secondPageExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) @@ -196,21 +197,20 @@ final class ForwardPaginationTests: XCTestCase, CacheDependentTesting { secondPageFetch.fulfill() }) - try await pager.loadMore() + try await pager.loadNext() await fulfillment(of: [secondPageExpectation, secondPageFetch], timeout: 1) subscription.cancel() let newResult = try await XCTUnwrapping(await pager.currentValue) - try XCTAssertSuccessResult(newResult) { value in - let (_, next, source) = value + try XCTAssertSuccessResult(newResult) { output in // Assert first page is unchanged - XCTAssertEqual(try? result.get().0, try? newResult.get().0) - XCTAssertFalse(next.isEmpty) - XCTAssertEqual(next.count, 1) - let page = try XCTUnwrap(next.first) + XCTAssertEqual(try? result.get().initialPage, try? newResult.get().initialPage) + XCTAssertFalse(output.nextPages.isEmpty) + XCTAssertEqual(output.nextPages.count, 1) + let page = try XCTUnwrap(output.nextPages.first) XCTAssertEqual(page.hero.friendsConnection.friends.count, 1) - XCTAssertEqual(source, .fetch) + XCTAssertEqual(output.updateSource, .fetch) } - let count = await pager.varMap.values.count + let count = await pager.nextPageVarMap.values.count XCTAssertEqual(count, 1) let transactionExpectation = expectation(description: "Writing to cache") @@ -227,29 +227,80 @@ final class ForwardPaginationTests: XCTestCase, CacheDependentTesting { } await fulfillment(of: [transactionExpectation, mutationExpectation]) let finalResult = try await XCTUnwrapping(await pager.currentValue) - XCTAssertSuccessResult(finalResult) { value in - XCTAssertEqual(value.0.hero.name, "C3PO") - XCTAssertEqual(value.1.count, 1) - XCTAssertEqual(value.1.first?.hero.name, "C3PO") + XCTAssertSuccessResult(finalResult) { output in + XCTAssertEqual(output.initialPage.hero.name, "C3PO") + XCTAssertEqual(output.nextPages.count, 1) + XCTAssertEqual(output.nextPages.first?.hero.name, "C3PO") } } - private func createPager() -> GraphQLQueryPager.Actor { + func test_loadAll() async throws { + let pager = createPager() + + let firstPageExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) + let lastPageExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) + let loadAllExpectation = expectation(description: "Load all pages") + await pager.subscribe(onUpdate: { _ in + loadAllExpectation.fulfill() + }).store(in: &cancellables) + try await pager.loadAll() + await fulfillment(of: [firstPageExpectation, lastPageExpectation, loadAllExpectation], timeout: 5) + } + + func test_failingFetch_finishes() async throws { + let initialQuery = Query() + initialQuery.__variables = ["id": "2001", "flirst": 2, "after": GraphQLNullable.none] + let pager = AsyncGraphQLQueryPager( + client: client, + initialQuery: initialQuery, + extractPageInfo: { data in + switch data { + case .initial(let data), .paginated(let data): + return CursorBasedPagination.Forward( + hasNext: data.hero.friendsConnection.pageInfo.hasNextPage, + endCursor: data.hero.friendsConnection.pageInfo.endCursor + ) + } + }, + pageResolver: { pageInfo, direction in + guard direction == .next else { return nil } + let nextQuery = Query() + nextQuery.__variables = [ + "id": "2001", + "first": 2, + "after": pageInfo.endCursor, + ] + return nextQuery + } + ) + let lastPageExpectation = Mocks.Hero.FriendsQuery.failingExpectation(server: server) + + let cancellable = await pager.subscribe { result in + try? XCTAssertThrowsError(result.get()) + } + await pager.fetch() + await fulfillment(of: [lastPageExpectation]) + cancellable.cancel() + } + + private func createPager() -> AsyncGraphQLQueryPager { let initialQuery = Query() initialQuery.__variables = ["id": "2001", "first": 2, "after": GraphQLNullable.null] - return GraphQLQueryPager.Actor( + return AsyncGraphQLQueryPager( client: client, initialQuery: initialQuery, + watcherDispatchQueue: .main, extractPageInfo: { data in switch data { case .initial(let data), .paginated(let data): - return CursorBasedPagination.ForwardPagination( + return CursorBasedPagination.Forward( hasNext: data.hero.friendsConnection.pageInfo.hasNextPage, endCursor: data.hero.friendsConnection.pageInfo.endCursor ) } }, - nextPageResolver: { pageInfo in + pageResolver: { pageInfo, direction in + guard direction == .next else { return nil } let nextQuery = Query() nextQuery.__variables = [ "id": "2001", @@ -261,3 +312,50 @@ final class ForwardPaginationTests: XCTestCase, CacheDependentTesting { ) } } + +private extension Mocks.Hero.FriendsQuery { + static func failingExpectation(server: MockGraphQLServer) -> XCTestExpectation { + let query = MockQuery() + query.__variables = ["id": "2001", "flirst": 2, "after": GraphQLNullable.none] + return server.expect(query) { _ in + let pageInfo: [AnyHashable: AnyHashable] = [ + "__typename": "PageInfo", + "endCursor": "Y3Vyc29yMg==", + "hasNextPage": true, + ] + let friends: [[String: AnyHashable]] = [ + [ + "__typename": "Human", + "name": "Luke Skywalker", + "id": "1000", + ], + [ + "__typename": "Human", + "name": "Han Solo", + "id": "1002", + ], + ] + let friendsConnection: [String: AnyHashable] = [ + "__typename": "FriendsConnection", + "totalCount": 3, + "friends": friends, + "pageInfo": pageInfo, + ] + + let hero: [String: AnyHashable] = [ + "__typename": "Droid", + "id": "2001", + "name": "R2-D2", + "friendsConnection": friendsConnection, + ] + + let data: [String: AnyHashable] = [ + "hero": hero + ] + + return [ + "data": data + ] + } + } +} diff --git a/Tests/ApolloPaginationTests/FriendsQuery+TestHelpers.swift b/Tests/ApolloPaginationTests/FriendsQuery+TestHelpers.swift index f53fa0f31..f9e3e314d 100644 --- a/Tests/ApolloPaginationTests/FriendsQuery+TestHelpers.swift +++ b/Tests/ApolloPaginationTests/FriendsQuery+TestHelpers.swift @@ -1,10 +1,12 @@ +import ApolloAPI import ApolloInternalTestHelpers import XCTest extension Mocks.Hero.FriendsQuery { - static func expectationForFirstPage(server: MockGraphQLServer) -> XCTestExpectation { - server.expect(MockQuery.self) { _ in + let query = MockQuery() + query.__variables = ["id": "2001", "first": 2, "after": GraphQLNullable.null] + return server.expect(query) { _ in let pageInfo: [AnyHashable: AnyHashable] = [ "__typename": "PageInfo", "endCursor": "Y3Vyc29yMg==", @@ -28,26 +30,28 @@ extension Mocks.Hero.FriendsQuery { "friends": friends, "pageInfo": pageInfo, ] - + let hero: [String: AnyHashable] = [ "__typename": "Droid", "id": "2001", "name": "R2-D2", "friendsConnection": friendsConnection, ] - + let data: [String: AnyHashable] = [ "hero": hero ] - + return [ "data": data ] } } - + static func expectationForSecondPage(server: MockGraphQLServer) -> XCTestExpectation { - server.expect(MockQuery.self) { _ in + let query = MockQuery() + query.__variables = ["id": "2001", "first": 2, "after": "Y3Vyc29yMg=="] + return server.expect(query) { _ in let pageInfo: [AnyHashable: AnyHashable] = [ "__typename": "PageInfo", "endCursor": "Y3Vyc29yMw==", @@ -66,18 +70,232 @@ extension Mocks.Hero.FriendsQuery { "friends": friends, "pageInfo": pageInfo, ] - + + let hero: [String: AnyHashable] = [ + "__typename": "Droid", + "id": "2001", + "name": "R2-D2", + "friendsConnection": friendsConnection, + ] + + let data: [String: AnyHashable] = [ + "hero": hero + ] + + return [ + "data": data + ] + } + } +} + +extension Mocks.Hero.ReverseFriendsQuery { + static func expectationForPreviousItem(server: MockGraphQLServer) -> XCTestExpectation { + let query = MockQuery() + query.__variables = ["id": "2001", "first": 2, "before": "Y3Vyc29yMg=="] + return server.expect(query) { _ in + let pageInfo: [AnyHashable: AnyHashable] = [ + "__typename": "PageInfo", + "startCursor": "Y3Vyc29yZg==", + "hasPreviousPage": false, + ] + let friends: [[String: AnyHashable]] = [ + [ + "__typename": "Human", + "name": "Luke Skywalker", + "id": "1000", + ], + ] + let friendsConnection: [String: AnyHashable] = [ + "__typename": "FriendsConnection", + "totalCount": 3, + "friends": friends, + "pageInfo": pageInfo, + ] + + let hero: [String: AnyHashable] = [ + "__typename": "Droid", + "id": "2001", + "name": "R2-D2", + "friendsConnection": friendsConnection, + ] + + let data: [String: AnyHashable] = [ + "hero": hero + ] + + return [ + "data": data + ] + } + } + static func expectationForLastItem(server: MockGraphQLServer) -> XCTestExpectation { + let query = MockQuery() + query.__variables = ["id": "2001", "first": 2, "before": "Y3Vyc29yMw=="] + return server.expect(query) { _ in + let pageInfo: [AnyHashable: AnyHashable] = [ + "__typename": "PageInfo", + "startCursor": "Y3Vyc29yMg==", + "hasPreviousPage": true, + ] + let friends: [[String: AnyHashable]] = [ + [ + "__typename": "Human", + "name": "Han Solo", + "id": "1002", + ], + [ + "__typename": "Human", + "name": "Leia Organa", + "id": "1003", + ], + ] + let friendsConnection: [String: AnyHashable] = [ + "__typename": "FriendsConnection", + "totalCount": 3, + "friends": friends, + "pageInfo": pageInfo, + ] + + let hero: [String: AnyHashable] = [ + "__typename": "Droid", + "id": "2001", + "name": "R2-D2", + "friendsConnection": friendsConnection, + ] + + let data: [String: AnyHashable] = [ + "hero": hero + ] + + return [ + "data": data + ] + } + } +} + +extension Mocks.Hero.BidirectionalFriendsQuery { + static func expectationForFirstFetchInMiddleOfList(server: MockGraphQLServer) -> XCTestExpectation { + let query = MockQuery() + query.__variables = ["id": "2001", "first": 1, "before": GraphQLNullable.null, "after": "Y3Vyc29yMw=="] + return server.expect(query) { _ in + let pageInfo: [AnyHashable: AnyHashable] = [ + "__typename": "PageInfo", + "startCursor": "Y3Vyc29yMw==", + "hasPreviousPage": true, + "endCursor": "Y3Vyc29yMg==", + "hasNextPage": true, + ] + let friends: [[String: AnyHashable]] = [ + [ + "__typename": "Human", + "name": "Leia Organa", + "id": "1003", + ], + ] + let friendsConnection: [String: AnyHashable] = [ + "__typename": "FriendsConnection", + "totalCount": 3, + "friends": friends, + "pageInfo": pageInfo, + ] + + let hero: [String: AnyHashable] = [ + "__typename": "Droid", + "id": "2001", + "name": "R2-D2", + "friendsConnection": friendsConnection, + ] + + let data: [String: AnyHashable] = [ + "hero": hero + ] + + return [ + "data": data + ] + } + } + + static func expectationForLastPage(server: MockGraphQLServer) -> XCTestExpectation { + let query = MockQuery() + query.__variables = ["id": "2001", "first": 1, "after": "Y3Vyc29yMg==", "before": GraphQLNullable.null] + return server.expect(query) { _ in + let pageInfo: [AnyHashable: AnyHashable] = [ + "__typename": "PageInfo", + "startCursor": "Y3Vyc29yMg==", + "hasPreviousPage": true, + "endCursor": "Y3Vyc29yMa==", + "hasNextPage": false, + ] + let friends: [[String: AnyHashable]] = [ + [ + "__typename": "Human", + "name": "Han Solo", + "id": "1002", + ], + ] + let friendsConnection: [String: AnyHashable] = [ + "__typename": "FriendsConnection", + "totalCount": 3, + "friends": friends, + "pageInfo": pageInfo, + ] + let hero: [String: AnyHashable] = [ "__typename": "Droid", "id": "2001", "name": "R2-D2", "friendsConnection": friendsConnection, ] - + let data: [String: AnyHashable] = [ "hero": hero ] - + + return [ + "data": data + ] + } + } + + static func expectationForPreviousPage(server: MockGraphQLServer) -> XCTestExpectation { + let query = MockQuery() + query.__variables = ["id": "2001", "first": 1, "before": "Y3Vyc29yMw==", "after": GraphQLNullable.null] + return server.expect(query) { _ in + let pageInfo: [AnyHashable: AnyHashable] = [ + "__typename": "PageInfo", + "startCursor": "Y3Vyc29yMq==", + "hasPreviousPage": false, + "endCursor": "Y3Vyc29yMw==", + "hasNextPage": true, + ] + let friends: [[String: AnyHashable]] = [ + [ + "__typename": "Human", + "name": "Luke Skywalker", + "id": "1000", + ], + ] + let friendsConnection: [String: AnyHashable] = [ + "__typename": "FriendsConnection", + "totalCount": 3, + "friends": friends, + "pageInfo": pageInfo, + ] + + let hero: [String: AnyHashable] = [ + "__typename": "Droid", + "id": "2001", + "name": "R2-D2", + "friendsConnection": friendsConnection, + ] + + let data: [String: AnyHashable] = [ + "hero": hero + ] + return [ "data": data ] diff --git a/Tests/ApolloPaginationTests/GraphQLQueryPagerTests.swift b/Tests/ApolloPaginationTests/GraphQLQueryPagerTests.swift new file mode 100644 index 000000000..fff304894 --- /dev/null +++ b/Tests/ApolloPaginationTests/GraphQLQueryPagerTests.swift @@ -0,0 +1,149 @@ +import Apollo +import ApolloAPI +import ApolloInternalTestHelpers +import Combine +import XCTest + +@testable import ApolloPagination + +final class GraphQLQueryPagerTests: XCTestCase, CacheDependentTesting { + private typealias ForwardQuery = MockQuery + + var cacheType: TestCacheProvider.Type { + InMemoryTestCacheProvider.self + } + + var cache: NormalizedCache! + var server: MockGraphQLServer! + var client: ApolloClient! + var cancellables: [AnyCancellable] = [] + + override func setUpWithError() throws { + try super.setUpWithError() + + cache = try makeNormalizedCache() + let store = ApolloStore(cache: cache) + + server = MockGraphQLServer() + let networkTransport = MockNetworkTransport(server: server, store: store) + + client = ApolloClient(networkTransport: networkTransport, store: store) + MockSchemaMetadata.stub_cacheKeyInfoForType_Object = IDCacheKeyProvider.resolver + } + + override func tearDownWithError() throws { + cache = nil + server = nil + client = nil + cancellables.forEach { $0.cancel() } + cancellables = [] + + try super.tearDownWithError() + } + + @available(iOS 16.0, macOS 13.0, *) + func test_pager_cancellation_calls_callback() async throws { + server.customDelay = .milliseconds(1) + let pager = GraphQLQueryPager(pager: createForwardPager()) + let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) + + pager.fetch() + await fulfillment(of: [serverExpectation], timeout: 1) + server.customDelay = .milliseconds(200) + let secondPageExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) + let callbackExpectation = expectation(description: "Callback") + pager.loadNext(completion: { _ in + callbackExpectation.fulfill() + }) + try await Task.sleep(for: .milliseconds(50)) + pager.cancel() + await fulfillment(of: [callbackExpectation, secondPageExpectation], timeout: 1) + } + + @available(iOS 16.0, macOS 13.0, *) + func test_pager_cancellation_calls_callback_manyQueuedRequests() throws { + server.customDelay = .milliseconds(1) + let pager = GraphQLQueryPager(pager: createForwardPager()) + let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) + var results: [Result, Error>] = [] + var errors: [PaginationError?] = [] + + pager.fetch() + wait(for: [serverExpectation], timeout: 1) + server.customDelay = .milliseconds(150) + pager.subscribe { result in + results.append(result) + } + let secondPageExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) + pager.loadNext(completion: { error in + errors.append(error) + }) + pager.loadNext(completion: { error in + errors.append(error) + }) + DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(10)) { + pager.cancel() + } + + wait(for: [secondPageExpectation], timeout: 2) + XCTAssertEqual(results.count, 1) // once for original fetch + XCTAssertEqual(errors.count, 2) + XCTAssertTrue(errors.contains(where: { PaginationError.isCancellation(error: $0) })) + } + + @available(iOS 16.0, macOS 13.0, *) + func test_pager_cancellation_calls_callback_deinit() async throws { + server.customDelay = .milliseconds(1) + var pager = GraphQLQueryPager(pager: createForwardPager()) + let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) + var results: [Result, Error>] = [] + var errors: [PaginationError?] = [] + + pager.fetch() + await fulfillment(of: [serverExpectation], timeout: 1) + server.customDelay = .milliseconds(150) + pager.subscribe { result in + results.append(result) + } + let secondPageExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) + pager.loadNext(completion: { error in + errors.append(error) + }) + try await Task.sleep(for: .milliseconds(50)) + pager = GraphQLQueryPager(pager: self.createForwardPager()) + + await fulfillment(of: [secondPageExpectation], timeout: 2) + XCTAssertEqual(results.count, 1) // once for original fetch + XCTAssertEqual(errors.count, 1) + XCTAssertTrue(errors.contains(where: { PaginationError.isCancellation(error: $0) })) + } + + private func createForwardPager() -> AsyncGraphQLQueryPager { + let initialQuery = ForwardQuery() + initialQuery.__variables = ["id": "2001", "first": 2, "after": GraphQLNullable.null] + return AsyncGraphQLQueryPager( + client: client, + initialQuery: initialQuery, + watcherDispatchQueue: .main, + extractPageInfo: { data in + switch data { + case .initial(let data), .paginated(let data): + return CursorBasedPagination.Forward( + hasNext: data.hero.friendsConnection.pageInfo.hasNextPage, + endCursor: data.hero.friendsConnection.pageInfo.endCursor + ) + } + }, + pageResolver: { pageInfo, direction in + guard direction == .next else { return nil } + let nextQuery = ForwardQuery() + nextQuery.__variables = [ + "id": "2001", + "first": 2, + "after": pageInfo.endCursor, + ] + return nextQuery + } + ) + } +} diff --git a/Tests/ApolloPaginationTests/Mocks.swift b/Tests/ApolloPaginationTests/Mocks.swift index 47449fdb9..240d0bd38 100644 --- a/Tests/ApolloPaginationTests/Mocks.swift +++ b/Tests/ApolloPaginationTests/Mocks.swift @@ -5,6 +5,127 @@ import XCTest enum Mocks { enum Hero { + class BidirectionalFriendsQuery: MockSelectionSet { + override class var __selections: [Selection] { [ + .field("hero", Hero?.self, arguments: ["id": .variable("id")]) + ]} + + var hero: Hero { __data["hero"] } + + class Hero: MockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("id", String.self), + .field("name", String.self), + .field("friendsConnection", FriendsConnection.self, arguments: [ + "first": .variable("first"), + "before": .variable("before"), + "after": .variable("after"), + ]), + ]} + + var name: String { __data["name"] } + var id: String { __data["id"] } + var friendsConnection: FriendsConnection { __data["friendsConnection"] } + + class FriendsConnection: MockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("totalCount", Int.self), + .field("friends", [Character].self), + .field("pageInfo", PageInfo.self), + ]} + + var totalCount: Int { __data["totalCount"] } + var friends: [Character] { __data["friends"] } + var pageInfo: PageInfo { __data["pageInfo"] } + + class Character: MockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("name", String.self), + .field("id", String.self), + ]} + + var name: String { __data["name"] } + var id: String { __data["id"] } + } + + class PageInfo: MockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("startCursor", Optional.self), + .field("hasPreviousPage", Bool.self), + .field("endCursor", Optional.self), + .field("hasNextPage", Bool.self), + ]} + + var endCursor: String? { __data["endCursor"] } + var hasNextPage: Bool { __data["hasNextPage"] } + var startCursor: String? { __data["startCursor"] } + var hasPreviousPage: Bool { __data["hasPreviousPage"] } + } + } + } + } + class ReverseFriendsQuery: MockSelectionSet { + override class var __selections: [Selection] { [ + .field("hero", Hero?.self, arguments: ["id": .variable("id")]) + ]} + + var hero: Hero { __data["hero"] } + + class Hero: MockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("id", String.self), + .field("name", String.self), + .field("friendsConnection", FriendsConnection.self, arguments: [ + "first": .variable("first"), + "before": .variable("before"), + ]), + ]} + + var name: String { __data["name"] } + var id: String { __data["id"] } + var friendsConnection: FriendsConnection { __data["friendsConnection"] } + + class FriendsConnection: MockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("totalCount", Int.self), + .field("friends", [Character].self), + .field("pageInfo", PageInfo.self), + ]} + + var totalCount: Int { __data["totalCount"] } + var friends: [Character] { __data["friends"] } + var pageInfo: PageInfo { __data["pageInfo"] } + + class Character: MockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("name", String.self), + .field("id", String.self), + ]} + + var name: String { __data["name"] } + var id: String { __data["id"] } + } + + class PageInfo: MockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("startCursor", Optional.self), + .field("hasPreviousPage", Bool.self), + ]} + + var startCursor: String? { __data["startCursor"] } + var hasPreviousPage: Bool { __data["hasPreviousPage"] } + } + } + } + } class FriendsQuery: MockSelectionSet { override class var __selections: [Selection] { [ .field("hero", Hero?.self, arguments: ["id": .variable("id")]) diff --git a/Tests/ApolloPaginationTests/PaginationError+Test.swift b/Tests/ApolloPaginationTests/PaginationError+Test.swift new file mode 100644 index 000000000..c025e0789 --- /dev/null +++ b/Tests/ApolloPaginationTests/PaginationError+Test.swift @@ -0,0 +1,19 @@ +@testable import ApolloPagination + +extension PaginationError { + static func isCancellation(error: PaginationError?) -> Bool { + if case .cancellation = error { + return true + } else { + return false + } + } + + static func isLoadInProgress(error: PaginationError?) -> Bool { + if case .loadInProgress = error { + return true + } else { + return false + } + } +} diff --git a/Tests/ApolloPaginationTests/ReversePaginationTests.swift b/Tests/ApolloPaginationTests/ReversePaginationTests.swift new file mode 100644 index 000000000..d33ac5f5c --- /dev/null +++ b/Tests/ApolloPaginationTests/ReversePaginationTests.swift @@ -0,0 +1,140 @@ +import Apollo +import ApolloAPI +import ApolloInternalTestHelpers +import Combine +import XCTest + +@testable import ApolloPagination + +final class ReversePaginationTests: XCTestCase, CacheDependentTesting { + + private typealias Query = MockQuery + + var cacheType: TestCacheProvider.Type { + InMemoryTestCacheProvider.self + } + + var cache: NormalizedCache! + var server: MockGraphQLServer! + var client: ApolloClient! + var cancellables: [AnyCancellable] = [] + + override func setUpWithError() throws { + try super.setUpWithError() + + cache = try makeNormalizedCache() + let store = ApolloStore(cache: cache) + + server = MockGraphQLServer() + let networkTransport = MockNetworkTransport(server: server, store: store) + + client = ApolloClient(networkTransport: networkTransport, store: store) + MockSchemaMetadata.stub_cacheKeyInfoForType_Object = IDCacheKeyProvider.resolver + } + + override func tearDownWithError() throws { + cache = nil + server = nil + client = nil + cancellables.forEach { $0.cancel() } + cancellables = [] + + try super.tearDownWithError() + } + + func test_fetchMultiplePages() async throws { + let pager = createPager() + + let serverExpectation = Mocks.Hero.ReverseFriendsQuery.expectationForLastItem(server: server) + + var results: [Result, Error>] = [] + let firstPageExpectation = expectation(description: "First page") + var subscription = await pager.subscribe(onUpdate: { _ in + firstPageExpectation.fulfill() + }) + await pager.fetch() + await fulfillment(of: [serverExpectation, firstPageExpectation], timeout: 1) + subscription.cancel() + var result = try await XCTUnwrapping(await pager.currentValue) + results.append(result) + XCTAssertSuccessResult(result) { output in + XCTAssertTrue(output.nextPages.isEmpty) + XCTAssertEqual(output.initialPage.hero.friendsConnection.friends.count, 2) + XCTAssertEqual(output.initialPage.hero.friendsConnection.totalCount, 3) + XCTAssertEqual(output.updateSource, .fetch) + } + + let secondPageExpectation = Mocks.Hero.ReverseFriendsQuery.expectationForPreviousItem(server: server) + let secondPageFetch = expectation(description: "Second Page") + secondPageFetch.expectedFulfillmentCount = 2 + subscription = await pager.subscribe(onUpdate: { _ in + secondPageFetch.fulfill() + }) + + try await pager.loadPrevious() + await fulfillment(of: [secondPageExpectation, secondPageFetch], timeout: 1) + subscription.cancel() + + result = try await XCTUnwrapping(await pager.currentValue) + results.append(result) + + try XCTAssertSuccessResult(result) { output in + // Assert first page is unchanged + XCTAssertEqual(try? results.first?.get().initialPage, try? results.last?.get().initialPage) + + XCTAssertFalse(output.previousPages.isEmpty) + XCTAssertEqual(output.previousPages.count, 1) + XCTAssertTrue(output.nextPages.isEmpty) + XCTAssertEqual(output.nextPages.count, 0) + let page = try XCTUnwrap(output.previousPages.first) + XCTAssertEqual(page.hero.friendsConnection.friends.count, 1) + XCTAssertEqual(output.updateSource, .fetch) + } + let previousCount = await pager.previousPageVarMap.values.count + XCTAssertEqual(previousCount, 1) + let nextCount = await pager.nextPageVarMap.values.count + XCTAssertEqual(nextCount, 0) + } + + func test_loadAll() async throws { + let pager = createPager() + + let firstPageExpectation = Mocks.Hero.ReverseFriendsQuery.expectationForLastItem(server: server) + let lastPageExpectation = Mocks.Hero.ReverseFriendsQuery.expectationForPreviousItem(server: server) + let loadAllExpectation = expectation(description: "Load all pages") + await pager.subscribe(onUpdate: { _ in + loadAllExpectation.fulfill() + }).store(in: &cancellables) + try await pager.loadAll() + await fulfillment(of: [firstPageExpectation, lastPageExpectation, loadAllExpectation], timeout: 5) + } + + private func createPager() -> AsyncGraphQLQueryPager { + let initialQuery = Query() + initialQuery.__variables = ["id": "2001", "first": 2, "before": "Y3Vyc29yMw=="] + return AsyncGraphQLQueryPager( + client: client, + initialQuery: initialQuery, + watcherDispatchQueue: .main, + extractPageInfo: { data in + switch data { + case .initial(let data), .paginated(let data): + return CursorBasedPagination.Reverse( + hasPrevious: data.hero.friendsConnection.pageInfo.hasPreviousPage, + startCursor: data.hero.friendsConnection.pageInfo.startCursor + ) + } + }, + pageResolver: { pageInfo, direction in + guard direction == .previous else { return nil } + let nextQuery = Query() + nextQuery.__variables = [ + "id": "2001", + "first": 2, + "before": pageInfo.startCursor, + ] + return nextQuery + } + ) + } +} diff --git a/Tests/ApolloPaginationTests/SubscribeTests.swift b/Tests/ApolloPaginationTests/SubscribeTests.swift index 256b317fd..045ed7f74 100644 --- a/Tests/ApolloPaginationTests/SubscribeTests.swift +++ b/Tests/ApolloPaginationTests/SubscribeTests.swift @@ -45,8 +45,8 @@ final class SubscribeTest: XCTestCase, CacheDependentTesting { let initialFetchExpectation = expectation(description: "Results") initialFetchExpectation.assertForOverFulfill = false - var results: [Result.Output, Error>] = [] - var otherResults: [Result.Output, Error>] = [] + var results: [Result, Error>] = [] + var otherResults: [Result, Error>] = [] await pager.$currentValue.compactMap({ $0 }).sink { result in results.append(result) initialFetchExpectation.fulfill() @@ -62,32 +62,33 @@ final class SubscribeTest: XCTestCase, CacheDependentTesting { await fulfillment(of: [serverExpectation, initialFetchExpectation], timeout: 1.0) XCTAssertFalse(results.isEmpty) let result = try XCTUnwrap(results.first) - XCTAssertSuccessResult(result) { value in - let (first, next, source) = value - XCTAssertTrue(next.isEmpty) - XCTAssertEqual(first.hero.friendsConnection.friends.count, 2) - XCTAssertEqual(first.hero.friendsConnection.totalCount, 3) - XCTAssertEqual(source, .fetch) + XCTAssertSuccessResult(result) { output in + XCTAssertTrue(output.nextPages.isEmpty) + XCTAssertEqual(output.initialPage.hero.friendsConnection.friends.count, 2) + XCTAssertEqual(output.initialPage.hero.friendsConnection.totalCount, 3) + XCTAssertEqual(output.updateSource, .fetch) XCTAssertEqual(results.count, otherResults.count) } } - private func createPager() -> GraphQLQueryPager.Actor { + private func createPager() -> AsyncGraphQLQueryPager { let initialQuery = Query() initialQuery.__variables = ["id": "2001", "first": 2, "after": GraphQLNullable.null] - return GraphQLQueryPager.Actor( + return AsyncGraphQLQueryPager( client: client, initialQuery: initialQuery, + watcherDispatchQueue: .main, extractPageInfo: { data in switch data { case .initial(let data), .paginated(let data): - return CursorBasedPagination.ForwardPagination( + return CursorBasedPagination.Forward( hasNext: data.hero.friendsConnection.pageInfo.hasNextPage, endCursor: data.hero.friendsConnection.pageInfo.endCursor ) } }, - nextPageResolver: { pageInfo in + pageResolver: { pageInfo, direction in + guard direction == .next else { return nil } let nextQuery = Query() nextQuery.__variables = [ "id": "2001", diff --git a/Tuist/ProjectDescriptionHelpers/Targets/Target+ApolloPaginationTests.swift b/Tuist/ProjectDescriptionHelpers/Targets/Target+ApolloPaginationTests.swift index f9456e53d..9805605ec 100644 --- a/Tuist/ProjectDescriptionHelpers/Targets/Target+ApolloPaginationTests.swift +++ b/Tuist/ProjectDescriptionHelpers/Targets/Target+ApolloPaginationTests.swift @@ -16,11 +16,9 @@ extension Target { ], dependencies: [ .target(name: ApolloTarget.apolloInternalTestHelpers.name), - .target(name: ApolloTarget.starWarsAPI.name), .package(product: "Apollo"), .package(product: "ApolloAPI"), .package(product: "ApolloTestSupport"), - .package(product: "Nimble"), .package(product: "ApolloPagination") ], settings: .forTarget(target) diff --git a/apollo-ios-pagination/Sources/ApolloPagination/AnyAsyncGraphQLQueryPager.swift b/apollo-ios-pagination/Sources/ApolloPagination/AnyAsyncGraphQLQueryPager.swift new file mode 100644 index 000000000..3f76ce184 --- /dev/null +++ b/apollo-ios-pagination/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/apollo-ios-pagination/Sources/ApolloPagination/AnyGraphQLPager.swift b/apollo-ios-pagination/Sources/ApolloPagination/AnyGraphQLPager.swift index 4c6f2de2a..87d717a9d 100644 --- a/apollo-ios-pagination/Sources/ApolloPagination/AnyGraphQLPager.swift +++ b/apollo-ios-pagination/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/apollo-ios-pagination/Sources/ApolloPagination/AsyncGraphQLQueryPager.swift b/apollo-ios-pagination/Sources/ApolloPagination/AsyncGraphQLQueryPager.swift new file mode 100644 index 000000000..04f00a4c2 --- /dev/null +++ b/apollo-ios-pagination/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/apollo-ios-pagination/Sources/ApolloPagination/CursorBasedPagination/BidirectionalPagination.swift b/apollo-ios-pagination/Sources/ApolloPagination/CursorBasedPagination/BidirectionalPagination.swift new file mode 100644 index 000000000..d1d26c8b4 --- /dev/null +++ b/apollo-ios-pagination/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/apollo-ios-pagination/Sources/ApolloPagination/CursorBasedPagination/CursorBasedPagination.swift b/apollo-ios-pagination/Sources/ApolloPagination/CursorBasedPagination/CursorBasedPagination.swift index baa915315..2d920e72b 100644 --- a/apollo-ios-pagination/Sources/ApolloPagination/CursorBasedPagination/CursorBasedPagination.swift +++ b/apollo-ios-pagination/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/apollo-ios-pagination/Sources/ApolloPagination/CursorBasedPagination/ForwardPagination.swift b/apollo-ios-pagination/Sources/ApolloPagination/CursorBasedPagination/ForwardPagination.swift index f42d7b9e5..c39dae1ef 100644 --- a/apollo-ios-pagination/Sources/ApolloPagination/CursorBasedPagination/ForwardPagination.swift +++ b/apollo-ios-pagination/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/apollo-ios-pagination/Sources/ApolloPagination/CursorBasedPagination/ReversePagination.swift b/apollo-ios-pagination/Sources/ApolloPagination/CursorBasedPagination/ReversePagination.swift index 01b6db9d4..a8435815f 100644 --- a/apollo-ios-pagination/Sources/ApolloPagination/CursorBasedPagination/ReversePagination.swift +++ b/apollo-ios-pagination/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/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPager+Convenience.swift b/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPager+Convenience.swift index b825f3cdf..e82667c30 100644 --- a/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPager+Convenience.swift +++ b/apollo-ios-pagination/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/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPager.swift b/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPager.swift index 679042df5..9acf449fb 100644 --- a/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPager.swift +++ b/apollo-ios-pagination/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/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPagerOutput.swift b/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPagerOutput.swift new file mode 100644 index 000000000..3c884e3d1 --- /dev/null +++ b/apollo-ios-pagination/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/apollo-ios-pagination/Sources/ApolloPagination/OffsetBasedPagination/OffsetPagination.swift b/apollo-ios-pagination/Sources/ApolloPagination/OffsetBasedPagination/OffsetPagination.swift index 2be054bd8..0e7f35bea 100644 --- a/apollo-ios-pagination/Sources/ApolloPagination/OffsetBasedPagination/OffsetPagination.swift +++ b/apollo-ios-pagination/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/apollo-ios-pagination/Sources/ApolloPagination/PageExtractionData.swift b/apollo-ios-pagination/Sources/ApolloPagination/PageExtractionData.swift new file mode 100644 index 000000000..8b7b82e49 --- /dev/null +++ b/apollo-ios-pagination/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/apollo-ios-pagination/Sources/ApolloPagination/PaginationDirection.swift b/apollo-ios-pagination/Sources/ApolloPagination/PaginationDirection.swift new file mode 100644 index 000000000..b530f2cc2 --- /dev/null +++ b/apollo-ios-pagination/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/apollo-ios-pagination/Sources/ApolloPagination/PaginationError.swift b/apollo-ios-pagination/Sources/ApolloPagination/PaginationError.swift index e510c50c3..1aa93e526 100644 --- a/apollo-ios-pagination/Sources/ApolloPagination/PaginationError.swift +++ b/apollo-ios-pagination/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/apollo-ios-pagination/Sources/ApolloPagination/PaginationInfo.swift b/apollo-ios-pagination/Sources/ApolloPagination/PaginationInfo.swift index bb676e0a3..22b54bcdd 100644 --- a/apollo-ios-pagination/Sources/ApolloPagination/PaginationInfo.swift +++ b/apollo-ios-pagination/Sources/ApolloPagination/PaginationInfo.swift @@ -1,3 +1,4 @@ public protocol PaginationInfo: Sendable { - var canLoadMore: Bool { get } + var canLoadNext: Bool { get } + var canLoadPrevious: Bool { get } }