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 3 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
133 changes: 133 additions & 0 deletions Sources/Apollo/GraphQLPaginatedQueryWatcher.swift
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
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


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

private let createPageQuery: CreatePageQuery
private let nextPageTransform: (T?, T) -> T

private var model: T? // 🚗
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'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
}
}
212 changes: 212 additions & 0 deletions Tests/ApolloTests/Cache/PaginatedWatchQueryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
@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
switch result {
case .success(let value):
dump(value)
case .failure(let error):
XCTFail("Error \(error)")
}
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"),
])
])

}
}
}