Skip to content

Commit

Permalink
[Pagination] Support nodes in Connection types (#5754)
Browse files Browse the repository at this point in the history
* Support nodes in Connection types

* Add a reference to the Relay spec issue about nodes
  • Loading branch information
BoD authored Mar 25, 2024
1 parent 3481de4 commit 01e6ca5
Show file tree
Hide file tree
Showing 11 changed files with 427 additions and 30 deletions.
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) {
// 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

0 comments on commit 01e6ca5

Please sign in to comment.