-
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
Changes from all commits
8b76cc7
369f760
0ab2630
5ec8f5d
b530005
4cb2869
933e086
512a4c2
9ae40df
79f7aac
f15d774
9e39373
d793b56
d270a62
d8921ac
2d0ca4f
74f21ff
9bdaab4
a149d51
c15366d
9ba3544
26e9de8
fbe5baa
506e444
5357668
593fe8c
5a5640e
0576f30
e7f7675
d5515d2
a47ce7d
a525e59
ba3bb4d
f8bf35c
5f219d4
aa8c94d
bfeee53
7f6575f
ee01b24
586e999
067eebf
b7cf1f6
d9921f2
d5a73c8
b0baeb9
0d08a4e
850b109
4372bb3
cc61d00
fdacf41
d7659d4
82cb86c
975bce8
587664c
f3c7c91
92b17b6
c76914d
1492973
f773878
284126b
7fa6f54
ff84911
badeddb
014f1d5
c8206ce
c881000
bf51e47
3554f38
f97ba72
4440a5c
015b02d
8c13b61
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
#if !COCOAPODS | ||
import ApolloAPI | ||
#endif | ||
|
||
/// Gives the caller full control over how to transform data. | ||
/// Can be used to output a custom type, or can be used for fine grain control over how a `Query.Data` is merged. | ||
public struct CustomDataTransformer<Query: GraphQLQuery, Output: Hashable>: DataTransformer { | ||
|
||
private let _transform: (Query.Data) -> Output? | ||
|
||
/// Designated intializer | ||
/// - Parameter transform: A user provided function which can transform a given network response into any `Hashable` output. | ||
public init(transform: @escaping (Query.Data) -> Output?) { | ||
self._transform = transform | ||
} | ||
|
||
/// The function by which we transform a `Query.Data` into an output | ||
/// - Parameter data: Network response | ||
/// - Returns: `Output` | ||
public func transform(data: Query.Data) -> Output? { | ||
_transform(data) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
#if !COCOAPODS | ||
import ApolloAPI | ||
#endif | ||
|
||
/// The strategy by which we create the next query in a series of paginated queries. Gives full custom control over mapping a `Page` into a `Query`. | ||
public struct CustomNextPageStrategy<Page: Hashable, Query: GraphQLQuery>: NextPageStrategy { | ||
private let _transform: (Page) -> Query | ||
|
||
public init(transform: @escaping (Page) -> Query) { | ||
self._transform = transform | ||
} | ||
|
||
public func createNextPageQuery(page: Page) -> Query { | ||
_transform(page) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
#if !COCOAPODS | ||
import ApolloAPI | ||
#endif | ||
|
||
/// A `PaginationMergeStrategy` which gives the caller fine-grain control over how to merge data together. | ||
public class CustomPaginationMergeStrategy<Query: GraphQLQuery, Output: Hashable>: PaginationMergeStrategy { | ||
|
||
let _transform: (PaginationDataResponse<Query, Output>) -> Output | ||
|
||
/// Designated initializer | ||
/// - Parameter transform: a user-defined function which can transform a `PaginationDataResponse` into an `Output`. | ||
public init(transform: @escaping (PaginationDataResponse<Query, Output>) -> Output) { | ||
self._transform = transform | ||
} | ||
|
||
/// The function by which we merge several responses, in the form of a `PaginationDataResponse` into one `Output`. | ||
/// - Parameter paginationResponse: A data type which contains the most recent response, the source of that response, and all other responses. | ||
/// - Returns: `Output` | ||
public func mergePageResults(paginationResponse: PaginationDataResponse<Query, Output>) -> Output { | ||
_transform(paginationResponse) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
#if !COCOAPODS | ||
import ApolloAPI | ||
#endif | ||
|
||
/// The protocol by which we transform a network/cache response into some `Output`. | ||
public protocol DataTransformer { | ||
associatedtype Query: GraphQLQuery | ||
associatedtype Output: Hashable | ||
|
||
/// Given a network response, transform it into the intended result type of the `PaginationStrategy`. | ||
/// - Parameter data: A network response | ||
/// - Returns: `Output` | ||
func transform(data: Query.Data) -> Output? | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
#if !COCOAPODS | ||
import ApolloAPI | ||
#endif | ||
import Dispatch | ||
|
||
/// Handles pagination in the queue by managing multiple query watchers. | ||
public final class GraphQLPaginatedQueryWatcher<Strategy: PaginationStrategy> { | ||
public typealias Page = Strategy.NextPageConstructor.Page | ||
private typealias ResultHandler = (Result<GraphQLResult<Strategy.Query.Data>, Error>) -> Void | ||
|
||
private let client: any ApolloClientProtocol | ||
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.
Any thoughts on this? @Iron-Ham @calvincestari @BobaFetters 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 think this sounds reasonable |
||
private var watchers: [GraphQLQueryWatcher<Strategy.Query>] = [] | ||
private var callbackQueue: DispatchQueue | ||
let strategy: Strategy | ||
|
||
/// Designated initalizer | ||
/// - Parameters: | ||
/// - client: the Apollo client | ||
/// - callbackQueue: The `DispatchQueue` that results are returned on. | ||
/// - strategy: The `PaginationStrategy` that this watcher employs. | ||
/// - initialQuery: The `Query` that is being watched. | ||
public init( | ||
client: ApolloClientProtocol, | ||
callbackQueue: DispatchQueue = .main, | ||
strategy: Strategy, | ||
initialQuery: Strategy.Query | ||
) { | ||
self.callbackQueue = callbackQueue | ||
self.client = client | ||
self.strategy = strategy | ||
let initialWatcher = GraphQLQueryWatcher( | ||
client: client, | ||
query: initialQuery, | ||
callbackQueue: callbackQueue, | ||
resultHandler: strategy.onWatchResult(result:) | ||
) | ||
watchers = [initialWatcher] | ||
} | ||
|
||
/// Fetch the first page | ||
/// NOTE: Does not refresh subsequent pages nor remove them from the return value. | ||
public func fetch(cachePolicy: CachePolicy = .returnCacheDataAndFetch) { | ||
watchers.first?.fetch(cachePolicy: cachePolicy) | ||
} | ||
|
||
/// Fetches the first page and purges all data from subsequent pages. | ||
public func refetch(cachePolicy: CachePolicy = .fetchIgnoringCacheData) { | ||
// Reset mapping of data and order of data | ||
strategy.reset() | ||
// Remove and cancel all watchers aside from the first page | ||
guard let initialWatcher = watchers.first else { return } | ||
let subsequentWatchers = watchers.dropFirst() | ||
subsequentWatchers.forEach { $0.cancel() } | ||
watchers = [initialWatcher] | ||
initialWatcher.refetch(cachePolicy: cachePolicy) | ||
} | ||
|
||
/// Fetches the next page | ||
@discardableResult public func fetchMore( | ||
cachePolicy: CachePolicy = .fetchIgnoringCacheData, | ||
completion: (() -> Void)? = nil | ||
) -> Bool { | ||
guard strategy.canFetchNextPage(), | ||
let currentPage = strategy.currentPage | ||
else { return false } | ||
let nextPageQuery = strategy.nextPageStrategy.createNextPageQuery(page: currentPage) | ||
|
||
let nextPageWatcher = client.watch( | ||
query: nextPageQuery, | ||
cachePolicy: cachePolicy, | ||
callbackQueue: callbackQueue | ||
) { [weak self] result in | ||
self?.strategy.onWatchResult(result: result) | ||
completion?() | ||
} | ||
watchers.append(nextPageWatcher) | ||
|
||
return true | ||
} | ||
|
||
/// Refetches data for a given page. | ||
/// NOTE: Does not refresh previous or subsequent pages nor remove them from the return value. | ||
public func refresh(page: Page?, cachePolicy: CachePolicy = .returnCacheDataAndFetch) { | ||
guard let page else { | ||
// Fetch first page | ||
return fetch(cachePolicy: cachePolicy) | ||
} | ||
guard let index = strategy.pages.firstIndex(where: { $0 == page }), | ||
watchers.count > index | ||
else { return } | ||
watchers[index].fetch(cachePolicy: cachePolicy) | ||
} | ||
|
||
/// Cancel any in progress fetching operations and unsubscribe from the store. | ||
public func cancel() { | ||
watchers.forEach { $0.cancel() } | ||
} | ||
|
||
deinit { | ||
cancel() | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
#if !COCOAPODS | ||
import ApolloAPI | ||
#endif | ||
|
||
/// The strategy by which we create the next query in a series of paginated queries. | ||
public protocol NextPageStrategy { | ||
associatedtype Query: GraphQLQuery | ||
associatedtype Page: Hashable | ||
|
||
/// Given some `Page`, returns a formed `Query` that uses the information contained within the `Page` to paginate. | ||
func createNextPageQuery(page: Page) -> Query | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
#if !COCOAPODS | ||
import ApolloAPI | ||
#endif | ||
|
||
/// Can extract and identify a `Page` for cursor-based endpoints | ||
public struct OffsetPageExtractor<Query: GraphQLQuery>: PageExtractionStrategy { | ||
|
||
/// A formed input for the `OffsetPageExtractor` | ||
public struct Input: Hashable { | ||
|
||
/// The `Query.Data` | ||
public let data: Query.Data | ||
|
||
/// The current offset | ||
public let offset: Int | ||
|
||
/// The number of expected results per page | ||
public let pageSize: Int | ||
|
||
init(data: Query.Data, offset: Int, pageSize: Int) { | ||
self.data = data | ||
self.offset = offset | ||
self.pageSize = pageSize | ||
} | ||
} | ||
|
||
/// A minimal definiton for a `Page` | ||
public struct Page: Hashable { | ||
/// Where in the list the server should start when returning items for a particular query | ||
public let offset: Int | ||
|
||
/// Whether or not there is potentially another page of results | ||
public let hasNextPage: Bool | ||
|
||
/// Designated Initializer | ||
/// - Parameters: | ||
/// - offset: Where in the list the server should start when returning items for a particular query | ||
/// - hasNextPage: Whether or not there is potentially another page of results | ||
public init(offset: Int, hasNextPage: Bool) { | ||
self.offset = offset | ||
self.hasNextPage = hasNextPage | ||
} | ||
} | ||
|
||
private let _transform: (Input) -> Page | ||
|
||
/// Designated initializer | ||
/// - Parameter transform: A user provided function which can extract a `Page` from a `Query.Data` | ||
public init(transform: @escaping (Input) -> Page) { | ||
self._transform = transform | ||
} | ||
|
||
/// Convenience initializer | ||
/// - Parameter arrayKeyPath: A `KeyPath` over a `Query.Data` which identifies the array key within the `Query.Data`. | ||
public init(arrayKeyPath: KeyPath<Query.Data, [(some SelectionSet)?]?>) { | ||
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. This doesn't account for 2 things:
I'm wondering if it would work if we change this to: public init(arrayKeyPath: KeyPath<Query.Data, some Collection>) { |
||
self._transform = { input in | ||
let count = input.data[keyPath: arrayKeyPath]?.count ?? 0 | ||
return Page(offset: input.offset + count, hasNextPage: count == input.pageSize) | ||
} | ||
} | ||
|
||
/// Convenience initializer | ||
/// - Parameter arrayKeyPath: A `KeyPath` over a `Query.Data` which identifies the array key within the `Query.Data`. | ||
public init(arrayKeyPath: KeyPath<Query.Data, [(some SelectionSet)]?>) { | ||
self._transform = { input in | ||
let count = input.data[keyPath: arrayKeyPath]?.count ?? 0 | ||
return Page(offset: input.offset + count, hasNextPage: count == input.pageSize) | ||
} | ||
} | ||
|
||
/// Transforms the `Query.Data` into a `Page` by utilizing the user-provided functions or key paths. | ||
/// - Parameter input: A query response data. | ||
/// - Returns: A `Page`. | ||
public func transform(input: Input) -> Page { | ||
_transform(input) | ||
} | ||
} |
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'm not sure I see the value in including this at all. I think this goes for the
CustomDataTransformer
andCustomPaginationMergeStrategy
as well. If people need to implement a custom implementation of any of these, you should just define your own types that conforms to the protocols.I guess I see why it is ergonomically valuable to be able to use these to essentially turn the initialization of
PaginationStrategy
into closure based parameters (as is shown in the unit tests for this), so I can be persuaded here. Let's discuss this more with the team.If it makes sense, what I would like to include is an implementation of a
NextPageStrategy
that does this for you given just a query type specifically for the cursor-based and offset + count pagination methods?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.
We discussed a bit on our call.
I think we could probably collapse both
CustomNextPageStrategy
and the need for aninitialQuery
into a newQueryProvider
protocol that takes in aPage?
as an argument (as opposed to thePage
that we take in now).We could default to generating the initial query if the
Page
is nil.