Skip to content
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
wants to merge 72 commits into from
Closed
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
8b76cc7
Simple paginated watcher implementation
Iron-Ham May 11, 2023
369f760
include tests
Iron-Ham May 11, 2023
0ab2630
Get rid of an unnecessary completion block
Iron-Ham May 11, 2023
5ec8f5d
Remove debugging code
Iron-Ham May 11, 2023
b530005
Manage pages
Iron-Ham May 11, 2023
4cb2869
Rename fetchNext to fetchMore for consistency with React
Iron-Ham May 11, 2023
933e086
Failing test
Iron-Ham May 11, 2023
512a4c2
Succeeding test
Iron-Ham May 11, 2023
9ae40df
Account for source for merging server vs cache update
Iron-Ham May 12, 2023
79f7aac
Cleanup test file
Iron-Ham May 12, 2023
f15d774
Simplify transform
Iron-Ham May 12, 2023
9e39373
Wrap with a model
Iron-Ham May 12, 2023
d793b56
Test for deletion of cache data
Iron-Ham May 12, 2023
d270a62
Cleanup Pagination + Tests
Iron-Ham May 15, 2023
d8921ac
Add support for fetching individual pages
Iron-Ham May 15, 2023
2d0ca4f
Start transition towards merge strategies
Iron-Ham May 16, 2023
74f21ff
Transition to strategies
Iron-Ham May 16, 2023
9bdaab4
Make sure we only notify observers once
Iron-Ham May 16, 2023
a149d51
Spacing cleanup
Iron-Ham May 16, 2023
c15366d
More tests
Iron-Ham May 16, 2023
9ba3544
Cleanup docs
Iron-Ham May 17, 2023
26e9de8
Explicit self
Iron-Ham May 17, 2023
fbe5baa
Typo fix
Iron-Ham May 17, 2023
506e444
Add initializer to PaginationDataResponse
Iron-Ham May 17, 2023
5357668
Update comments
Iron-Ham May 17, 2023
593fe8c
Update comments, again
Iron-Ham May 17, 2023
5a5640e
Public values
Iron-Ham May 18, 2023
0576f30
Add completion block to fetchMore
Iron-Ham May 18, 2023
e7f7675
Source should be included as part of the result handler
Iron-Ham May 18, 2023
d5515d2
Add a type erased GraphQLPaginatedQueryWatcher
Iron-Ham May 18, 2023
a47ce7d
AnyPaginationStrategy
Iron-Ham May 22, 2023
a525e59
Add protocol for paginated query watchers
Iron-Ham May 22, 2023
ba3bb4d
Check for cocoapods
Iron-Ham May 22, 2023
f8bf35c
Add comments
Iron-Ham May 22, 2023
5f219d4
More docs
Iron-Ham May 23, 2023
aa8c94d
Deleted AnyGraphQLPaginatedQueryWatcher
Iron-Ham May 24, 2023
bfeee53
RelayPaginationStrategy
Iron-Ham May 24, 2023
7f6575f
Pagination Merge strategies
Iron-Ham May 24, 2023
ee01b24
Start fixing tests
Iron-Ham May 24, 2023
586e999
Refetch
Iron-Ham May 24, 2023
067eebf
With keypaths
Iron-Ham May 24, 2023
b7cf1f6
Refetch
Iron-Ham May 24, 2023
d9921f2
Fetch + Local Cache Update
Iron-Ham May 24, 2023
d5a73c8
Custom Strategy tests
Iron-Ham May 24, 2023
b0baeb9
Simple multipage
Iron-Ham May 24, 2023
0d08a4e
Targeted
Iron-Ham May 24, 2023
850b109
All Green
Iron-Ham May 24, 2023
4372bb3
Some docs
Iron-Ham May 25, 2023
cc61d00
More docs
Iron-Ham May 25, 2023
fdacf41
Merge branch 'main' into hs/paginated-watcher
Iron-Ham May 25, 2023
d7659d4
Update with main
Iron-Ham May 25, 2023
82cb86c
More docs
Iron-Ham May 25, 2023
975bce8
Spacing
Iron-Ham May 25, 2023
587664c
Prefer private extension
Iron-Ham May 25, 2023
f3c7c91
Green again
Iron-Ham May 25, 2023
92b17b6
Union of fragments
Iron-Ham May 25, 2023
c76914d
public _resultHandler
Iron-Ham May 25, 2023
1492973
Make KeyPaths optional
Iron-Ham May 26, 2023
f773878
Allow the relay pagination strategy to use the protocol
Iron-Ham Jun 5, 2023
284126b
Update resultHandler
Iron-Ham Jun 8, 2023
7fa6f54
Rough draft of docs for pagination
Iron-Ham Jun 12, 2023
ff84911
Merge branch 'main' of github.com:Iron-Ham/apollo-ios into hs/paginat…
Iron-Ham Jul 5, 2023
badeddb
Update schema
Iron-Ham Jul 6, 2023
014f1d5
Tests
Iron-Ham Jul 6, 2023
c8206ce
Cleanup
Iron-Ham Jul 6, 2023
c881000
Xcodeproj
Iron-Ham Jul 6, 2023
bf51e47
Docs
Iron-Ham Jul 6, 2023
3554f38
Update website docs
Iron-Ham Jul 6, 2023
f97ba72
Fix test
Iron-Ham Jul 6, 2023
4440a5c
empty commit
Iron-Ham Jul 12, 2023
015b02d
empty commit2
Iron-Ham Jul 12, 2023
8c13b61
Import Dispatch
Iron-Ham Jul 12, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Apollo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@
C338DF1722DD9DE9006AF33E /* RequestBodyCreatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C338DF1622DD9DE9006AF33E /* RequestBodyCreatorTests.swift */; };
C377CCA922D798BD00572E03 /* GraphQLFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C377CCA822D798BD00572E03 /* GraphQLFile.swift */; };
C377CCAB22D7992E00572E03 /* MultipartFormData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C377CCAA22D7992E00572E03 /* MultipartFormData.swift */; };
D03E80D42A0D56F300269361 /* GraphQLPaginatedQueryWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03E80D32A0D56F300269361 /* GraphQLPaginatedQueryWatcher.swift */; };
D03E80D62A0D579300269361 /* PaginatedWatchQueryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03E80D52A0D579300269361 /* PaginatedWatchQueryTests.swift */; };
D87AC09F2564D60B0079FAA5 /* ApolloClientOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87AC09E2564D60B0079FAA5 /* ApolloClientOperationTests.swift */; };
DE01451228A442BF000F6F18 /* String+SwiftNameEscaping.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE01451128A442BF000F6F18 /* String+SwiftNameEscaping.swift */; };
DE01451428A5A156000F6F18 /* ValidationOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE01451328A5A156000F6F18 /* ValidationOptions.swift */; };
Expand Down Expand Up @@ -1333,6 +1335,8 @@
C338DF1622DD9DE9006AF33E /* RequestBodyCreatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestBodyCreatorTests.swift; sourceTree = "<group>"; };
C377CCA822D798BD00572E03 /* GraphQLFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLFile.swift; sourceTree = "<group>"; };
C377CCAA22D7992E00572E03 /* MultipartFormData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipartFormData.swift; sourceTree = "<group>"; };
D03E80D32A0D56F300269361 /* GraphQLPaginatedQueryWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLPaginatedQueryWatcher.swift; sourceTree = "<group>"; };
D03E80D52A0D579300269361 /* PaginatedWatchQueryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginatedWatchQueryTests.swift; sourceTree = "<group>"; };
D87AC09E2564D60B0079FAA5 /* ApolloClientOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApolloClientOperationTests.swift; sourceTree = "<group>"; };
D90F1AF92479DEE5007A1534 /* WebSocketTransportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketTransportTests.swift; sourceTree = "<group>"; };
DE01451128A442BF000F6F18 /* String+SwiftNameEscaping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+SwiftNameEscaping.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2657,6 +2661,7 @@
9FC750621D2A59F600458D91 /* ApolloClient.swift */,
9B708AAC2305884500604A11 /* ApolloClientProtocol.swift */,
9FCDFD281E33D0CE007519DC /* GraphQLQueryWatcher.swift */,
D03E80D32A0D56F300269361 /* GraphQLPaginatedQueryWatcher.swift */,
9FC9A9BE1E2C27FB0023C4D5 /* GraphQLResult.swift */,
9FC9A9D21E2FD48B0023C4D5 /* GraphQLError.swift */,
9F27D4601D40363A00715680 /* Execution */,
Expand Down Expand Up @@ -3864,6 +3869,7 @@
9F8622F71EC2004200C38162 /* ReadWriteFromStoreTests.swift */,
9FD03C2D25527CE6002227DC /* StoreConcurrencyTests.swift */,
9FA6ABCB1EC0A9F7000017BE /* WatchQueryTests.swift */,
D03E80D52A0D579300269361 /* PaginatedWatchQueryTests.swift */,
2EE7FFCF276802E30035DC39 /* CacheKeyConstructionTests.swift */,
);
path = Cache;
Expand Down Expand Up @@ -5439,6 +5445,7 @@
9BF1A95122CA6E71005292C2 /* GraphQLGETTransformer.swift in Sources */,
9B260BFF245A054700562176 /* JSONRequest.swift in Sources */,
9B260BF9245A030100562176 /* ResponseCodeInterceptor.swift in Sources */,
D03E80D42A0D56F300269361 /* GraphQLPaginatedQueryWatcher.swift in Sources */,
9B260BF3245A026F00562176 /* RequestChain.swift in Sources */,
E69F436C29B81182006FF548 /* InterceptorRequestChain.swift in Sources */,
9FF90A611DDDEB100034C3B6 /* GraphQLResponse.swift in Sources */,
Expand Down Expand Up @@ -5476,6 +5483,7 @@
DE46A55626F13A7400357C52 /* JSONResponseParsingInterceptorTests.swift in Sources */,
E616B6D126C3335600DB049E /* ExecutionTests.swift in Sources */,
9B9BBB1C24DB760B0021C30F /* UploadRequestTests.swift in Sources */,
D03E80D62A0D579300269361 /* PaginatedWatchQueryTests.swift in Sources */,
E61DD76526D60C1800C41614 /* SQLiteDotSwiftDatabaseBehaviorTests.swift in Sources */,
9BC139A424EDCA6C00876D29 /* MaxRetryInterceptorTests.swift in Sources */,
DE46A55126EFEB6900357C52 /* JSONValueMatcher.swift in Sources */,
Expand Down
157 changes: 157 additions & 0 deletions Sources/Apollo/GraphQLPaginatedQueryWatcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#if !COCOAPODS
import ApolloAPI
#endif
import Foundation

