diff --git a/Tests/ApolloPaginationTests/AsyncGraphQLQueryPagerTests.swift b/Tests/ApolloPaginationTests/AsyncGraphQLQueryPagerTests.swift index f4f7368d7..144d81cf1 100644 --- a/Tests/ApolloPaginationTests/AsyncGraphQLQueryPagerTests.swift +++ b/Tests/ApolloPaginationTests/AsyncGraphQLQueryPagerTests.swift @@ -26,7 +26,7 @@ final class AsyncGraphQLQueryPagerTests: XCTestCase { initialQuery.__variables = [ "id": "2001", "first": 2, - "after": GraphQLNullable.null + "after": GraphQLNullable.null, ] let pager = AsyncGraphQLQueryPager( client: client, @@ -36,7 +36,7 @@ final class AsyncGraphQLQueryPagerTests: XCTestCase { hasNext: data.hero.friendsConnection.pageInfo.hasNextPage, endCursor: data.hero.friendsConnection.pageInfo.endCursor ) - }, + }, pageResolver: { page, direction in switch direction { case .next: @@ -44,7 +44,7 @@ final class AsyncGraphQLQueryPagerTests: XCTestCase { nextQuery.__variables = [ "id": "2001", "first": 2, - "after": page.endCursor + "after": page.endCursor, ] return nextQuery case .previous: @@ -63,7 +63,7 @@ final class AsyncGraphQLQueryPagerTests: XCTestCase { let secondPageExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) let secondPageFetch = expectation(description: "Second Page") secondPageFetch.expectedFulfillmentCount = 2 - let subscription = pager.sink { value in + let subscription = pager.sink { _ in secondPageFetch.fulfill() } try await pager.loadNext() @@ -82,7 +82,7 @@ final class AsyncGraphQLQueryPagerTests: XCTestCase { initialQuery.__variables = [ "id": "2001", "first": 2, - "after": GraphQLNullable.null + "after": GraphQLNullable.null, ] let pager = AsyncGraphQLQueryPager( client: client, @@ -100,7 +100,7 @@ final class AsyncGraphQLQueryPagerTests: XCTestCase { nextQuery.__variables = [ "id": "2001", "first": 2, - "after": page.endCursor + "after": page.endCursor, ] return nextQuery case .previous: @@ -121,9 +121,8 @@ final class AsyncGraphQLQueryPagerTests: XCTestCase { secondPageFetch.expectedFulfillmentCount = 2 let subscription = pager .compactMap { output -> [ViewModel]? in - guard case .success((let output, _)) = output else { return nil } - let inOrderData = output.previousPages + [output.initialPage] + output.nextPages - let models = inOrderData.flatMap { data in + guard case .success(let output) = output else { return nil } + let models = output.allData.flatMap { data in data.hero.friendsConnection.friends.map { friend in ViewModel(name: friend.name) } } return models @@ -144,7 +143,7 @@ final class AsyncGraphQLQueryPagerTests: XCTestCase { initialQuery.__variables = [ "id": "2001", "first": 2, - "after": GraphQLNullable.null + "after": GraphQLNullable.null, ] let pager = AsyncGraphQLQueryPager( client: client, @@ -162,18 +161,12 @@ final class AsyncGraphQLQueryPagerTests: XCTestCase { nextQuery.__variables = [ "id": "2001", "first": 2, - "after": page.endCursor + "after": page.endCursor, ] return nextQuery case .previous: return nil } - } , - transform: { previous, first, next in - let inOrderData = previous + [first] + next - return inOrderData.flatMap { data in - data.hero.friendsConnection.friends.map { friend in friend.name } - } } ) @@ -187,7 +180,7 @@ final class AsyncGraphQLQueryPagerTests: XCTestCase { let secondPageExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) let secondPageFetch = expectation(description: "Second Page") secondPageFetch.expectedFulfillmentCount = 2 - let subscription = pager.sink { value in + let subscription = pager.sink { _ in secondPageFetch.fulfill() } try await pager.loadNext() @@ -206,7 +199,7 @@ final class AsyncGraphQLQueryPagerTests: XCTestCase { initialQuery.__variables = [ "id": "2001", "first": 2, - "after": GraphQLNullable.null + "after": GraphQLNullable.null, ] let pager = AsyncGraphQLQueryPager( client: client, @@ -224,18 +217,12 @@ final class AsyncGraphQLQueryPagerTests: XCTestCase { nextQuery.__variables = [ "id": "2001", "first": 2, - "after": page.endCursor + "after": page.endCursor, ] return nextQuery case .previous: return nil } - }, - transform: { previous, first, next in - let inOrderData = previous + [first] + next - return inOrderData.flatMap { data in - data.hero.friendsConnection.friends.map { friend in friend.name } - } } ) @@ -252,13 +239,15 @@ final class AsyncGraphQLQueryPagerTests: XCTestCase { let subscription = pager .compactMap { result in switch result { - case .success((let strings, _)): - return strings.map(ViewModel.init(name:)) + case .success(let output): + return output.allData.flatMap { data in + data.hero.friendsConnection.friends.map { friend in friend.name } + }.map(ViewModel.init(name:)) case .failure(let error): XCTFail("Unexpected failure: \(error)") return nil } - }.sink { value in + }.sink { _ in secondPageFetch.fulfill() } try await pager.loadNext() @@ -273,52 +262,63 @@ final class AsyncGraphQLQueryPagerTests: XCTestCase { let name: String } - let anyPager = createPager().eraseToAnyPager { data in - data.hero.friendsConnection.friends.map { - ViewModel(name: $0.name) - } - } + let anyPager = createPager() 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, _)): + let subscriber = anyPager + .compactMap { result in + switch result { + case .success(let data): + return data.allData.flatMap { data in + data.hero.friendsConnection.friends.map { + ViewModel(name: $0.name) + } + } + case .failure(let error): + XCTFail(error.localizedDescription) + return nil + } + }.sink { viewModels in 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) + await fulfillment(of: [subscriptionExpectation], timeout: 1) let results = try XCTUnwrap(expectedViewModels) XCTAssertEqual(results.count, 3) XCTAssertEqual(results.map(\.name), ["Luke Skywalker", "Han Solo", "Leia Organa"]) + subscriber.cancel() } func test_passesBackSeparateData() async throws { - let anyPager = 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 anyPager = createPager() 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 subscriber = anyPager + .compactMap { result in + switch result { + case .success(let output): + if let latestPage = output.nextPages.last { + return latestPage.data?.hero.friendsConnection.friends.last?.name + } + return output.initialPage?.data?.hero.friendsConnection.friends.last?.name + case .failure(let error): + XCTFail(error.localizedDescription) + return nil + } + } + .sink { viewModel in let oldValue = expectedViewModel expectedViewModel = viewModel if oldValue == nil { @@ -326,18 +326,16 @@ final class AsyncGraphQLQueryPagerTests: XCTestCase { } else { secondExpectation.fulfill() } - default: - XCTFail("Failed to get view models from pager.") } - } await fetchFirstPage(pager: anyPager) - await fulfillment(of: [initialExpectation], timeout: 1.0) + await fulfillment(of: [initialExpectation], timeout: 1) XCTAssertEqual(expectedViewModel, "Han Solo") try await fetchSecondPage(pager: anyPager) - await fulfillment(of: [secondExpectation], timeout: 1.0) + await fulfillment(of: [secondExpectation], timeout: 1) XCTAssertEqual(expectedViewModel, "Leia Organa") + subscriber.cancel() } func test_loadAll() async throws { @@ -346,7 +344,7 @@ final class AsyncGraphQLQueryPagerTests: XCTestCase { 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 + let subscriber = pager.sink { _ in loadAllExpectation.fulfill() } try await pager.loadAll() @@ -354,43 +352,96 @@ final class AsyncGraphQLQueryPagerTests: XCTestCase { subscriber.cancel() } - func test_equatable() async { - let pagerA = AsyncGraphQLQueryPager(pager: createPager(), transform: { previous, initial, next in - let allPages = previous + [initial] + next - return allPages.flatMap { data in - data.hero.friendsConnection.friends.map { $0.name } - } - }) - - let pagerB = AsyncGraphQLQueryPager(pager: createPager(), transform: { previous, initial, next in - let allPages = previous + [initial] + next - return allPages.flatMap { data in - data.hero.friendsConnection.friends.map { $0.name } - } - }) - - XCTAssertEqual(pagerA, pagerB) + func test_errors_partialSuccess() async throws { + let pager = createPager() + var expectedResults: [Result, any Error>] = [] + let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPageWithErrors(server: server) + let fetchExpectation = expectation(description: "Fetch") + let subscription = pager.sink { output in + expectedResults.append(output) + fetchExpectation.fulfill() + } + await pager.fetch() + await fulfillment(of: [serverExpectation, fetchExpectation], timeout: 3) + XCTAssertEqual(expectedResults.count, 1) + let result = try XCTUnwrap(expectedResults.first) + let successValue = try result.get() + XCTAssertFalse(successValue.allErrors.isEmpty) + XCTAssertEqual(successValue.initialPage?.data?.hero.name, "R2-D2") + let canLoadNext = await pager.canLoadNext + XCTAssertTrue(canLoadNext) + subscription.cancel() + } - pagerA._subject.send(.success((["Al-Khwarizmi", "Al-Jaziri", "Charles Babbage", "Ada Lovelace"], .cache))) - XCTAssertNotEqual(pagerA, pagerB) + func test_errors_noData() async throws { + let pager = createPager() + var expectedResults: [Result, any Error>] = [] + let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPageErrorsOnly(server: server) + let fetchExpectation = expectation(description: "Fetch") + let subscription = pager.sink { output in + expectedResults.append(output) + fetchExpectation.fulfill() + } + await pager.fetch() + await fulfillment(of: [serverExpectation, fetchExpectation], timeout: 3) + XCTAssertEqual(expectedResults.count, 1) + let result = try XCTUnwrap(expectedResults.first) + let successValue = try result.get() + XCTAssertFalse(successValue.allErrors.isEmpty) + XCTAssertNil(successValue.initialPage?.data) + subscription.cancel() + } - pagerB._subject.send(.success((["Al-Khwarizmi", "Al-Jaziri", "Charles Babbage", "Ada Lovelace"], .cache))) - XCTAssertEqual(pagerA, pagerB) + func test_errors_noData_loadAll() async throws { + let pager = createPager() + var expectedResults: [Result, any Error>] = [] + let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPageErrorsOnly(server: server) + let fetchExpectation = expectation(description: "Fetch") + let subscription = pager.sink { output in + expectedResults.append(output) + XCTAssertEqual(expectedResults.count, 1) + do { + let result = try XCTUnwrap(expectedResults.first) + let successValue = try result.get() + XCTAssertFalse(successValue.allErrors.isEmpty) + XCTAssertNil(successValue.initialPage?.data) + } catch { + XCTFail(error.localizedDescription) + } + fetchExpectation.fulfill() + } + try await pager.loadAll(fetchFromInitialPage: true) + await fulfillment(of: [serverExpectation, fetchExpectation], timeout: 3) + subscription.cancel() + } - pagerA._subject.send(.success((["Al-Khwarizmi", "Al-Jaziri", "Charles Babbage", "Ada Lovelace"], .fetch))) - XCTAssertNotEqual(pagerA, pagerB) + func test_errors_noDataOnSecondPage_loadAll() async throws { + let pager = createPager() + var expectedResults: [Result, any Error>] = [] - pagerB._subject.send(.success((["Al-Khwarizmi", "Al-Jaziri", "Charles Babbage", "Ada Lovelace"], .fetch))) - await pagerA.reset() - XCTAssertEqual(pagerA, pagerB) + let firstPageExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) + let lastPageExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPageErrorsOnly(server: server) + let loadAllExpectation = expectation(description: "Load all pages") + let subscriber = pager.sink { output in + expectedResults.append(output) + loadAllExpectation.fulfill() + } + try await pager.loadAll() + await fulfillment(of: [firstPageExpectation, lastPageExpectation, loadAllExpectation], timeout: 5) + let result = try XCTUnwrap(expectedResults.first) + let successValue = try result.get() + XCTAssertFalse(successValue.allErrors.isEmpty) + XCTAssertNotNil(successValue.initialPage) + XCTAssertNil(successValue.nextPages[0].data) + subscriber.cancel() } // MARK: - Test helpers - private func createPager() -> AsyncGraphQLQueryPagerCoordinator { + private func createPager() -> AsyncGraphQLQueryPager> { let initialQuery = Query() initialQuery.__variables = ["id": "2001", "first": 2, "after": GraphQLNullable.null] - return AsyncGraphQLQueryPagerCoordinator( + return .init(pager: AsyncGraphQLQueryPagerCoordinator( client: client, initialQuery: initialQuery, watcherDispatchQueue: .main, @@ -413,18 +464,18 @@ final class AsyncGraphQLQueryPagerTests: XCTestCase { ] return nextQuery } - ) + )) } private func fetchFirstPage(pager: AsyncGraphQLQueryPager) async { let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) await pager.fetch() - await fulfillment(of: [serverExpectation], timeout: 1.0) + await fulfillment(of: [serverExpectation], timeout: 1) } private func fetchSecondPage(pager: AsyncGraphQLQueryPager) async throws { let serverExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) try await pager.loadNext() - await fulfillment(of: [serverExpectation], timeout: 1.0) + await fulfillment(of: [serverExpectation], timeout: 1) } } diff --git a/Tests/ApolloPaginationTests/BidirectionalPaginationTests.swift b/Tests/ApolloPaginationTests/BidirectionalPaginationTests.swift index c62f29201..e736f63be 100644 --- a/Tests/ApolloPaginationTests/BidirectionalPaginationTests.swift +++ b/Tests/ApolloPaginationTests/BidirectionalPaginationTests.swift @@ -92,7 +92,7 @@ final class BidirectionalPaginationTests: XCTestCase, CacheDependentTesting { let pager = createPager() let serverExpectation = Mocks.Hero.BidirectionalFriendsQuery.expectationForFirstFetchInMiddleOfList(server: server) - var results: [Result<(PaginationOutput, UpdateSource), any Error>] = [] + var results: [Result, any Error>] = [] let firstPageExpectation = expectation(description: "First page") var subscription = await pager.subscribe(onUpdate: { _ in firstPageExpectation.fulfill() @@ -102,11 +102,11 @@ final class BidirectionalPaginationTests: XCTestCase, CacheDependentTesting { subscription.cancel() var result = try await XCTUnwrapping(await pager.currentValue) results.append(result) - XCTAssertSuccessResult(result) { (output, source) in + XCTAssertSuccessResult(result) { output in XCTAssertTrue(output.nextPages.isEmpty) - XCTAssertEqual(output.initialPage.hero.friendsConnection.friends.count, 1) - XCTAssertEqual(output.initialPage.hero.friendsConnection.totalCount, 3) - XCTAssertEqual(source, .fetch) + XCTAssertEqual(output.initialPage?.data?.hero.friendsConnection.friends.count, 1) + XCTAssertEqual(output.initialPage?.data?.hero.friendsConnection.totalCount, 3) + XCTAssertEqual(output.initialPage?.source, .server) } let secondPageExpectation = Mocks.Hero.BidirectionalFriendsQuery.expectationForLastPage(server: server) @@ -123,17 +123,17 @@ final class BidirectionalPaginationTests: XCTestCase, CacheDependentTesting { result = try await XCTUnwrapping(await pager.currentValue) results.append(result) - try XCTAssertSuccessResult(result) { (output, source) in + try XCTAssertSuccessResult(result) { output in // Assert first page is unchanged - XCTAssertEqual(try? results.first?.get().0.initialPage, try? results.last?.get().0.initialPage) + 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(source, .fetch) + XCTAssertEqual(page.data?.hero.friendsConnection.friends.count, 1) + XCTAssertEqual(page.source, .server) } var previousCount = await pager.previousPageVarMap.values.count XCTAssertEqual(previousCount, 0) @@ -155,17 +155,17 @@ final class BidirectionalPaginationTests: XCTestCase, CacheDependentTesting { result = try await XCTUnwrapping(await pager.currentValue) results.append(result) - try XCTAssertSuccessResult(result) { (output, source) in + try XCTAssertSuccessResult(result) { output in // Assert first page is unchanged - XCTAssertEqual(try? results.first?.get().0.initialPage, try? results.last?.get().0.initialPage) + 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(source, .fetch) + XCTAssertEqual(page.data?.hero.friendsConnection.friends.count, 1) + XCTAssertEqual(page.source, .server) } previousCount = await pager.previousPageVarMap.values.count XCTAssertEqual(previousCount, 1) @@ -190,12 +190,14 @@ final class BidirectionalPaginationTests: XCTestCase, CacheDependentTesting { timeout: 5 ) - let (result, _) = try await XCTUnwrapping(try await pager.currentValue?.get()) + let result = try await XCTUnwrapping(try await pager.currentValue?.get()) XCTAssertFalse(result.previousPages.isEmpty) - XCTAssertEqual(result.initialPage.hero.friendsConnection.friends.count, 1) + XCTAssertEqual(result.initialPage?.data?.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 ?? []) + let friends = ( + result.previousPages.compactMap(\.data?.hero.friendsConnection.friends) + + result.nextPages.compactMap(\.data?.hero.friendsConnection.friends) + ).flatMap { $0 } + (result.initialPage?.data?.hero.friendsConnection.friends ?? []) XCTAssertEqual(Set(friends).count, 3) } @@ -206,7 +208,7 @@ final class BidirectionalPaginationTests: XCTestCase, CacheDependentTesting { let pager = GraphQLQueryPagerCoordinator(pager: createPager()) let serverExpectation = Mocks.Hero.BidirectionalFriendsQuery.expectationForFirstFetchInMiddleOfList(server: server) - var results: [Result<(PaginationOutput, UpdateSource), any Error>] = [] + var results: [Result, any Error>] = [] let firstPageExpectation = expectation(description: "First page") var subscription = await pager.publisher.sink { _ in firstPageExpectation.fulfill() @@ -216,11 +218,11 @@ final class BidirectionalPaginationTests: XCTestCase, CacheDependentTesting { subscription.cancel() var result = try await XCTUnwrapping(await pager.pager.currentValue) results.append(result) - XCTAssertSuccessResult(result) { (output, source) in + XCTAssertSuccessResult(result) { output in XCTAssertTrue(output.nextPages.isEmpty) - XCTAssertEqual(output.initialPage.hero.friendsConnection.friends.count, 1) - XCTAssertEqual(output.initialPage.hero.friendsConnection.totalCount, 3) - XCTAssertEqual(source, .fetch) + XCTAssertEqual(output.initialPage?.data?.hero.friendsConnection.friends.count, 1) + XCTAssertEqual(output.initialPage?.data?.hero.friendsConnection.totalCount, 3) + XCTAssertEqual(output.initialPage?.source, .server) } let secondPageExpectation = Mocks.Hero.BidirectionalFriendsQuery.expectationForLastPage(server: server) @@ -237,17 +239,17 @@ final class BidirectionalPaginationTests: XCTestCase, CacheDependentTesting { result = try await XCTUnwrapping(await pager.pager.currentValue) results.append(result) - try XCTAssertSuccessResult(result) { (output, source) in + try XCTAssertSuccessResult(result) { output in // Assert first page is unchanged - XCTAssertEqual(try? results.first?.get().0.initialPage, try? results.last?.get().0.initialPage) + 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(source, .fetch) + XCTAssertEqual(page.data?.hero.friendsConnection.friends.count, 1) + XCTAssertEqual(page.source, .server) } let previousPageExpectation = Mocks.Hero.BidirectionalFriendsQuery.expectationForPreviousPage(server: server) @@ -265,17 +267,17 @@ final class BidirectionalPaginationTests: XCTestCase, CacheDependentTesting { result = try await XCTUnwrapping(await pager.pager.currentValue) results.append(result) - try XCTAssertSuccessResult(result) { (output, source) in + try XCTAssertSuccessResult(result) { output in // Assert first page is unchanged - XCTAssertEqual(try? results.first?.get().0.initialPage, try? results.last?.get().0.initialPage) + 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(source, .fetch) + XCTAssertEqual(page.data?.hero.friendsConnection.friends.count, 1) + XCTAssertEqual(page.source, .server) } } @@ -296,14 +298,15 @@ final class BidirectionalPaginationTests: XCTestCase, CacheDependentTesting { timeout: 5 ) - let result = try await XCTUnwrapping(try await pager.pager.currentValue?.get().0) + let result = try await XCTUnwrapping(try await pager.pager.currentValue?.get()) XCTAssertFalse(result.previousPages.isEmpty) - XCTAssertEqual(result.initialPage.hero.friendsConnection.friends.count, 1) + XCTAssertEqual(result.initialPage?.data?.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 ?? []) + let friends = ( + result.previousPages.compactMap(\.data?.hero.friendsConnection.friends) + + result.nextPages.compactMap(\.data?.hero.friendsConnection.friends) + ).flatMap { $0 } + (result.initialPage?.data?.hero.friendsConnection.friends ?? []) XCTAssertEqual(Set(friends).count, 3) } diff --git a/Tests/ApolloPaginationTests/ConcurrencyTest.swift b/Tests/ApolloPaginationTests/ConcurrencyTest.swift index 7ce0270f1..d42352af2 100644 --- a/Tests/ApolloPaginationTests/ConcurrencyTest.swift +++ b/Tests/ApolloPaginationTests/ConcurrencyTest.swift @@ -137,7 +137,7 @@ final class ConcurrencyTests: XCTestCase { func test_concurrentFetches() async throws { let pager = createPager() - var results: [Result<(PaginationOutput, UpdateSource), any Error>] = [] + var results: [Result, any Error>] = [] let resultsExpectation = expectation(description: "Results arrival") resultsExpectation.expectedFulfillmentCount = 2 await pager.subscribe { result in @@ -160,7 +160,7 @@ final class ConcurrencyTests: XCTestCase { func test_concurrentFetches_nonisolated() throws { let pager = createNonisolatedPager() - var results: [Result<(PaginationOutput, UpdateSource), any Error>] = [] + var results: [Result, any Error>] = [] let initialExpectation = expectation(description: "Initial") initialExpectation.assertForOverFulfill = false let nextExpectation = expectation(description: "Next") diff --git a/Tests/ApolloPaginationTests/ForwardPaginationTests.swift b/Tests/ApolloPaginationTests/ForwardPaginationTests.swift index b10144323..b43d4b36b 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<(PaginationOutput, UpdateSource), any Error>] = [] + var results: [Result, any Error>] = [] let firstPageExpectation = expectation(description: "First page") var subscription = await pager.subscribe(onUpdate: { _ in firstPageExpectation.fulfill() @@ -57,11 +57,11 @@ final class ForwardPaginationTests: XCTestCase, CacheDependentTesting { subscription.cancel() var result = try await XCTUnwrapping(await pager.currentValue) results.append(result) - XCTAssertSuccessResult(result) { (output, source) in + XCTAssertSuccessResult(result) { output in XCTAssertTrue(output.nextPages.isEmpty) - XCTAssertEqual(output.initialPage.hero.friendsConnection.friends.count, 2) - XCTAssertEqual(output.initialPage.hero.friendsConnection.totalCount, 3) - XCTAssertEqual(source, .fetch) + XCTAssertEqual(output.initialPage?.data?.hero.friendsConnection.friends.count, 2) + XCTAssertEqual(output.initialPage?.data?.hero.friendsConnection.totalCount, 3) + XCTAssertEqual(output.initialPage?.source, .server) } let secondPageExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) @@ -78,17 +78,17 @@ final class ForwardPaginationTests: XCTestCase, CacheDependentTesting { result = try await XCTUnwrapping(await pager.currentValue) results.append(result) - try XCTAssertSuccessResult(result) { (output, source) in + try XCTAssertSuccessResult(result) { output in // Assert first page is unchanged - XCTAssertEqual(try? results.first?.get().0.initialPage, try? results.last?.get().0.initialPage) + 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(source, .fetch) + XCTAssertEqual(page.data?.hero.friendsConnection.friends.count, 1) + XCTAssertEqual(page.source, .server) } let previousCount = await pager.previousPageVarMap.values.count XCTAssertEqual(previousCount, 0) @@ -183,11 +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) { (output, source) in + XCTAssertSuccessResult(result) { output in XCTAssertTrue(output.nextPages.isEmpty) - XCTAssertEqual(output.initialPage.hero.friendsConnection.friends.count, 2) - XCTAssertEqual(output.initialPage.hero.friendsConnection.totalCount, 3) - XCTAssertEqual(source, .fetch) + XCTAssertEqual(output.initialPage?.data?.hero.friendsConnection.friends.count, 2) + XCTAssertEqual(output.initialPage?.data?.hero.friendsConnection.totalCount, 3) + XCTAssertEqual(output.initialPage?.source, .server) } let secondPageExpectation = Mocks.Hero.FriendsQuery.expectationForSecondPage(server: server) @@ -201,14 +201,14 @@ final class ForwardPaginationTests: XCTestCase, CacheDependentTesting { await fulfillment(of: [secondPageExpectation, secondPageFetch], timeout: 1) subscription.cancel() let newResult = try await XCTUnwrapping(await pager.currentValue) - try XCTAssertSuccessResult(newResult) { (output, source) in + try XCTAssertSuccessResult(newResult) { output in // Assert first page is unchanged - XCTAssertEqual(try? result.get().0.initialPage, try? newResult.get().0.initialPage) + 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(page.data?.hero.friendsConnection.friends.count, 1) + XCTAssertEqual(page.source, .server) } let count = await pager.nextPageVarMap.values.count XCTAssertEqual(count, 1) @@ -227,10 +227,10 @@ final class ForwardPaginationTests: XCTestCase, CacheDependentTesting { } await fulfillment(of: [transactionExpectation, mutationExpectation]) let finalResult = try await XCTUnwrapping(await pager.currentValue) - XCTAssertSuccessResult(finalResult) { (output, _) in - XCTAssertEqual(output.initialPage.hero.name, "C3PO") + XCTAssertSuccessResult(finalResult) { output in + XCTAssertEqual(output.initialPage?.data?.hero.name, "C3PO") XCTAssertEqual(output.nextPages.count, 1) - XCTAssertEqual(output.nextPages.first?.hero.name, "C3PO") + XCTAssertEqual(output.nextPages.first?.data?.hero.name, "C3PO") } } diff --git a/Tests/ApolloPaginationTests/FriendsQuery+TestHelpers.swift b/Tests/ApolloPaginationTests/FriendsQuery+TestHelpers.swift index bfa4ecbb3..fed0b74f4 100644 --- a/Tests/ApolloPaginationTests/FriendsQuery+TestHelpers.swift +++ b/Tests/ApolloPaginationTests/FriendsQuery+TestHelpers.swift @@ -87,6 +87,93 @@ extension Mocks.Hero.FriendsQuery { ] } } + + static func expectationForFirstPageWithErrors(server: MockGraphQLServer) -> XCTestExpectation { + 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==", + "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, + "errors": [ + [ + "message": "uh oh!" + ], + [ + "message": "Some error" + ], + ], + ] + } + } + + static func expectationForFirstPageErrorsOnly(server: MockGraphQLServer) -> XCTestExpectation { + let query = MockQuery() + query.__variables = ["id": "2001", "first": 2, "after": GraphQLNullable.null] + return server.expect(query) { _ in + return [ + "errors": [ + [ + "message": "uh oh!" + ], + [ + "message": "Some error" + ], + ], + ] + } + } + + static func expectationForSecondPageErrorsOnly(server: MockGraphQLServer) -> XCTestExpectation { + let query = MockQuery() + query.__variables = ["id": "2001", "first": 2, "after": "Y3Vyc29yMg=="] + return server.expect(query) { _ in + return [ + "errors": [ + [ + "message": "uh oh!" + ], + [ + "message": "Some error" + ], + ], + ] + } + } } extension Mocks.Hero.ReverseFriendsQuery { diff --git a/Tests/ApolloPaginationTests/GraphQLQueryPagerCoordinatorTests.swift b/Tests/ApolloPaginationTests/GraphQLQueryPagerCoordinatorTests.swift index fe8531e27..da3267db4 100644 --- a/Tests/ApolloPaginationTests/GraphQLQueryPagerCoordinatorTests.swift +++ b/Tests/ApolloPaginationTests/GraphQLQueryPagerCoordinatorTests.swift @@ -96,7 +96,7 @@ final class GraphQLQueryPagerCoordinatorTests: XCTestCase, CacheDependentTesting server.customDelay = .milliseconds(1) let pager = GraphQLQueryPagerCoordinator(pager: createForwardPager()) let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) - var results: [Result<(PaginationOutput, UpdateSource), any Error>] = [] + var results: [Result, any Error>] = [] var errors: [PaginationError?] = [] pager.fetch() @@ -127,7 +127,7 @@ final class GraphQLQueryPagerCoordinatorTests: XCTestCase, CacheDependentTesting server.customDelay = .milliseconds(1) var pager: GraphQLQueryPagerCoordinator! = GraphQLQueryPagerCoordinator(pager: createForwardPager()) let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) - var results: [Result<(PaginationOutput, UpdateSource), any Error>] = [] + var results: [Result, any Error>] = [] var errors: [PaginationError?] = [] pager.fetch() diff --git a/Tests/ApolloPaginationTests/GraphQLQueryPagerTests.swift b/Tests/ApolloPaginationTests/GraphQLQueryPagerTests.swift index 2b3bdfd2e..075f026d2 100644 --- a/Tests/ApolloPaginationTests/GraphQLQueryPagerTests.swift +++ b/Tests/ApolloPaginationTests/GraphQLQueryPagerTests.swift @@ -31,10 +31,10 @@ final class GraphQLQueryPagerTests: XCTestCase { // MARK: - Test helpers - private func createPager() -> GraphQLQueryPagerCoordinator { + private func createPager() -> GraphQLQueryPager> { let initialQuery = Query() initialQuery.__variables = ["id": "2001", "first": 2, "after": GraphQLNullable.null] - return GraphQLQueryPagerCoordinator( + return .init(pager: GraphQLQueryPagerCoordinator( client: client, initialQuery: initialQuery, watcherDispatchQueue: .main, @@ -57,13 +57,13 @@ final class GraphQLQueryPagerTests: XCTestCase { ] return nextQuery } - ) + )) } - private func createReversePager() -> GraphQLQueryPagerCoordinator { + private func createReversePager() -> GraphQLQueryPager> { let initialQuery = ReverseQuery() initialQuery.__variables = ["id": "2001", "first": 2, "before": "Y3Vyc29yMw=="] - return GraphQLQueryPagerCoordinator( + return .init(pager: GraphQLQueryPagerCoordinator( client: client, initialQuery: initialQuery, watcherDispatchQueue: .main, @@ -86,8 +86,7 @@ final class GraphQLQueryPagerTests: XCTestCase { ] return nextQuery } - ) - + )) } // This is due to a timing issue in unit tests only wherein we deinit immediately after waiting for expectations @@ -130,26 +129,29 @@ final class GraphQLQueryPagerTests: XCTestCase { let name: String } - let anyPager = createPager().eraseToAnyPager { data in - data.hero.friendsConnection.friends.map { - ViewModel(name: $0.name) - } - } + let anyPager = createPager() 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 + let subscription = anyPager.compactMap { result in switch result { - case .success((let viewModels, _)): - expectedViewModels = viewModels - fetchExpectation.fulfill() - subscriptionExpectation.fulfill() - default: - XCTFail("Failed to get view models from pager.") + case .success(let data): + return data.allData.flatMap { data in + data.hero.friendsConnection.friends.map { + ViewModel(name: $0.name) + } + } + case .failure(let error): + XCTFail(error.localizedDescription) + return nil } + }.sink { viewModels in + expectedViewModels = viewModels + fetchExpectation.fulfill() + subscriptionExpectation.fulfill() } fetchFirstPage(pager: anyPager) @@ -160,53 +162,16 @@ final class GraphQLQueryPagerTests: XCTestCase { let results = try XCTUnwrap(expectedViewModels) XCTAssertEqual(results.count, 3) XCTAssertEqual(results.map(\.name), ["Luke Skywalker", "Han Solo", "Leia Organa"]) - } - - func test_publisher() throws { - struct ViewModel { - let name: String - } - - let anyPager = 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.sink(receiveValue: { value in - switch value { - case .success((let viewModels, _)): - expectedViewModels = viewModels - fetchExpectation.fulfill() - subscriptionExpectation.fulfill() - default: - XCTFail("Failed to get view models from pager.") - } - }).store(in: &subscriptions) - - fetchFirstPage(pager: anyPager) - wait(for: [fetchExpectation], timeout: 1) - try fetchSecondPage(pager: anyPager) - - wait(for: [subscriptionExpectation], timeout: 1.0) - let results = try XCTUnwrap(expectedViewModels) - XCTAssertEqual(results.count, 3) - XCTAssertEqual(results.map(\.name), ["Luke Skywalker", "Han Solo", "Leia Organa"]) + subscription.cancel() } func test_transformless_init() throws { - let pager = GraphQLQueryPager(pager: createPager()) + let pager = createPager() let fetchExpectation = expectation(description: "Initial Fetch") var expectedViewModels: [PaginationOutput] = [] pager.sink { result in switch result { - case .success((let value, _)): + case .success(let value): expectedViewModels.append(value) fetchExpectation.fulfill() default: @@ -221,19 +186,23 @@ final class GraphQLQueryPagerTests: XCTestCase { } func test_passesBackSeparateData() throws { - let anyPager = 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 anyPager = createPager() let initialExpectation = expectation(description: "Initial") let secondExpectation = expectation(description: "Second") var expectedViewModel: String? - anyPager.sink { result in - switch result { - case .success((let viewModel, _)): + anyPager + .map { result in + switch result { + case .success(let output): + return output.allData.last.flatMap(\.hero.friendsConnection.friends.last?.name) + case .failure(let error): + XCTFail(error.localizedDescription) + return nil + } + } + .receive(on: RunLoop.main) + .sink { viewModel in let oldValue = expectedViewModel expectedViewModel = viewModel if oldValue == nil { @@ -241,10 +210,7 @@ final class GraphQLQueryPagerTests: XCTestCase { } else { secondExpectation.fulfill() } - default: - XCTFail("Failed to get view models from pager.") - } - }.store(in: &subscriptions) + }.store(in: &subscriptions) fetchFirstPage(pager: anyPager) wait(for: [initialExpectation], timeout: 1.0) @@ -260,19 +226,26 @@ final class GraphQLQueryPagerTests: XCTestCase { } 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 anyPager = createReversePager() 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 subscriber = anyPager + .compactMap { result in + switch result { + case .success(let output): + if let latestPage = output.previousPages.last { + return latestPage.data?.hero.friendsConnection.friends.first?.name + } + return output.initialPage?.data?.hero.friendsConnection.friends.first?.name + case .failure(let error): + XCTFail(error.localizedDescription) + return nil + } + } + .receive(on: RunLoop.main) + .sink { viewModel in let oldValue = expectedViewModel expectedViewModel = viewModel if oldValue == nil { @@ -280,10 +253,7 @@ final class GraphQLQueryPagerTests: XCTestCase { } else { secondExpectation.fulfill() } - default: - XCTFail("Failed to get view models from pager.") } - } reverseFetchLastPage(pager: anyPager) wait(for: [initialExpectation], timeout: 1.0) @@ -296,6 +266,7 @@ final class GraphQLQueryPagerTests: XCTestCase { XCTAssertEqual(expectedViewModel, "Luke Skywalker") XCTAssertFalse(anyPager.canLoadNext) XCTAssertFalse(anyPager.canLoadPrevious) + subscriber.cancel() } // MARK: - Reset Tests @@ -303,12 +274,7 @@ final class GraphQLQueryPagerTests: XCTestCase { @available(iOS 16.0, macOS 13.0, *) func test_pager_reset_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 - } - return initial.hero.friendsConnection.friends.last?.name - } + let pager = createPager() let serverExpectation = Mocks.Hero.FriendsQuery.expectationForFirstPage(server: server) pager.fetch() @@ -323,36 +289,4 @@ final class GraphQLQueryPagerTests: XCTestCase { pager.reset() await fulfillment(of: [callbackExpectation, secondPageExpectation], timeout: 1) } - - func test_equatable() { - let pagerA = GraphQLQueryPager(pager: createPager(), transform: { previous, initial, next in - let allPages = previous + [initial] + next - return allPages.flatMap { data in - data.hero.friendsConnection.friends.map { $0.name } - } - }) - - let pagerB = GraphQLQueryPager(pager: createPager(), transform: { previous, initial, next in - let allPages = previous + [initial] + next - return allPages.flatMap { data in - data.hero.friendsConnection.friends.map { $0.name } - } - }) - - XCTAssertEqual(pagerA, pagerB) - - pagerA._subject.send(.success((["Al-Khwarizmi", "Al-Jaziri", "Charles Babbage", "Ada Lovelace"], .cache))) - XCTAssertNotEqual(pagerA, pagerB) - - pagerB._subject.send(.success((["Al-Khwarizmi", "Al-Jaziri", "Charles Babbage", "Ada Lovelace"], .cache))) - XCTAssertEqual(pagerA, pagerB) - - pagerA._subject.send(.success((["Al-Khwarizmi", "Al-Jaziri", "Charles Babbage", "Ada Lovelace"], .fetch))) - XCTAssertNotEqual(pagerA, pagerB) - - pagerB._subject.send(.success((["Al-Khwarizmi", "Al-Jaziri", "Charles Babbage", "Ada Lovelace"], .fetch))) - pagerA.reset() - XCTAssertEqual(pagerA, pagerB) - } - } diff --git a/Tests/ApolloPaginationTests/OffsetTests.swift b/Tests/ApolloPaginationTests/OffsetTests.swift index 81a442c38..e917ca6fd 100644 --- a/Tests/ApolloPaginationTests/OffsetTests.swift +++ b/Tests/ApolloPaginationTests/OffsetTests.swift @@ -36,8 +36,7 @@ final class OffsetTests: XCTestCase { case .initial(let data, let output), .paginated(let data, let output): var totalOffset: Int = 0 if let output { - let pages = (output.previousPages + [output.initialPage] + output.nextPages) - pages.forEach { page in + output.allData.forEach { page in totalOffset += page.hero.friends.count } } @@ -53,7 +52,7 @@ final class OffsetTests: XCTestCase { nextQuery.__variables = [ "id": "2001", "offset": pageInfo.offset, - "limit": pageSize + "limit": pageSize, ] return nextQuery } @@ -70,7 +69,6 @@ final class OffsetTests: XCTestCase { } } - private func fetchFirstPage(pager: AsyncGraphQLQueryPager) async { let serverExpectation = Mocks.Hero.OffsetFriendsQuery.expectationForFirstPage(server: server) await pager.fetch() @@ -94,10 +92,8 @@ final class OffsetTests: XCTestCase { var results: [ViewModel]? let cancellable = pager.map { value in switch value { - case .success((let output, _)): - let pages = output.previousPages + [output.initialPage] + output.nextPages - - let friends = pages.flatMap { data in + case .success(let output): + let friends = output.allData.flatMap { data in data.hero.friends.map { friend in ViewModel(name: friend.name) } diff --git a/Tests/ApolloPaginationTests/PagerCoordinator+Erase.swift b/Tests/ApolloPaginationTests/PagerCoordinator+Erase.swift deleted file mode 100644 index 38eccd0f6..000000000 --- a/Tests/ApolloPaginationTests/PagerCoordinator+Erase.swift +++ /dev/null @@ -1,62 +0,0 @@ -@testable import ApolloPagination - -extension GraphQLQueryPagerCoordinator { - func eraseToAnyPager( - transform: @escaping ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> T - ) -> GraphQLQueryPager { - GraphQLQueryPager(pager: self, transform: transform) - } - - func eraseToAnyPager( - initialTransform: @escaping (InitialQuery.Data) throws -> S, - nextPageTransform: @escaping (PaginatedQuery.Data) throws -> S - ) -> GraphQLQueryPager where T == S.Element { - GraphQLQueryPager( - pager: self, - initialTransform: initialTransform, - pageTransform: nextPageTransform - ) - } - - func eraseToAnyPager( - transform: @escaping (InitialQuery.Data) throws -> S - ) -> GraphQLQueryPager where InitialQuery == PaginatedQuery, T == S.Element { - GraphQLQueryPager( - pager: self, - initialTransform: transform, - pageTransform: transform - ) - } -} - -extension AsyncGraphQLQueryPagerCoordinator { - nonisolated func eraseToAnyPager( - transform: @escaping ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> T - ) -> AsyncGraphQLQueryPager { - AsyncGraphQLQueryPager( - pager: self, - transform: transform - ) - } - - nonisolated func eraseToAnyPager( - initialTransform: @escaping (InitialQuery.Data) throws -> S, - pageTransform: @escaping (PaginatedQuery.Data) throws -> S - ) -> AsyncGraphQLQueryPager where T == S.Element { - AsyncGraphQLQueryPager( - pager: self, - initialTransform: initialTransform, - pageTransform: pageTransform - ) - } - - nonisolated func eraseToAnyPager( - transform: @escaping (InitialQuery.Data) throws -> S - ) -> AsyncGraphQLQueryPager where InitialQuery == PaginatedQuery, T == S.Element { - AsyncGraphQLQueryPager( - pager: self, - initialTransform: transform, - pageTransform: transform - ) - } -} diff --git a/Tests/ApolloPaginationTests/ReversePaginationTests.swift b/Tests/ApolloPaginationTests/ReversePaginationTests.swift index 83f76d62d..367abbcb1 100644 --- a/Tests/ApolloPaginationTests/ReversePaginationTests.swift +++ b/Tests/ApolloPaginationTests/ReversePaginationTests.swift @@ -47,7 +47,7 @@ final class ReversePaginationTests: XCTestCase, CacheDependentTesting { let serverExpectation = Mocks.Hero.ReverseFriendsQuery.expectationForLastItem(server: server) - var results: [Result<(PaginationOutput, UpdateSource), any Error>] = [] + var results: [Result, any Error>] = [] let firstPageExpectation = expectation(description: "First page") var subscription = await pager.subscribe(onUpdate: { _ in firstPageExpectation.fulfill() @@ -57,11 +57,11 @@ final class ReversePaginationTests: XCTestCase, CacheDependentTesting { subscription.cancel() var result = try await XCTUnwrapping(await pager.currentValue) results.append(result) - XCTAssertSuccessResult(result) { (output, source) in + XCTAssertSuccessResult(result) { output in XCTAssertTrue(output.nextPages.isEmpty) - XCTAssertEqual(output.initialPage.hero.friendsConnection.friends.count, 2) - XCTAssertEqual(output.initialPage.hero.friendsConnection.totalCount, 3) - XCTAssertEqual(source, .fetch) + XCTAssertEqual(output.initialPage?.data?.hero.friendsConnection.friends.count, 2) + XCTAssertEqual(output.initialPage?.data?.hero.friendsConnection.totalCount, 3) + XCTAssertEqual(output.initialPage?.source, .server) } let secondPageExpectation = Mocks.Hero.ReverseFriendsQuery.expectationForPreviousItem(server: server) @@ -78,17 +78,17 @@ final class ReversePaginationTests: XCTestCase, CacheDependentTesting { result = try await XCTUnwrapping(await pager.currentValue) results.append(result) - try XCTAssertSuccessResult(result) { (output, source) in + try XCTAssertSuccessResult(result) { output in // Assert first page is unchanged - XCTAssertEqual(try? results.first?.get().0.initialPage, try? results.last?.get().0.initialPage) + 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(source, .fetch) + XCTAssertEqual(page.data?.hero.friendsConnection.friends.count, 1) + XCTAssertEqual(page.source, .server) } let previousCount = await pager.previousPageVarMap.values.count XCTAssertEqual(previousCount, 1) diff --git a/Tests/ApolloPaginationTests/SubscribeTests.swift b/Tests/ApolloPaginationTests/SubscribeTests.swift index 552be1376..76336b7bb 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<(PaginationOutput, UpdateSource), any Error>] = [] - var otherResults: [Result<(PaginationOutput, UpdateSource), any Error>] = [] + var results: [Result, any Error>] = [] + var otherResults: [Result, any Error>] = [] await pager.$currentValue.compactMap({ $0 }).sink { result in results.append(result) initialFetchExpectation.fulfill() @@ -62,11 +62,11 @@ final class SubscribeTest: XCTestCase, CacheDependentTesting { await fulfillment(of: [serverExpectation, initialFetchExpectation], timeout: 1.0) XCTAssertFalse(results.isEmpty) let result = try XCTUnwrap(results.first) - XCTAssertSuccessResult(result) { (output, source) in + XCTAssertSuccessResult(result) { output in XCTAssertTrue(output.nextPages.isEmpty) - XCTAssertEqual(output.initialPage.hero.friendsConnection.friends.count, 2) - XCTAssertEqual(output.initialPage.hero.friendsConnection.totalCount, 3) - XCTAssertEqual(source, .fetch) + XCTAssertEqual(output.initialPage?.data?.hero.friendsConnection.friends.count, 2) + XCTAssertEqual(output.initialPage?.data?.hero.friendsConnection.totalCount, 3) + XCTAssertEqual(output.initialPage?.source, .server) XCTAssertEqual(results.count, otherResults.count) } } diff --git a/apollo-ios-pagination/Package.swift b/apollo-ios-pagination/Package.swift index db7da8aad..99f0db844 100644 --- a/apollo-ios-pagination/Package.swift +++ b/apollo-ios-pagination/Package.swift @@ -8,7 +8,7 @@ let package = Package( .iOS(.v13), .macOS(.v10_15), .tvOS(.v13), - .watchOS(.v6) + .watchOS(.v6), ], products: [ .library(name: "ApolloPagination", targets: ["ApolloPagination"]), diff --git a/apollo-ios-pagination/Sources/ApolloPagination/AsyncGraphQLQueryPager.swift b/apollo-ios-pagination/Sources/ApolloPagination/AsyncGraphQLQueryPager.swift index eeea21506..52d07378f 100644 --- a/apollo-ios-pagination/Sources/ApolloPagination/AsyncGraphQLQueryPager.swift +++ b/apollo-ios-pagination/Sources/ApolloPagination/AsyncGraphQLQueryPager.swift @@ -6,7 +6,7 @@ import Foundation /// Type-erases a query pager, transforming data from a generic type to a specific type, often a view model or array of view models. public class AsyncGraphQLQueryPager: Publisher { public typealias Failure = Never - public typealias Output = Result<(Model, UpdateSource), any Error> + public typealias Output = Result let _subject: CurrentValueSubject = .init(nil) var publisher: AnyPublisher { _subject.compactMap({ $0 }).eraseToAnyPublisher() } @Atomic public var cancellables: Set = [] @@ -17,7 +17,7 @@ public class AsyncGraphQLQueryPager: Publisher { init, InitialQuery, PaginatedQuery>( pager: Pager, - transform: @escaping ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> Model + transform: @escaping (PaginationOutput) throws -> Model ) { self.pager = pager Task { @@ -26,10 +26,10 @@ public class AsyncGraphQLQueryPager: Publisher { let returnValue: Output switch result { - case let .success((output, source)): + case let .success(output): do { - let transformedModels = try transform(output.previousPages, output.initialPage, output.nextPages) - returnValue = .success((transformedModels, source)) + let transformedModels = try transform(output) + returnValue = .success(transformedModels) } catch { returnValue = .failure(error) } @@ -50,89 +50,19 @@ public class AsyncGraphQLQueryPager: Publisher { Task { let cancellable = await pager.subscribe { [weak self] result in guard let self else { return } - let returnValue: Output - - switch result { - case let .success((output, source)): - returnValue = .success((output, source)) - case let .failure(error): - returnValue = .failure(error) - } - - _subject.send(returnValue) + _subject.send(result) } _ = $cancellables.mutate { $0.insert(cancellable) } } } - convenience init< - Pager: AsyncGraphQLQueryPagerCoordinator, - InitialQuery, - PaginatedQuery, - Element - >( - pager: Pager, - initialTransform: @escaping (InitialQuery.Data) throws -> Model, - pageTransform: @escaping (PaginatedQuery.Data) throws -> Model - ) where Model: RangeReplaceableCollection, Model.Element == Element { - self.init( - pager: pager, - transform: { previousData, initialData, nextData in - let previous = try previousData.flatMap { try pageTransform($0) } - let initial = try initialTransform(initialData) - let next = try nextData.flatMap { try pageTransform($0) } - return previous + initial + next - } - ) - } - - public convenience init< - P: PaginationInfo, - InitialQuery: GraphQLQuery, - PaginatedQuery: GraphQLQuery, - Element - >( - client: any ApolloClientProtocol, - initialQuery: InitialQuery, - watcherDispatchQueue: DispatchQueue = .main, - extractPageInfo: @escaping (PageExtractionData) -> P, - pageResolver: ((P, PaginationDirection) -> PaginatedQuery?)?, - initialTransform: @escaping (InitialQuery.Data) throws -> Model, - pageTransform: @escaping (PaginatedQuery.Data) throws -> Model - ) where Model: RangeReplaceableCollection, Model.Element == Element { - let pager = AsyncGraphQLQueryPagerCoordinator( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: { data in - switch data { - case .initial(let data, let output): - return extractPageInfo(.initial(data, convertOutput(result: output))) - case .paginated(let data, let output): - return extractPageInfo(.paginated(data, convertOutput(result: output))) - } - }, - pageResolver: pageResolver - ) - self.init( - pager: pager, - initialTransform: initialTransform, - pageTransform: pageTransform - ) - - func convertOutput(result: PaginationOutput?) -> Model? { - guard let result else { return nil } - - let transform: ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> Model = { previousData, initialData, nextData in - let previous = try previousData.flatMap { try pageTransform($0) } - let initial = try initialTransform(initialData) - let next = try nextData.flatMap { try pageTransform($0) } - return previous + initial + next - } - return try? transform(result.previousPages, result.initialPage, result.nextPages) - } - } - + /// Initialize an `AsyncGraphQLQueryPager` that outputs a `PaginationOutput`. + /// - Parameters: + /// - client: Apollo client type + /// - initialQuery: The query to call for the first page of pagination. May be a separate type of query than the pagination query. + /// - watcherDispatchQueue: The queue that the underlying `GraphQLQueryWatcher`s respond on. Defaults to `main`. + /// - extractPageInfo: A user-input closure that instructs the pager on how to extract `P`, a `PaginationInfo` type, from the `Data` of either the `InitialQuery` or `PaginatedQuery`. + /// - pageResolver: A user-input closure that instructs the pager on how to create a new `PaginatedQuery` given a `PaginationInfo` and a `PaginationDirection`. public convenience init< P: PaginationInfo, InitialQuery: GraphQLQuery, @@ -151,11 +81,17 @@ public class AsyncGraphQLQueryPager: Publisher { extractPageInfo: extractPageInfo, pageResolver: pageResolver ) - self.init( - pager: pager - ) + self.init(pager: pager) } + /// Initialize an `AsyncGraphQLQueryPager` that outputs a user-defined `Model`, the result of the `transform` argument. + /// - Parameters: + /// - client: Apollo client type + /// - initialQuery: The query to call for the first page of pagination. May be a separate type of query than the pagination query. + /// - watcherDispatchQueue: The queue that the underlying `GraphQLQueryWatcher`s respond on. Defaults to `main`. + /// - extractPageInfo: A user-input closure that instructs the pager on how to extract `P`, a `PaginationInfo` type, from the `Data` of either the `InitialQuery` or `PaginatedQuery`. + /// - pageResolver: A user-input closure that instructs the pager on how to create a new `PaginatedQuery` given a `PaginationInfo` and a `PaginationDirection`. + /// - transform: Transforms the `PaginationOutput` into a `Model` type. public convenience init< P: PaginationInfo, InitialQuery: GraphQLQuery, @@ -164,38 +100,23 @@ public class AsyncGraphQLQueryPager: Publisher { client: any ApolloClientProtocol, initialQuery: InitialQuery, watcherDispatchQueue: DispatchQueue = .main, - extractPageInfo: @escaping (PageExtractionData) -> P, + extractPageInfo: @escaping (PageExtractionData?>) -> P, pageResolver: ((P, PaginationDirection) -> PaginatedQuery?)?, - transform: @escaping ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> Model + transform: @escaping (PaginationOutput) throws -> Model ) { let pager = AsyncGraphQLQueryPagerCoordinator( client: client, initialQuery: initialQuery, watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: { data in - switch data { - case .initial(let data, let output): - return extractPageInfo(.initial(data, convertOutput(result: output))) - case .paginated(let data, let output): - return extractPageInfo(.paginated(data, convertOutput(result: output))) - } - }, + extractPageInfo: extractPageInfo, pageResolver: pageResolver ) - self.init( - pager: pager, - transform: transform - ) - - func convertOutput(result: PaginationOutput?) -> Model? { - guard let result else { return nil } - return try? transform(result.previousPages, result.initialPage, result.nextPages) - } + self.init(pager: pager, transform: transform) } - /// Subscribe to the results of the pager, with the management of the subscriber being stored internally to the `AnyGraphQLQueryPager`. /// - Parameter completion: The closure to trigger when new values come in. + @available(*, deprecated, message: "Will be removed in a future version of ApolloPagination. Use the `Combine` publishers instead. If you need to dispatch to the main thread, make sure to use a `.receive(on: RunLoop.main)` as part of your `Combine` operation.") public func subscribe(completion: @MainActor @escaping (Output) -> Void) { let cancellable = publisher.sink { result in Task { await completion(result) } @@ -221,7 +142,7 @@ public class AsyncGraphQLQueryPager: Publisher { try await pager.loadPrevious(cachePolicy: cachePolicy) } - /// Loads all pages. + /// Loads all pages. Does not output a value until all pages have loaded. /// - Parameters: /// - fetchFromInitialPage: Pass true to begin loading from the initial page; otherwise pass false. Defaults to `true`. **NOTE**: Loading all pages with this value set to `false` requires that the initial page has already been loaded previously. public func loadAll( @@ -243,30 +164,12 @@ public class AsyncGraphQLQueryPager: Publisher { /// Resets pagination state and cancels in-flight updates from the pager. public func reset() async { - await pager.reset() + await pager.reset() } public func receive( subscriber: S - ) where S: Subscriber, Never == S.Failure, Result<(Model, UpdateSource), any Error> == S.Input { + ) where S: Subscriber, Never == S.Failure, Result == S.Input { publisher.subscribe(subscriber) } } - -extension AsyncGraphQLQueryPager: Equatable where Model: Equatable { - public static func == (lhs: AsyncGraphQLQueryPager, rhs: AsyncGraphQLQueryPager) -> Bool { - let left = lhs._subject.value - let right = rhs._subject.value - - switch (left, right) { - case (.success((let leftValue, let leftSource)), .success((let rightValue, let rightSource))): - return leftValue == rightValue && leftSource == rightSource - case (.failure(let leftError), .failure(let rightError)): - return leftError.localizedDescription == rightError.localizedDescription - case (.none, .none): - return true - default: - return false - } - } -} diff --git a/apollo-ios-pagination/Sources/ApolloPagination/AsyncGraphQLQueryPagerCoordinator.swift b/apollo-ios-pagination/Sources/ApolloPagination/AsyncGraphQLQueryPagerCoordinator.swift index a6ddf8d06..c919bcbd4 100644 --- a/apollo-ios-pagination/Sources/ApolloPagination/AsyncGraphQLQueryPagerCoordinator.swift +++ b/apollo-ios-pagination/Sources/ApolloPagination/AsyncGraphQLQueryPagerCoordinator.swift @@ -35,20 +35,20 @@ actor AsyncGraphQLQueryPagerCoordinator, PaginatedQuery.Data>>.Publisher, - initialPageResult: Published.Publisher, - nextPageVarMap: Published, PaginatedQuery.Data>>.Publisher + previousPageVarMap: Published, GraphQLResult>>.Publisher, + initialPageResult: Published?>.Publisher, + nextPageVarMap: Published, GraphQLResult>>.Publisher ) { return ($previousPageVarMap, $initialPageResult, $nextPageVarMap) } - typealias ResultType = Result<(PaginationOutput, UpdateSource), any Error> + typealias ResultType = Result, any Error> @Published var currentValue: ResultType? private var queuedValue: ResultType? - @Published var initialPageResult: InitialQuery.Data? - var latest: (previous: [PaginatedQuery.Data], initial: InitialQuery.Data, next: [PaginatedQuery.Data])? { + @Published var initialPageResult: GraphQLResult? + var latest: (previous: [GraphQLResult], initial: GraphQLResult, next: [GraphQLResult])? { guard let initialPageResult else { return nil } return ( Array(previousPageVarMap.values).reversed(), @@ -58,8 +58,8 @@ actor AsyncGraphQLQueryPagerCoordinator, PaginatedQuery.Data> = [:] - @Published var previousPageVarMap: OrderedDictionary, PaginatedQuery.Data> = [:] + @Published var nextPageVarMap: OrderedDictionary, GraphQLResult> = [:] + @Published var previousPageVarMap: OrderedDictionary, GraphQLResult> = [:] private var tasks: Set> = [] private var taskGroup: ThrowingTaskGroup? private var watcherCallbackQueue: DispatchQueue @@ -136,7 +136,7 @@ actor AsyncGraphQLQueryPagerCoordinator, UpdateSource), any Error>) -> Void + onUpdate: @escaping (Result, any Error>) -> Void ) -> AnyCancellable { $currentValue.compactMap({ $0 }) - .sink { [weak self] result in - Task { [weak self] in - guard let self else { return } - let isLoadingAll = await self.isLoadingAll - guard !isLoadingAll else { return } - onUpdate(result) + .flatMap { [weak self] result in + Future, any Error>?, Never> { [weak self] promise in + Task { [weak self] in + guard let self else { return } + let isLoadingAll = await self.isLoadingAll + guard !isLoadingAll else { return promise(.success(nil)) } + promise(.success(result)) + } } } + .sink { (result: Result, any Error>?) in + result.flatMap(onUpdate) + } } /// Reloads all data, starting at the first query, resetting pagination state. @@ -298,11 +303,6 @@ actor AsyncGraphQLQueryPagerCoordinator, UpdateSource), any Error>? - var output: ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data])? + var value: Result, any Error>? + var output: PaginationOutput? + var didFail = false switch fetchType { case .initial: - guard let pageData = pageData as? InitialQuery.Data else { return } - initialPageResult = pageData - if let latest { - output = (latest.previous, pageData, latest.next) + initialPageResult = data as? GraphQLResult + output = initialPageResult.flatMap { result in + .init( + previousPages: latest?.previous ?? [], + initialPage: latest?.initial, + nextPages: latest?.next ?? [], + lastUpdatedPage: .initial(result) + ) + } + if initialPageResult?.data == nil { + didFail = true } case .paginated(let direction, let query): - guard let pageData = pageData as? PaginatedQuery.Data else { return } - let variables = Set(query.__variables?.underlyingJson ?? []) + let underlyingData = data.data as? PaginatedQuery.Data switch direction { case .next: - nextPageVarMap[variables] = pageData + nextPageVarMap[variables] = data as? GraphQLResult case .previous: - previousPageVarMap[variables] = pageData + previousPageVarMap[variables] = data as? GraphQLResult } - if let latest { - output = latest + if let latest, let paginatedResult = data as? GraphQLResult { + output = .init( + previousPages: latest.previous, + initialPage: latest.initial, + nextPages: latest.next, + lastUpdatedPage: .paginated(paginatedResult) + ) + } + if underlyingData == nil { + didFail = true } } - value = output.flatMap { previousPages, initialPage, nextPages in - Result.success(( - PaginationOutput( - previousPages: previousPages, - initialPage: initialPage, - nextPages: nextPages - ), - data.source == .cache ? .cache : .fetch - )) + value = output.flatMap { paginationOutput in + Result.success(paginationOutput) } if let value { @@ -353,24 +361,27 @@ actor AsyncGraphQLQueryPagerCoordinator (any PaginationInfo)? { - let currentValue = try? currentValue?.get().0 - guard let last = nextPageVarMap.values.last else { - return initialPageResult.flatMap { extractPageInfo(.initial($0, currentValue)) } + let currentValue = try? currentValue?.get() + guard let last = nextPageVarMap.values.last?.data else { + return initialPageResult?.data.flatMap { extractPageInfo(.initial($0, currentValue)) } } return extractPageInfo(.paginated(last, currentValue)) } private func previousPageTransformation() -> (any PaginationInfo)? { - let currentValue = try? currentValue?.get().0 - guard let first = previousPageVarMap.values.last else { - return initialPageResult.flatMap { extractPageInfo(.initial($0, currentValue)) } + let currentValue = try? currentValue?.get() + guard let first = previousPageVarMap.values.last?.data else { + return initialPageResult?.data.flatMap { extractPageInfo(.initial($0, currentValue)) } } return extractPageInfo(.paginated(first, currentValue)) } @@ -456,3 +467,9 @@ private extension GraphQLOperation.Variables { values.compactMap { $0._jsonEncodableValue?._jsonValue } } } + +internal extension GraphQLResult { + var updateSource: UpdateSource { + source == .cache ? .cache : .server + } +} diff --git a/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPager+Convenience.swift b/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPager+Convenience.swift index c879defda..ee7193bd2 100644 --- a/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPager+Convenience.swift +++ b/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPager+Convenience.swift @@ -25,51 +25,6 @@ public extension GraphQLQueryPager { )) } - /// Convenience initializer for creating a pager that has a single query and - /// transforms output responses. - convenience init( - client: any ApolloClientProtocol, - watcherDispatchQueue: DispatchQueue = .main, - initialQuery: InitialQuery, - extractPageInfo: @escaping (InitialQuery.Data) -> P, - pageResolver: @escaping (P, PaginationDirection) -> InitialQuery?, - transform: @escaping ([InitialQuery.Data], InitialQuery.Data, [InitialQuery.Data]) throws -> Model - ) { - self.init( - pager: GraphQLQueryPagerCoordinator( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: pageExtraction(transform: extractPageInfo), - pageResolver: pageResolver - ), - transform: transform - ) - } - - /// Convenience initializer for creating a pager that has a single query and - /// transforms output responses into a collection. - convenience init( - client: any ApolloClientProtocol, - watcherDispatchQueue: DispatchQueue = .main, - initialQuery: InitialQuery, - extractPageInfo: @escaping (InitialQuery.Data) -> P, - pageResolver: @escaping (P, PaginationDirection) -> InitialQuery?, - transform: @escaping (InitialQuery.Data) throws -> Model - ) where Model: RangeReplaceableCollection, T == Model.Element { - self.init( - pager: GraphQLQueryPagerCoordinator( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: pageExtraction(transform: extractPageInfo), - pageResolver: pageResolver - ), - initialTransform: transform, - pageTransform: transform - ) - } - /// Convenience initializer for creating a multi-query pager that does not /// transform output responses. convenience init( @@ -93,59 +48,6 @@ public extension GraphQLQueryPager { ) ) } - - /// Convenience initializer for creating a multi-query pager that transforms output responses. - convenience init( - client: any ApolloClientProtocol, - initialQuery: InitialQuery, - watcherDispatchQueue: DispatchQueue = .main, - extractInitialPageInfo: @escaping (InitialQuery.Data) -> P, - extractNextPageInfo: @escaping (PaginatedQuery.Data) -> P, - pageResolver: @escaping (P, PaginationDirection) -> PaginatedQuery?, - transform: @escaping ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> Model - ) where Model == PaginationOutput { - self.init( - pager: .init( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: pageExtraction( - initialTransfom: extractInitialPageInfo, - paginatedTransform: extractNextPageInfo - ), - pageResolver: pageResolver - ), - transform: transform - ) - } - - /// Convenience initializer for creating a multi-query pager that - /// transforms output responses into collections - convenience init( - client: any ApolloClientProtocol, - initialQuery: InitialQuery, - watcherDispatchQueue: DispatchQueue = .main, - extractInitialPageInfo: @escaping (InitialQuery.Data) -> P, - extractNextPageInfo: @escaping (PaginatedQuery.Data) -> P, - pageResolver: @escaping (P, PaginationDirection) -> PaginatedQuery?, - initialTransform: @escaping (InitialQuery.Data) throws -> Model, - pageTransform: @escaping (PaginatedQuery.Data) throws -> Model - ) where Model: RangeReplaceableCollection, T == Model.Element { - self.init( - pager: .init( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: pageExtraction( - initialTransfom: extractInitialPageInfo, - paginatedTransform: extractNextPageInfo - ), - pageResolver: pageResolver - ), - initialTransform: initialTransform, - pageTransform: pageTransform - ) - } } // MARK: - AsyncGraphQLQueryPager Convenience Functions @@ -170,51 +72,6 @@ public extension AsyncGraphQLQueryPager { )) } - /// Convenience initializer for creating a pager that has a single query and - /// transforms output responses. - convenience init( - client: any ApolloClientProtocol, - watcherDispatchQueue: DispatchQueue = .main, - initialQuery: InitialQuery, - extractPageInfo: @escaping (InitialQuery.Data) -> P, - pageResolver: @escaping (P, PaginationDirection) -> InitialQuery?, - transform: @escaping ([InitialQuery.Data], InitialQuery.Data, [InitialQuery.Data]) throws -> Model - ) { - self.init( - pager: AsyncGraphQLQueryPagerCoordinator( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: pageExtraction(transform: extractPageInfo), - pageResolver: pageResolver - ), - transform: transform - ) - } - - /// Convenience initializer for creating a pager that has a single query and - /// transforms output responses into a collection. - convenience init( - client: any ApolloClientProtocol, - watcherDispatchQueue: DispatchQueue = .main, - initialQuery: InitialQuery, - extractPageInfo: @escaping (InitialQuery.Data) -> P, - pageResolver: @escaping (P, PaginationDirection) -> InitialQuery?, - transform: @escaping (InitialQuery.Data) throws -> Model - ) where Model: RangeReplaceableCollection, T == Model.Element { - self.init( - pager: AsyncGraphQLQueryPagerCoordinator( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: pageExtraction(transform: extractPageInfo), - pageResolver: pageResolver - ), - initialTransform: transform, - pageTransform: transform - ) - } - /// Convenience initializer for creating a multi-query pager that does not /// transform output responses. convenience init( @@ -238,60 +95,6 @@ public extension AsyncGraphQLQueryPager { ) ) } - - /// Convenience initializer for creating a multi-query pager that - /// transforms output responses. - convenience init( - client: any ApolloClientProtocol, - initialQuery: InitialQuery, - watcherDispatchQueue: DispatchQueue = .main, - extractInitialPageInfo: @escaping (InitialQuery.Data) -> P, - extractNextPageInfo: @escaping (PaginatedQuery.Data) -> P, - pageResolver: @escaping (P, PaginationDirection) -> PaginatedQuery?, - transform: @escaping ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> Model - ) where Model == PaginationOutput { - self.init( - pager: .init( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: pageExtraction( - initialTransfom: extractInitialPageInfo, - paginatedTransform: extractNextPageInfo - ), - pageResolver: pageResolver - ), - transform: transform - ) - } - - /// Convenience initializer for creating a multi-query pager that - /// transforms output responses into collections - convenience init( - client: any ApolloClientProtocol, - initialQuery: InitialQuery, - watcherDispatchQueue: DispatchQueue = .main, - extractInitialPageInfo: @escaping (InitialQuery.Data) -> P, - extractNextPageInfo: @escaping (PaginatedQuery.Data) -> P, - pageResolver: @escaping (P, PaginationDirection) -> PaginatedQuery?, - initialTransform: @escaping (InitialQuery.Data) throws -> Model, - pageTransform: @escaping (PaginatedQuery.Data) throws -> Model - ) where Model: RangeReplaceableCollection, T == Model.Element { - self.init( - pager: .init( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: pageExtraction( - initialTransfom: extractInitialPageInfo, - paginatedTransform: extractNextPageInfo - ), - pageResolver: pageResolver - ), - initialTransform: initialTransform, - pageTransform: pageTransform - ) - } } private func pageExtraction( diff --git a/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPager.swift b/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPager.swift index dcd47afa8..ffc604577 100644 --- a/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPager.swift +++ b/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPager.swift @@ -6,7 +6,7 @@ import Foundation /// Type-erases a query pager, transforming data from a generic type to a specific type, often a view model or array of view models. public class GraphQLQueryPager: Publisher { public typealias Failure = Never - public typealias Output = Result<(Model, UpdateSource), any Error> + public typealias Output = Result let _subject: CurrentValueSubject = .init(nil) var publisher: AnyPublisher { _subject.compactMap { $0 }.eraseToAnyPublisher() } public var cancellables: Set = [] @@ -17,7 +17,7 @@ public class GraphQLQueryPager: Publisher { init, InitialQuery, PaginatedQuery>( pager: Pager, - transform: @escaping ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> Model + transform: @escaping (PaginationOutput) throws -> Model ) { self.pager = pager pager.subscribe { [weak self] result in @@ -25,10 +25,10 @@ public class GraphQLQueryPager: Publisher { let returnValue: Output switch result { - case let .success((output, source)): + case let .success(output): do { - let transformedModels = try transform(output.previousPages, output.initialPage, output.nextPages) - returnValue = .success((transformedModels, source)) + let transformedModels = try transform(output) + returnValue = .success(transformedModels) } catch { returnValue = .failure(error) } @@ -50,27 +50,13 @@ public class GraphQLQueryPager: Publisher { } } - convenience init< - Pager: GraphQLQueryPagerCoordinator, - InitialQuery, - PaginatedQuery, - Element - >( - pager: Pager, - initialTransform: @escaping (InitialQuery.Data) throws -> Model, - pageTransform: @escaping (PaginatedQuery.Data) throws -> Model - ) where Model: RangeReplaceableCollection, Model.Element == Element { - self.init( - pager: pager, - transform: { previousData, initialData, nextData in - let previous = try previousData.flatMap { try pageTransform($0) } - let initial = try initialTransform(initialData) - let next = try nextData.flatMap { try pageTransform($0) } - return previous + initial + next - } - ) - } - + /// Initialize an `GraphQLQueryPager` that outputs a `PaginationOutput`. + /// - Parameters: + /// - client: Apollo client type + /// - initialQuery: The query to call for the first page of pagination. May be a separate type of query than the pagination query. + /// - watcherDispatchQueue: The queue that the underlying `GraphQLQueryWatcher`s respond on. Defaults to `main`. + /// - extractPageInfo: A user-input closure that instructs the pager on how to extract `P`, a `PaginationInfo` type, from the `Data` of either the `InitialQuery` or `PaginatedQuery`. + /// - pageResolver: A user-input closure that instructs the pager on how to create a new `PaginatedQuery` given a `PaginationInfo` and a `PaginationDirection`. public convenience init< P: PaginationInfo, InitialQuery: GraphQLQuery, @@ -92,88 +78,34 @@ public class GraphQLQueryPager: Publisher { self.init(pager: pager) } - public convenience init< - P: PaginationInfo, - InitialQuery: GraphQLQuery, - PaginatedQuery: GraphQLQuery, - Element - >( - client: any ApolloClientProtocol, - initialQuery: InitialQuery, - watcherDispatchQueue: DispatchQueue = .main, - extractPageInfo: @escaping (PageExtractionData) -> P, - pageResolver: ((P, PaginationDirection) -> PaginatedQuery?)?, - initialTransform: @escaping (InitialQuery.Data) throws -> Model, - pageTransform: @escaping (PaginatedQuery.Data) throws -> Model - ) where Model: RangeReplaceableCollection, Model.Element == Element { - let pager = GraphQLQueryPagerCoordinator( - client: client, - initialQuery: initialQuery, - watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: { data in - switch data { - case .initial(let data, let output): - return extractPageInfo(.initial(data, convertOutput(result: output))) - case .paginated(let data, let output): - return extractPageInfo(.paginated(data, convertOutput(result: output))) - } - }, - pageResolver: pageResolver - ) - self.init( - pager: pager, - initialTransform: initialTransform, - pageTransform: pageTransform - ) - - func convertOutput(result: PaginationOutput?) -> Model? { - guard let result else { return nil } - - let transform: ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> Model = { previousData, initialData, nextData in - let previous = try previousData.flatMap { try pageTransform($0) } - let initial = try initialTransform(initialData) - let next = try nextData.flatMap { try pageTransform($0) } - return previous + initial + next - } - return try? transform(result.previousPages, result.initialPage, result.nextPages) - } - } - + /// Initialize an `GraphQLQueryPager` that outputs a user-defined `Model`, the result of the `transform` argument. + /// - Parameters: + /// - client: Apollo client type + /// - initialQuery: The query to call for the first page of pagination. May be a separate type of query than the pagination query. + /// - watcherDispatchQueue: The queue that the underlying `GraphQLQueryWatcher`s respond on. Defaults to `main`. + /// - extractPageInfo: A user-input closure that instructs the pager on how to extract `P`, a `PaginationInfo` type, from the `Data` of either the `InitialQuery` or `PaginatedQuery`. + /// - pageResolver: A user-input closure that instructs the pager on how to create a new `PaginatedQuery` given a `PaginationInfo` and a `PaginationDirection`. + /// - transform: Transforms the `PaginationOutput` into a `Model` type. public convenience init< P: PaginationInfo, InitialQuery: GraphQLQuery, PaginatedQuery: GraphQLQuery >( client: any ApolloClientProtocol, - initialQuery: InitialQuery, watcherDispatchQueue: DispatchQueue = .main, - extractPageInfo: @escaping (PageExtractionData) -> P, + initialQuery: InitialQuery, + extractPageInfo: @escaping (PageExtractionData?>) -> P, pageResolver: ((P, PaginationDirection) -> PaginatedQuery?)?, - transform: @escaping ([PaginatedQuery.Data], InitialQuery.Data, [PaginatedQuery.Data]) throws -> Model + transform: @escaping (PaginationOutput) throws -> Model ) { let pager = GraphQLQueryPagerCoordinator( client: client, initialQuery: initialQuery, watcherDispatchQueue: watcherDispatchQueue, - extractPageInfo: { data in - switch data { - case .initial(let data, let output): - return extractPageInfo(.initial(data, convertOutput(result: output))) - case .paginated(let data, let output): - return extractPageInfo(.paginated(data, convertOutput(result: output))) - } - }, + extractPageInfo: extractPageInfo, pageResolver: pageResolver ) - self.init( - pager: pager, - transform: transform - ) - - func convertOutput(result: PaginationOutput?) -> Model? { - guard let result else { return nil } - return try? transform(result.previousPages, result.initialPage, result.nextPages) - } + self.init(pager: pager, transform: transform) } deinit { @@ -182,6 +114,7 @@ public class GraphQLQueryPager: Publisher { /// Subscribe to the results of the pager, with the management of the subscriber being stored internally to the `AnyGraphQLQueryPager`. /// - Parameter completion: The closure to trigger when new values come in. Guaranteed to run on the main thread. + @available(*, deprecated, message: "Will be removed in a future version of ApolloPagination. Use the `Combine` publishers instead. If you need to dispatch to the main thread, make sure to use a `.receive(on: RunLoop.main)` as part of your `Combine` operation.") public func subscribe(completion: @escaping @MainActor (Output) -> Void) { publisher.sink { result in Task { await completion(result) } @@ -214,7 +147,7 @@ public class GraphQLQueryPager: Publisher { pager.loadPrevious(cachePolicy: cachePolicy, callbackQueue: callbackQueue, completion: completion) } - /// Loads all pages. + /// Loads all pages. Does not output a value until all pages have loaded. /// - Parameters: /// - fetchFromInitialPage: Pass true to begin loading from the initial page; otherwise pass false. Defaults to `true`. **NOTE**: Loading all pages with this value set to `false` requires that the initial page has already been loaded previously. /// - callbackQueue: The `DispatchQueue` that the `completion` fires on. Defaults to `main`. @@ -258,25 +191,7 @@ public class GraphQLQueryPager: Publisher { public func receive( subscriber: S - ) where S: Subscriber, Never == S.Failure, Result<(Model, UpdateSource), any Error> == S.Input { + ) where S: Subscriber, Never == S.Failure, Result == S.Input { publisher.subscribe(subscriber) } } - -extension GraphQLQueryPager: Equatable where Model: Equatable { - public static func == (lhs: GraphQLQueryPager, rhs: GraphQLQueryPager) -> Bool { - let left = lhs._subject.value - let right = rhs._subject.value - - switch (left, right) { - case (.success((let leftValue, let leftSource)), .success((let rightValue, let rightSource))): - return leftValue == rightValue && leftSource == rightSource - case (.failure(let leftError), .failure(let rightError)): - return leftError.localizedDescription == rightError.localizedDescription - case (.none, .none): - return true - default: - return false - } - } -} diff --git a/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPagerCoordinator.swift b/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPagerCoordinator.swift index 900a5912d..8f9d081d7 100644 --- a/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPagerCoordinator.swift +++ b/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPagerCoordinator.swift @@ -42,7 +42,7 @@ class GraphQLQueryPagerCoordinator, UpdateSource), any Error>, Never> { + var publisher: AnyPublisher, any Error>, Never> { get async { await pager.$currentValue.compactMap { $0 }.eraseToAnyPublisher() } } @@ -87,7 +87,7 @@ class GraphQLQueryPagerCoordinator, UpdateSource), any Error>) -> Void) { + func subscribe(onUpdate: @escaping (Result, any Error>) -> Void) { Task { [weak self] in guard let self else { return } let subscription = await self.pager.subscribe(onUpdate: onUpdate) @@ -167,7 +167,7 @@ class GraphQLQueryPagerCoordinator Void)? = nil ) { - execute(callbackQueue: callbackQueue, completion: { _ in completion?() }) { [weak self] in + execute(callbackQueue: callbackQueue) { _ in completion?() } operation: { [weak self] in guard let self else { return } for completion in await self.completionManager.completions { completion.execute(error: PaginationError.cancellation) @@ -184,7 +184,7 @@ class GraphQLQueryPagerCoordinator Void)? = nil ) { - execute(callbackQueue: callbackQueue, completion: { _ in completion?() }) { [weak self] in + execute(callbackQueue: callbackQueue) { _ in completion?() } operation: { [weak self] in await self?.pager.fetch() } } diff --git a/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPagerOutput.swift b/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPagerOutput.swift index e1eaa3dac..dc70cf2d0 100644 --- a/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPagerOutput.swift +++ b/apollo-ios-pagination/Sources/ApolloPagination/GraphQLQueryPagerOutput.swift @@ -1,3 +1,4 @@ +import Apollo import ApolloAPI import Foundation @@ -5,21 +6,77 @@ import Foundation public struct PaginationOutput: Hashable { /// An array of previous pages, in pagination order /// Earlier pages come first in the array. - public let previousPages: [PaginatedQuery.Data] + public let previousPages: [GraphQLResult] /// The initial page that we fetched. - public let initialPage: InitialQuery.Data + public let initialPage: GraphQLResult? /// An array of pages after the initial page. - public let nextPages: [PaginatedQuery.Data] + public let nextPages: [GraphQLResult] + + public let lastUpdatedPage: QueryWrapper public init( - previousPages: [PaginatedQuery.Data], - initialPage: InitialQuery.Data, - nextPages: [PaginatedQuery.Data] + previousPages: [GraphQLResult], + initialPage: GraphQLResult?, + nextPages: [GraphQLResult], + lastUpdatedPage: QueryWrapper ) { self.previousPages = previousPages self.initialPage = initialPage self.nextPages = nextPages + self.lastUpdatedPage = lastUpdatedPage + } + + public var allErrors: [GraphQLError] { + (previousPages.compactMap(\.errors) + [initialPage?.errors].compactMap { $0 } + nextPages.compactMap(\.errors)).flatMap { $0 } + } +} + +extension PaginationOutput { + public enum QueryWrapper: Hashable { + case initial(GraphQLResult) + case paginated(GraphQLResult) + } +} + +extension PaginationOutput.QueryWrapper { + public var errors: [GraphQLError]? { + switch self { + case .initial(let result): + result.errors + case .paginated(let result): + result.errors + } + } + + public var source: UpdateSource { + switch self { + case .initial(let result): + result.updateSource + case .paginated(let result): + result.updateSource + } + } +} + +extension PaginationOutput.QueryWrapper where InitialQuery == PaginatedQuery { + public var data: InitialQuery.Data? { + switch self { + case .initial(let result): + result.data + case .paginated(let result): + result.data + } + } +} + +extension PaginationOutput where InitialQuery == PaginatedQuery { + public var allData: [InitialQuery.Data] { + previousPages.compactMap(\.data) + [initialPage?.data].compactMap { $0 } + nextPages.compactMap(\.data) + } + + public var allPages: [GraphQLResult] { + previousPages + [initialPage].compactMap { $0 } + nextPages } } diff --git a/apollo-ios-pagination/Sources/ApolloPagination/UpdateSource.swift b/apollo-ios-pagination/Sources/ApolloPagination/UpdateSource.swift index ba947caf1..8acecbfea 100644 --- a/apollo-ios-pagination/Sources/ApolloPagination/UpdateSource.swift +++ b/apollo-ios-pagination/Sources/ApolloPagination/UpdateSource.swift @@ -1,3 +1,3 @@ public enum UpdateSource: Hashable { - case fetch, cache + case server, cache }