-
Notifications
You must be signed in to change notification settings - Fork 730
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Paginated Watchers for GraphQL Queries #3007
Closed
Closed
Changes from 4 commits
Commits
Show all changes
72 commits
Select commit
Hold shift + click to select a range
8b76cc7
Simple paginated watcher implementation
Iron-Ham 369f760
include tests
Iron-Ham 0ab2630
Get rid of an unnecessary completion block
Iron-Ham 5ec8f5d
Remove debugging code
Iron-Ham b530005
Manage pages
Iron-Ham 4cb2869
Rename fetchNext to fetchMore for consistency with React
Iron-Ham 933e086
Failing test
Iron-Ham 512a4c2
Succeeding test
Iron-Ham 9ae40df
Account for source for merging server vs cache update
Iron-Ham 79f7aac
Cleanup test file
Iron-Ham f15d774
Simplify transform
Iron-Ham 9e39373
Wrap with a model
Iron-Ham d793b56
Test for deletion of cache data
Iron-Ham d270a62
Cleanup Pagination + Tests
Iron-Ham d8921ac
Add support for fetching individual pages
Iron-Ham 2d0ca4f
Start transition towards merge strategies
Iron-Ham 74f21ff
Transition to strategies
Iron-Ham 9bdaab4
Make sure we only notify observers once
Iron-Ham a149d51
Spacing cleanup
Iron-Ham c15366d
More tests
Iron-Ham 9ba3544
Cleanup docs
Iron-Ham 26e9de8
Explicit self
Iron-Ham fbe5baa
Typo fix
Iron-Ham 506e444
Add initializer to PaginationDataResponse
Iron-Ham 5357668
Update comments
Iron-Ham 593fe8c
Update comments, again
Iron-Ham 5a5640e
Public values
Iron-Ham 0576f30
Add completion block to fetchMore
Iron-Ham e7f7675
Source should be included as part of the result handler
Iron-Ham d5515d2
Add a type erased GraphQLPaginatedQueryWatcher
Iron-Ham a47ce7d
AnyPaginationStrategy
Iron-Ham a525e59
Add protocol for paginated query watchers
Iron-Ham ba3bb4d
Check for cocoapods
Iron-Ham f8bf35c
Add comments
Iron-Ham 5f219d4
More docs
Iron-Ham aa8c94d
Deleted AnyGraphQLPaginatedQueryWatcher
Iron-Ham bfeee53
RelayPaginationStrategy
Iron-Ham 7f6575f
Pagination Merge strategies
Iron-Ham ee01b24
Start fixing tests
Iron-Ham 586e999
Refetch
Iron-Ham 067eebf
With keypaths
Iron-Ham b7cf1f6
Refetch
Iron-Ham d9921f2
Fetch + Local Cache Update
Iron-Ham d5a73c8
Custom Strategy tests
Iron-Ham b0baeb9
Simple multipage
Iron-Ham 0d08a4e
Targeted
Iron-Ham 850b109
All Green
Iron-Ham 4372bb3
Some docs
Iron-Ham cc61d00
More docs
Iron-Ham fdacf41
Merge branch 'main' into hs/paginated-watcher
Iron-Ham d7659d4
Update with main
Iron-Ham 82cb86c
More docs
Iron-Ham 975bce8
Spacing
Iron-Ham 587664c
Prefer private extension
Iron-Ham f3c7c91
Green again
Iron-Ham 92b17b6
Union of fragments
Iron-Ham c76914d
public _resultHandler
Iron-Ham 1492973
Make KeyPaths optional
Iron-Ham f773878
Allow the relay pagination strategy to use the protocol
Iron-Ham 284126b
Update resultHandler
Iron-Ham 7fa6f54
Rough draft of docs for pagination
Iron-Ham ff84911
Merge branch 'main' of github.com:Iron-Ham/apollo-ios into hs/paginat…
Iron-Ham badeddb
Update schema
Iron-Ham 014f1d5
Tests
Iron-Ham c8206ce
Cleanup
Iron-Ham c881000
Xcodeproj
Iron-Ham bf51e47
Docs
Iron-Ham 3554f38
Update website docs
Iron-Ham f97ba72
Fix test
Iron-Ham 4440a5c
empty commit
Iron-Ham 015b02d
empty commit2
Iron-Ham 8c13b61
Import Dispatch
Iron-Ham File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
#if !COCOAPODS | ||
import ApolloAPI | ||
#endif | ||
import Foundation | ||
|
||
public typealias Cursor = String | ||
/// Pagination help information, returned by the GraphQL server. Contains info based on the requested connection parameters. | ||
public protocol PageInfoType { | ||
/// Whether or not there are subsequent items past this page in the connection. | ||
var hasNextPage: Bool { get } | ||
|
||
/// The final cursor, marking the end of the page. | ||
var endCursor: Cursor? { get } | ||
} | ||
|
||
/// Handles pagination in the queue by managing multiple query watchers. | ||
final class GraphQLPaginatedQueryWatcher<Query: GraphQLQuery, T>: Cancellable { | ||
/// Given a page, create a query of the type this watcher is responsible for | ||
public typealias CreatePageQuery = (PageInfoType) -> Query? | ||
|
||
private typealias ResultHandler = (Result<GraphQLResult<Query.Data>, Error>) -> Void | ||
|
||
private let client: any ApolloClientProtocol | ||
|
||
private var initialWatcher: GraphQLQueryWatcher<Query>? | ||
private var subsequentWatchers: [GraphQLQueryWatcher<Query>] = [] | ||
|
||
private let createPageQuery: CreatePageQuery | ||
private let nextPageTransform: (T?, T) -> T | ||
|
||
private var model: T? // 🚗 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i'm so sorry i couldn't help myself. |
||
private var resultHandler: ResultHandler? | ||
private var callbackQueue: DispatchQueue | ||
|
||
/// Designated initializer | ||
/// | ||
/// - Parameters: | ||
/// - client: The client protocol to pass in | ||
/// - inititalCachePolicy: The preferred cache policy for the initlal page. Defaults to `returnCacheDataAndFetch`. | ||
/// - callbackQueue: The queue for response callbacks. | ||
/// - query: The query to watch | ||
/// - createPageQuery: A function which creates a new `Query` given some pagination information. | ||
/// - transform: Transforms the `Query.Data` to the intended model | ||
/// - nextPageTransform: A function which combines extant data with the new data from the next page | ||
/// - onReceiveResults: The callback function which returns changes or an error. | ||
public init( | ||
client: ApolloClientProtocol, | ||
inititalCachePolicy: CachePolicy = .returnCacheDataAndFetch, | ||
callbackQueue: DispatchQueue = .main, | ||
query: Query, | ||
createPageQuery: @escaping CreatePageQuery, | ||
transform: @escaping (Query.Data) -> T?, | ||
nextPageTransform: @escaping (T?, T) -> T, | ||
onReceiveResults: @escaping (Result<T, Error>) -> Void | ||
) { | ||
self.callbackQueue = callbackQueue | ||
self.client = client | ||
self.createPageQuery = createPageQuery | ||
self.nextPageTransform = nextPageTransform | ||
|
||
let resultHandler: ResultHandler = { [weak self] result in | ||
guard let self else { return } | ||
switch result { | ||
case .failure(let error): | ||
guard !error.wasCancelled else { return } | ||
// Forward all errors aside from network cancellation errors | ||
onReceiveResults(.failure(error)) | ||
case .success(let graphQLResult): | ||
guard let data = graphQLResult.data, let transformedModel = transform(data) else { return } | ||
let model = nextPageTransform(self.model, transformedModel) | ||
self.model = model | ||
onReceiveResults(.success(model)) | ||
} | ||
} | ||
|
||
self.resultHandler = resultHandler | ||
initialWatcher = client.watch( | ||
query: query, | ||
cachePolicy: .returnCacheDataAndFetch, | ||
callbackQueue: callbackQueue, | ||
resultHandler: resultHandler | ||
) | ||
} | ||
|
||
public func fetch() { | ||
initialWatcher?.refetch() | ||
cancelSubsequentWatchers() | ||
} | ||
|
||
@discardableResult | ||
public func fetchNext(page: PageInfoType) -> Bool { | ||
guard page.hasNextPage, | ||
let nextPageQuery = createPageQuery(page), | ||
let resultHandler | ||
else { return false } | ||
|
||
let nextPageWatcher = client.watch( | ||
query: nextPageQuery, | ||
cachePolicy: .fetchIgnoringCacheData, | ||
Iron-Ham marked this conversation as resolved.
Show resolved
Hide resolved
|
||
callbackQueue: callbackQueue | ||
) { result in | ||
resultHandler(result) | ||
} | ||
subsequentWatchers.append(nextPageWatcher) | ||
|
||
return true | ||
} | ||
|
||
public func cancel() { | ||
initialWatcher?.cancel() | ||
cancelSubsequentWatchers() | ||
} | ||
|
||
private func cancelSubsequentWatchers() { | ||
subsequentWatchers.forEach { $0.cancel() } | ||
subsequentWatchers.removeAll() | ||
} | ||
|
||
deinit { | ||
cancel() | ||
} | ||
} | ||
|
||
private extension Error { | ||
var wasCancelled: Bool { | ||
if let apolloError = self as? URLSessionClient.URLSessionClientError, | ||
case let .networkError(data: _, response: _, underlying: underlying) = apolloError { | ||
return underlying.wasCancelled | ||
} | ||
|
||
return (self as NSError).code == NSURLErrorCancelled | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,206 @@ | ||
@testable import Apollo | ||
import ApolloAPI | ||
import ApolloInternalTestHelpers | ||
import Nimble | ||
import XCTest | ||
|
||
class PaginatedWatchQueryTests: XCTestCase, CacheDependentTesting { | ||
|
||
var cacheType: TestCacheProvider.Type { | ||
InMemoryTestCacheProvider.self | ||
} | ||
|
||
var cache: NormalizedCache! | ||
var server: MockGraphQLServer! | ||
var client: ApolloClient! | ||
|
||
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) | ||
} | ||
|
||
override func tearDownWithError() throws { | ||
cache = nil | ||
server = nil | ||
client = nil | ||
|
||
try super.tearDownWithError() | ||
} | ||
|
||
private class MockPaginatedSelectionSet: MockSelectionSet { | ||
override class var __selections: [Selection] { [ | ||
.field("hero", Hero.self) | ||
]} | ||
|
||
var hero: Hero { __data["hero"] } | ||
|
||
class Hero: MockSelectionSet { | ||
override class var __selections: [Selection] {[ | ||
.field("name", String.self), | ||
.field("friendsConnection", FriendsConnection.self, arguments: [ | ||
"first": .variable("first"), | ||
"after": .variable("after") | ||
]) | ||
]} | ||
|
||
var name: String { __data["name"] } | ||
var friends: FriendsConnection { __data["friendsConnection"] } | ||
|
||
class FriendsConnection: MockSelectionSet { | ||
override class var __selections: [Selection] {[ | ||
.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("name", String.self), | ||
]} | ||
|
||
var name: String { __data["name"] } | ||
} | ||
|
||
class PageInfo: MockSelectionSet { | ||
override class var __selections: [Selection] {[ | ||
.field("endCursor", Optional<String>.self), | ||
.field("hasNextPage", Bool.self) | ||
]} | ||
|
||
var endCursor: String? { __data["endCursor"] } | ||
var hasNextPage: Bool { __data["hasNextPage"] } | ||
} | ||
} | ||
} | ||
} | ||
|
||
struct HeroViewModel: Equatable { | ||
struct Friend: Equatable { | ||
let name: String | ||
} | ||
|
||
let name: String | ||
let friends: [Friend] | ||
} | ||
|
||
struct Page: PageInfoType { | ||
var endCursor: Cursor? | ||
var hasNextPage: Bool | ||
} | ||
|
||
// MARK: - Tests | ||
|
||
func testMultiPageResults() { | ||
let query = MockQuery<MockPaginatedSelectionSet>() | ||
query.__variables = ["first": 2, "after": GraphQLNullable<String>.null] | ||
|
||
var results: [HeroViewModel] = [] | ||
var watcher: GraphQLPaginatedQueryWatcher<MockQuery<MockPaginatedSelectionSet>, HeroViewModel>? | ||
addTeardownBlock { watcher?.cancel() } | ||
|
||
runActivity("Initial fetch from server") { _ in | ||
let serverExpectation = server.expect(MockQuery<MockPaginatedSelectionSet>.self) { _ in | ||
[ | ||
"data": [ | ||
"hero": [ | ||
"name": "R2-D2", | ||
"friendsConnection": [ | ||
"totalCount": 3, | ||
"friends": [ | ||
[ | ||
"name": "Luke Skywalker" | ||
], | ||
[ | ||
"name": "Han Solo" | ||
] | ||
], | ||
"pageInfo": [ | ||
"endCursor": "Y3Vyc29yMg==", | ||
"hasNextPage": true | ||
] | ||
] | ||
], | ||
] | ||
] | ||
} | ||
|
||
watcher = GraphQLPaginatedQueryWatcher( | ||
client: client, | ||
query: query | ||
) { pageInfo in | ||
let query = MockQuery<MockPaginatedSelectionSet>() | ||
query.__variables = ["first": 2, "after": "1"] | ||
return query | ||
} transform: { data in | ||
HeroViewModel( | ||
name: data.hero.name, | ||
friends: data.hero.friends.friends.map { | ||
HeroViewModel.Friend(name: $0.name) | ||
} | ||
) | ||
} nextPageTransform: { oldData, newData in | ||
guard let oldData else { return newData } | ||
|
||
return HeroViewModel( | ||
name: newData.name, | ||
friends: oldData.friends + newData.friends | ||
) | ||
} onReceiveResults: { result in | ||
guard case let .success(value) = result else { return XCTFail() } | ||
results.append(value) | ||
} | ||
guard let watcher else { return XCTFail() } | ||
wait(for: [serverExpectation], timeout: 1.0) | ||
|
||
let secondPageExpectation = server.expect(MockQuery<MockPaginatedSelectionSet>.self) { _ in | ||
[ | ||
"data": [ | ||
"hero": [ | ||
"name": "R2-D2", | ||
"friendsConnection": [ | ||
"totalCount": 3, | ||
"friends": [ | ||
[ | ||
"name": "Leia Organa" | ||
] | ||
], | ||
"pageInfo": [ | ||
"endCursor": "Y3Vyc29yMw==", | ||
"hasNextPage": false | ||
] | ||
] | ||
], | ||
] | ||
] | ||
} | ||
|
||
_ = watcher.fetchNext(page: Page(endCursor: "Y3Vyc29yMg==", hasNextPage: true)) | ||
wait(for: [secondPageExpectation], timeout: 1.0) | ||
|
||
XCTAssertEqual(results.count, 2) | ||
XCTAssertEqual(results, [ | ||
HeroViewModel(name: "R2-D2", friends: [ | ||
HeroViewModel.Friend(name: "Luke Skywalker"), | ||
HeroViewModel.Friend(name: "Han Solo"), | ||
]), | ||
HeroViewModel(name: "R2-D2", friends: [ | ||
HeroViewModel.Friend(name: "Luke Skywalker"), | ||
HeroViewModel.Friend(name: "Han Solo"), | ||
HeroViewModel.Friend(name: "Leia Organa"), | ||
]) | ||
]) | ||
|
||
} | ||
} | ||
} |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
GraphQLQueryWatcher
has always held aweak
reference to the client. I'm wondering if this should do the same. I don't imagine that this would create a retain cycle, as the retain cycle would be broken as soon as you callcancel()
.Any thoughts on this? @Iron-Ham @calvincestari @BobaFetters
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this sounds reasonable