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

[Pagination] Support nodes in Connection types #5754

Merged
merged 2 commits into from
Mar 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 4 additions & 2 deletions design-docs/Normalized cache pagination.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,10 @@ type UserConnection {
}

type PageInfo {
startCursor: String!
endCursor: String!
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}

type UserEdge {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ internal fun GQLInterfaceTypeDefinition.toIr(schema: Schema, usedFields: Map<Str
*/
private fun connectionTypeEmbeddedFields(typeName: String, schema: Schema): Set<String> {
return if (typeName in schema.connectionTypes) {
setOf("edges")
setOf("edges", "pageInfo")
} else {
emptySet()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,14 @@ class ConnectionMetadataGenerator(private val connectionTypes: Set<String>) : Me
override fun metadataForObject(obj: Any?, context: MetadataGeneratorContext): Map<String, Any?> {
if (context.field.type.rawType().name in connectionTypes) {
obj as Map<String, Any?>
val edges = obj["edges"] as List<Map<String, Any?>>
val startCursor = edges.firstOrNull()?.get("cursor") as String?
val endCursor = edges.lastOrNull()?.get("cursor") as String?
val pageInfo = obj["pageInfo"] as? Map<String, Any?>
val edges = obj["edges"] as? List<Map<String, Any?>>
if (edges == null && pageInfo == null) {
return emptyMap()
}
// Get start and end cursors from the PageInfo, if present in the selection. Else, get it from the first and last edges.
val startCursor = pageInfo?.get("startCursor") as String? ?: edges?.firstOrNull()?.get("cursor") as String?
val endCursor = pageInfo?.get("endCursor") as String? ?: edges?.lastOrNull()?.get("cursor") as String?
return mapOf(
"startCursor" to startCursor,
"endCursor" to endCursor,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ class FieldRecordMerger(private val fieldMerger: FieldMerger) : RecordMerger {
}
}

/**
* A [RecordMerger] that merges lists in Relay style Connection types.
*
* It will merge the `edges` and `nodes` lists and update the `pageInfo` field.
*
* If the incoming data can't be merged with the existing data, it will replace the existing data.
*
* Note: although `nodes` is not a standard field in Relay, it is often used - see
* [this issue on the Relay spec](https://github.com/facebook/relay/issues/3850) that discusses this pattern.
*/
@ApolloExperimental
val ConnectionRecordMerger = FieldRecordMerger(ConnectionFieldMerger)

Expand All @@ -111,41 +121,95 @@ private object ConnectionFieldMerger : FieldRecordMerger.FieldMerger {
return if (incomingBeforeArgument == null && incomingAfterArgument == null) {
martinbonnin marked this conversation as resolved.
Show resolved Hide resolved
// Not a pagination query
incoming
} else if (existingStartCursor == null || existingEndCursor == null) {
} else if (existingStartCursor == null && existingEndCursor == null) {
// Existing is empty
incoming
} else if (incomingStartCursor == null || incomingEndCursor == null) {
} else if (incomingStartCursor == null && incomingEndCursor == null) {
// Incoming is empty
existing
} else {
val existingValue = existing.value as Map<String, Any?>
val existingList = existingValue["edges"] as List<*>
val incomingList = (incoming.value as Map<String, Any?>)["edges"] as List<*>

val mergedList: List<*>
val newStartCursor: String
val newEndCursor: String
val existingEdges = existingValue["edges"] as? List<*>
val existingNodes = existingValue["nodes"] as? List<*>
val existingPageInfo = existingValue["pageInfo"] as? Map<String, Any?>
val existingHasPreviousPage = existingPageInfo?.get("hasPreviousPage") as? Boolean
val existingHasNextPage = existingPageInfo?.get("hasNextPage") as? Boolean

val incomingValue = incoming.value as Map<String, Any?>
val incomingEdges = incomingValue["edges"] as? List<*>
val incomingNodes = incomingValue["nodes"] as? List<*>
val incomingPageInfo = incomingValue["pageInfo"] as? Map<String, Any?>
val incomingHasPreviousPage = incomingPageInfo?.get("hasPreviousPage") as? Boolean
val incomingHasNextPage = incomingPageInfo?.get("hasNextPage") as? Boolean

val mergedEdges: List<*>?
val mergedNodes: List<*>?
val mergedStartCursor: String?
val mergedEndCursor: String?
val mergedHasPreviousPage: Boolean?
val mergedHasNextPage: Boolean?
if (incomingAfterArgument == existingEndCursor) {
mergedList = existingList + incomingList
newStartCursor = existingStartCursor
newEndCursor = incomingEndCursor
// Append to the end
mergedStartCursor = existingStartCursor
mergedEndCursor = incomingEndCursor
mergedEdges = if (existingEdges == null || incomingEdges == null) {
null
} else {
existingEdges + incomingEdges
}
mergedNodes = if (existingNodes == null || incomingNodes == null) {
null
} else {
existingNodes + incomingNodes
}
mergedHasPreviousPage = existingHasPreviousPage
mergedHasNextPage = incomingHasNextPage
} else if (incomingBeforeArgument == existingStartCursor) {
mergedList = incomingList + existingList
newStartCursor = incomingStartCursor
newEndCursor = existingEndCursor
// Prepend to the start
mergedStartCursor = incomingStartCursor
mergedEndCursor = existingEndCursor
mergedEdges = if (existingEdges == null || incomingEdges == null) {
null
} else {
incomingEdges + existingEdges
}
mergedNodes = if (existingNodes == null || incomingNodes == null) {
null
} else {
incomingNodes + existingNodes
}
mergedHasPreviousPage = incomingHasPreviousPage
mergedHasNextPage = existingHasNextPage
} else {
// We received a list which is neither the previous nor the next page.
// Handle this case by resetting the cache with this page
mergedList = incomingList
newStartCursor = incomingStartCursor
newEndCursor = incomingEndCursor
mergedStartCursor = incomingStartCursor
mergedEndCursor = incomingEndCursor
mergedEdges = incomingEdges
mergedNodes = incomingNodes
mergedHasPreviousPage = incomingHasPreviousPage
mergedHasNextPage = incomingHasNextPage
}

val mergedFieldValue = existingValue.toMutableMap()
mergedFieldValue["edges"] = mergedList
val mergedPageInfo: Map<String, Any?>? = if (existingPageInfo == null && incomingPageInfo == null) {
null
} else {
(existingPageInfo.orEmpty() + incomingPageInfo.orEmpty()).toMutableMap().also { mergedPageInfo ->
if (mergedHasNextPage != null) mergedPageInfo["hasNextPage"] = mergedHasNextPage
if (mergedHasPreviousPage != null) mergedPageInfo["hasPreviousPage"] = mergedHasPreviousPage
if (mergedStartCursor != null) mergedPageInfo["startCursor"] = mergedStartCursor
if (mergedEndCursor != null) mergedPageInfo["endCursor"] = mergedEndCursor
}
}

val mergedValue = (existingValue + incomingValue).toMutableMap()
if (mergedEdges != null) mergedValue["edges"] = mergedEdges
if (mergedNodes != null) mergedValue["nodes"] = mergedNodes
if (mergedPageInfo != null) mergedValue["pageInfo"] = mergedPageInfo

FieldRecordMerger.FieldInfo(
value = mergedFieldValue,
metadata = mapOf("startCursor" to newStartCursor, "endCursor" to newEndCursor)
value = mergedValue,
metadata = mapOf("startCursor" to mergedStartCursor, "endCursor" to mergedEndCursor)
)
}
}
Expand Down
6 changes: 6 additions & 0 deletions tests/pagination/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,10 @@ apollo {
@OptIn(ApolloExperimental::class)
generateDataBuilders.set(true)
}
service("pagination.connectionWithNodes") {
packageName.set("pagination.connectionWithNodes")
srcDir("src/commonMain/graphql/pagination/connectionWithNodes")
@OptIn(ApolloExperimental::class)
generateDataBuilders.set(true)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ type UserConnection {
}

type PageInfo {
startCursor: String!
endCursor: String!
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}

type UserEdge {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
extend schema @link(url: "https://specs.apollo.dev/kotlin_labs/v0.2", import: ["@typePolicy", "@fieldPolicy"])

extend type Query @typePolicy(connectionFields: "users")

extend type User @typePolicy(keyFields: "id")
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
query Users($first: Int, $after: String, $last: Int, $before: String) {
users(first: $first, after: $after, last: $last, before: $before) {
pageInfo {
startCursor
endCursor
}
nodes {
id
name
email
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
type Query {
users(first: Int = 10, after: String = null, last: Int = null, before: String = null): UserConnection!
}

# In this schema, Connection types have a `nodes` field in addition to the `edges` field.
# This can simplify accessing the data. The GitHub API uses this pattern for example.
# See [this issue on the Relay spec](https://github.com/facebook/relay/issues/3850) that discusses this.
type UserConnection {
pageInfo: PageInfo!
edges: [UserEdge!]!
nodes: [User!]!
}

type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}

type UserEdge {
cursor: String!
node: User!
}

type User {
id: ID!
name: String!
email: String!
admin: Boolean
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import pagination.connection.type.buildUserEdge
import kotlin.test.Test
import kotlin.test.assertEquals

class TypePolicyConnectionFieldsTest {
class ConnectionPaginationTest {
@Test
fun typePolicyConnectionFieldsMemoryCache() {
typePolicyConnectionFields(MemoryCacheFactory())
Expand Down
Loading
Loading