public typealias Cursor = String

/// Handles pagination in the queue by managing multiple query watchers.
final class GraphQLPaginatedQueryWatcher<Query: GraphQLQuery, T>: Cancellable {

public struct Page {
let hasNextPage: Bool
let endCursor: Cursor?
}

public struct DataResponse {
let allResponses: [T]
let mostRecent: T
let source: GraphQLResult<Query.Data>.Source
}

/// Given a page, create a query of the type this watcher is responsible for
public typealias CreatePageQuery = (Page) -> Query?

private typealias ResultHandler = (Result<GraphQLResult<Query.Data>, Error>) -> Void

private let client: any ApolloClientProtocol
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GraphQLQueryWatcher has always held a weak 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 call cancel().

Any thoughts on this? @Iron-Ham @calvincestari @BobaFetters

Copy link
Contributor Author

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


var initialWatcher: GraphQLQueryWatcher<Query>?
private var subsequentWatchers: [GraphQLQueryWatcher<Query>] = []

private let createPageQuery: CreatePageQuery
private let nextPageTransform: (DataResponse) -> T

private var modelMap: [Cursor?: T] = [:]
private var cursorOrder: [Cursor?] = []
private var resultHandler: ResultHandler?
private var callbackQueue: DispatchQueue
private var page: Page?

/// 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 and extracts the `Page` from the `Data`.
/// - 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?, Page?)?,
Iron-Ham marked this conversation as resolved.
Show resolved Hide resolved
nextPageTransform: @escaping (DataResponse) -> 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,
AnthonyMDev marked this conversation as resolved.
Show resolved Hide resolved
let (transformedModel, page) = transform(data),
let transformedModel
else { return }
modelMap[page?.endCursor] = transformedModel
if !cursorOrder.contains(page?.endCursor) {
cursorOrder.append(page?.endCursor)
}
let model = nextPageTransform(
DataResponse(
allResponses: cursorOrder.compactMap { [weak self] cursor in
self?.modelMap[cursor]
},
mostRecent: transformedModel,
source: graphQLResult.source
)
)
self.page = page
onReceiveResults(.success(model))
}
}

self.resultHandler = resultHandler
initialWatcher = client.watch(
query: query,
cachePolicy: .returnCacheDataAndFetch,
callbackQueue: callbackQueue,
resultHandler: resultHandler
)
}

public func fetch() {
modelMap.removeAll()
cursorOrder.removeAll()
initialWatcher?.refetch()
cancelSubsequentWatchers()
}

@discardableResult
public func fetchMore() -> Bool {
guard let page,
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
}
}
Loading