Skip to content

Commit 0b4566b

Browse files
BoDmartinbonnin
andcommitted
Pagination: use "field key" instead of "field name" (apollographql#5898)
* Use "field key" instead of "field name" * Add a few cache related terms to the glossary * Apply suggestions from code review Co-authored-by: Martin Bonnin <[email protected]> --------- Co-authored-by: Martin Bonnin <[email protected]>
1 parent 0df854d commit 0b4566b

File tree

22 files changed

+280
-224
lines changed

22 files changed

+280
-224
lines changed

design-docs/Glossary.md

+25-5
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
1-
# Codegen Glossary
2-
3-
## Glossary
4-
5-
A small Glossary of the terms used during codegen. The [GraphQL Spec](https://spec.graphql.org/draft/) does a nice job of defining the common terms like `Field`, `SelectionSet`, etc... so I'm not adding these terms here. But it misses some concepts that we bumped into during codegen and that I'm trying to clarify here.
1+
# Glossary
62

3+
The [GraphQL Spec](https://spec.graphql.org/draft/) does a nice job of defining common terms like `Field`, `SelectionSet`, etc. but here are a few other concepts that the library deals with, and their definition.
74

85
### Response shape
96

@@ -105,3 +102,26 @@ Example:
105102
### Polymorphic field
106103

107104
A field that can take several shapes
105+
106+
### Record
107+
108+
A shallow map of a response object. Nested objects in the map values are replaced by a cache reference to another Record.
109+
110+
### Cache key
111+
112+
A unique identifier for a Record.
113+
By default it is the path formed by all the field keys from the root of the query to the field referencing the Record.
114+
To avoid duplication the Cache key can also be computed from the Record contents, usually using its key fields.
115+
116+
### Field key
117+
118+
A key that uniquely identifies a field within a Record. By default composed of the field name and the arguments passed to it.
119+
120+
### Key fields
121+
122+
Fields that are used to compute a Cache key for an object.
123+
124+
### Pagination arguments
125+
126+
Field arguments that control pagination, e.g. `first`, `after`, etc. They should be omitted when computing a field key so different pages can be merged into the same field.
127+

design-docs/Normalized cache overview.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ An example:
9393

9494
<table>
9595
<tr>
96-
<th>Key</th>
96+
<th>Cache key</th>
9797
<th>Record</th>
9898
</tr>
9999

@@ -217,7 +217,7 @@ An instance is created when building the `ApolloClient` and held by the `ApolloC
217217

218218
## Record merging
219219

220-
When a `Record` is stored in the cache, it is _merged_ with the existing one at the same key (if any):
220+
When a `Record` is stored in the cache, it is _merged_ with the existing one at the same cache key (if any):
221221
- existing fields are replaced with the new value
222222
- new fields are added
223223

design-docs/Normalized cache pagination.md

+4-4
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ If your schema uses a different pagination style, you can still use the paginati
210210

211211
#### Pagination arguments
212212

213-
The `@fieldPolicy` directive has a `paginationArgs` argument that can be used to specify the arguments that should be omitted from the field name.
213+
The `@fieldPolicy` directive has a `paginationArgs` argument that can be used to specify the arguments that should be omitted from the field key.
214214

215215
Going back to the example above with `usersPage`:
216216

@@ -221,7 +221,7 @@ extend type Query
221221
```
222222

223223
> [!NOTE]
224-
> This can also be done programmatically by configuring the `ApolloStore` with a `FieldNameGenerator` implementation.
224+
> This can also be done programmatically by configuring the `ApolloStore` with a `FieldKeyGenerator` implementation.
225225

226226
With that in place, after fetching the first page, the cache will look like this:
227227

@@ -231,7 +231,7 @@ With that in place, after fetching the first page, the cache will look like this
231231
| user:1 | id: 1, name: John Smith |
232232
| user:2 | id: 2, name: Jane Doe |
233233

234-
The field name no longer includes the `page` argument, which means watching `UsersPage(page = 1)` or any page will observe the same list.
234+
The field key no longer includes the `page` argument, which means watching `UsersPage(page = 1)` or any page will observe the same list.
235235

236236
Here's what happens when fetching the second page:
237237

@@ -245,7 +245,7 @@ Here's what happens when fetching the second page:
245245

246246
The field containing the first page was overwritten by the second page.
247247

248-
This is because the field name is now the same for all pages and the default merging strategy is to overwrite existing fields with the new value.
248+
This is because the field key is now the same for all pages and the default merging strategy is to overwrite existing fields with the new value.
249249

250250
#### Record merging
251251

intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/NormalizedCache.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ data class NormalizedCache(
1010
)
1111

1212
data class Field(
13-
val name: String,
13+
val key: String,
1414
val value: FieldValue,
1515
)
1616

intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/provider/DatabaseNormalizedCacheProvider.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ class DatabaseNormalizedCacheProvider : NormalizedCacheProvider<File> {
3939
apolloRecords.map { (key, apolloRecord) ->
4040
NormalizedCache.Record(
4141
key = key,
42-
fields = apolloRecord.map { (fieldName, fieldValue) ->
43-
Field(fieldName, fieldValue.toFieldValue())
42+
fields = apolloRecord.map { (fieldKey, fieldValue) ->
43+
Field(fieldKey, fieldValue.toFieldValue())
4444
},
4545
sizeInBytes = apolloRecord.sizeInBytes
4646
)

intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/FieldTreeTable.kt

+8-5
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,14 @@ class FieldTreeTable(selectRecord: (String) -> Unit) : JBTreeTable(FieldTreeTabl
4040
is NormalizedCache.FieldValue.StringValue -> append("\"${v.value}\"")
4141
is NormalizedCache.FieldValue.NumberValue -> append(v.value.toString())
4242
is NormalizedCache.FieldValue.BooleanValue -> append(v.value.toString())
43-
is NormalizedCache.FieldValue.ListValue -> append(when (val size = v.value.size) {
44-
0 -> ApolloBundle.message("normalizedCacheViewer.fields.list.empty")
45-
1 -> ApolloBundle.message("normalizedCacheViewer.fields.list.single")
46-
else -> ApolloBundle.message("normalizedCacheViewer.fields.list.multiple", size)
47-
}, SimpleTextAttributes.GRAY_ITALIC_ATTRIBUTES)
43+
is NormalizedCache.FieldValue.ListValue -> append(
44+
when (val size = v.value.size) {
45+
0 -> ApolloBundle.message("normalizedCacheViewer.fields.list.empty")
46+
1 -> ApolloBundle.message("normalizedCacheViewer.fields.list.single")
47+
else -> ApolloBundle.message("normalizedCacheViewer.fields.list.multiple", size)
48+
},
49+
SimpleTextAttributes.GRAY_ITALIC_ATTRIBUTES
50+
)
4851

4952
is NormalizedCache.FieldValue.CompositeValue -> append("{...}", SimpleTextAttributes.GRAY_ITALIC_ATTRIBUTES)
5053
NormalizedCache.FieldValue.Null -> append("null")

intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/FieldTreeTableModel.kt

+3-2
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ class FieldTreeTableModel : ListTreeTableModel(
1414
override fun getColumnClass() = TreeTableModel::class.java
1515
override fun valueOf(item: Unit) = Unit
1616
},
17-
object : ColumnInfo<NormalizedCacheFieldTreeNode, NormalizedCache.Field>(ApolloBundle.message("normalizedCacheViewer.fields.column.value")) {
17+
object :
18+
ColumnInfo<NormalizedCacheFieldTreeNode, NormalizedCache.Field>(ApolloBundle.message("normalizedCacheViewer.fields.column.value")) {
1819
override fun getColumnClass() = NormalizedCache.Field::class.java
1920
override fun valueOf(item: NormalizedCacheFieldTreeNode) = item.field
2021
},
@@ -42,7 +43,7 @@ class FieldTreeTableModel : ListTreeTableModel(
4243

4344
class NormalizedCacheFieldTreeNode(val field: NormalizedCache.Field) : DefaultMutableTreeNode() {
4445
init {
45-
userObject = field.name
46+
userObject = field.key
4647
}
4748
}
4849
}

intellij-plugin/src/main/resources/messages/ApolloBundle.properties

+2-2
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ errorReport.actionText=Open GitHub Issue
191191
toolwindow.stripe.NormalizedCacheViewer=Apollo Normalized Cache
192192
normalizedCacheViewer.newTab=New Tab
193193
normalizedCacheViewer.tabName.empty=Empty
194-
normalizedCacheViewer.fields.column.key=Key
194+
normalizedCacheViewer.fields.column.key=Field Key
195195
normalizedCacheViewer.fields.column.value=Value
196196
normalizedCacheViewer.toolbar.expandAll=Expand all keys
197197
normalizedCacheViewer.toolbar.collapseAll=Collapse all keys
@@ -201,7 +201,7 @@ normalizedCacheViewer.toolbar.refresh=Refresh
201201
normalizedCacheViewer.empty.message=Open or drag and drop a normalized cache .db file.
202202
normalizedCacheViewer.empty.openFile=Open file...
203203
normalizedCacheViewer.empty.pullFromDevice=Pull from device
204-
normalizedCacheViewer.records.table.key=Key
204+
normalizedCacheViewer.records.table.key=Cache Key
205205
normalizedCacheViewer.records.table.size=Size
206206
normalizedCacheViewer.fields.list.empty=[empty]
207207
normalizedCacheViewer.fields.list.single=[1 item]

libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/CompiledGraphQL.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ class CompiledField internal constructor(
6868

6969
val value = argument.value.getOrThrow()
7070
return if (value is CompiledVariable) {
71-
if (variables.valueMap.containsKey(value.name)) {
71+
if (variables.valueMap.containsKey(value.name)) {
7272
Optional.present(variables.valueMap[value.name])
7373
} else {
7474
// this argument has a variable value that is absent
@@ -100,7 +100,7 @@ class CompiledField internal constructor(
100100
/**
101101
* Returns a String containing the name of this field as well as encoded arguments. For an example:
102102
* `hero({"episode": "Jedi"})`
103-
* This is mostly used internally to compute records.
103+
* This is mostly used internally to compute field keys / cache keys.
104104
*
105105
* ## Note1:
106106
* The argument defaultValues are not added to the name. If the schema changes from:

libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/exception/Exceptions.kt

+18-11
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ sealed class ApolloException(message: String? = null, cause: Throwable? = null)
1717
/**
1818
* A generic exception used when there is no additional context besides the message.
1919
*/
20-
class DefaultApolloException(message: String? = null, cause: Throwable? = null): ApolloException(message, cause)
20+
class DefaultApolloException(message: String? = null, cause: Throwable? = null) : ApolloException(message, cause)
2121

2222
/**
2323
* No data was found
2424
*/
25-
class NoDataException(cause: Throwable?): ApolloException("No data was found", cause)
25+
class NoDataException(cause: Throwable?) : ApolloException("No data was found", cause)
2626

2727
/**
2828
* An I/O error happened: socket closed, DNS issue, TLS problem, file not found, etc...
@@ -118,7 +118,7 @@ class JsonDataException(message: String) : ApolloException(message)
118118
*
119119
* Due to the way the parsers work, it is not possible to distinguish between both cases.
120120
*/
121-
class NullOrMissingField(message: String): ApolloException(message)
121+
class NullOrMissingField(message: String) : ApolloException(message)
122122

123123
/**
124124
* The response could not be parsed because of an I/O exception.
@@ -132,8 +132,8 @@ class NullOrMissingField(message: String): ApolloException(message)
132132
@Deprecated("ApolloParseException was only used for I/O exceptions and is now mapped to ApolloNetworkException.")
133133
class ApolloParseException(message: String? = null, cause: Throwable? = null) : ApolloException(message = message, cause = cause)
134134

135-
class ApolloGraphQLException(val error: Error): ApolloException("GraphQL error: '${error.message}'") {
136-
constructor(errors: List<Error>): this(errors.first())
135+
class ApolloGraphQLException(val error: Error) : ApolloException("GraphQL error: '${error.message}'") {
136+
constructor(errors: List<Error>) : this(errors.first())
137137

138138
@Deprecated("Use error instead", level = DeprecationLevel.ERROR)
139139
val errors: List<Error> = listOf(error)
@@ -143,10 +143,17 @@ class ApolloGraphQLException(val error: Error): ApolloException("GraphQL error:
143143
* An object/field was missing in the cache
144144
* If [fieldName] is null, it means a reference to an object could not be resolved
145145
*/
146-
147146
class CacheMissException @ApolloInternal constructor(
147+
/**
148+
* The cache key to the missing object, or to the parent of the missing field if [fieldName] is not null.
149+
*/
148150
val key: String,
151+
152+
/**
153+
* The field key that was missing. If null, it means the object referenced by [key] was missing.
154+
*/
149155
val fieldName: String? = null,
156+
150157
stale: Boolean = false,
151158
) : ApolloException(message = message(key, fieldName, stale)) {
152159

@@ -156,14 +163,14 @@ class CacheMissException @ApolloInternal constructor(
156163
constructor(key: String, fieldName: String?) : this(key, fieldName, false)
157164

158165
companion object {
159-
internal fun message(key: String?, fieldName: String?, stale: Boolean): String {
160-
return if (fieldName == null) {
161-
"Object '$key' not found"
166+
internal fun message(cacheKey: String?, fieldKey: String?, stale: Boolean): String {
167+
return if (fieldKey == null) {
168+
"Object '$cacheKey' not found"
162169
} else {
163170
if (stale) {
164-
"Field '$fieldName' on object '$key' is stale"
171+
"Field '$fieldKey' on object '$cacheKey' is stale"
165172
} else {
166-
"Object '$key' has no field named '$fieldName'"
173+
"Object '$cacheKey' has no field named '$fieldKey'"
167174
}
168175
}
169176
}

libraries/apollo-normalized-cache-api-incubating/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/api/CacheResolver.kt

+24-26
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ interface CacheResolver {
6565
* @param variables the variables of the current operation
6666
* @param parent the parent object as a map. It can contain the same values as [Record]. Especially, nested objects will be represented
6767
* by [CacheKey]
68-
* @param parentId the id of the parent. Mainly used for debugging
68+
* @param parentId the key of the parent. Mainly used for debugging
6969
*
7070
* @return a value that can go in a [Record]. No type checking is done. It is the responsibility of implementations to return the correct
7171
* type
@@ -83,10 +83,10 @@ class ResolverContext(
8383
val field: CompiledField,
8484
val variables: Executable.Variables,
8585
val parent: Map<String, @JvmSuppressWildcards Any?>,
86-
val parentId: String,
86+
val parentKey: String,
8787
val parentType: String,
8888
val cacheHeaders: CacheHeaders,
89-
val fieldNameGenerator: FieldNameGenerator,
89+
val fieldKeyGenerator: FieldKeyGenerator,
9090
)
9191

9292
/**
@@ -112,12 +112,12 @@ object DefaultCacheResolver : CacheResolver {
112112
parent: Map<String, @JvmSuppressWildcards Any?>,
113113
parentId: String,
114114
): Any? {
115-
val name = field.nameWithArguments(variables)
116-
if (!parent.containsKey(name)) {
117-
throw CacheMissException(parentId, name)
115+
val fieldKey = field.nameWithArguments(variables)
116+
if (!parent.containsKey(fieldKey)) {
117+
throw CacheMissException(parentId, fieldKey)
118118
}
119119

120-
return parent[name]
120+
return parent[fieldKey]
121121
}
122122
}
123123

@@ -126,12 +126,12 @@ object DefaultCacheResolver : CacheResolver {
126126
*/
127127
object DefaultApolloResolver : ApolloResolver {
128128
override fun resolveField(context: ResolverContext): Any? {
129-
val name = context.fieldNameGenerator.getFieldName(FieldNameContext(context.parentType, context.field, context.variables))
130-
if (!context.parent.containsKey(name)) {
131-
throw CacheMissException(context.parentId, name)
129+
val fieldKey = context.fieldKeyGenerator.getFieldKey(FieldKeyContext(context.parentType, context.field, context.variables))
130+
if (!context.parent.containsKey(fieldKey)) {
131+
throw CacheMissException(context.parentKey, fieldKey)
132132
}
133133

134-
return context.parent[name]
134+
return context.parent[fieldKey]
135135
}
136136
}
137137

@@ -142,31 +142,29 @@ object DefaultApolloResolver : ApolloResolver {
142142
class ReceiveDateApolloResolver(private val maxAge: Int) : ApolloResolver {
143143

144144
override fun resolveField(context: ResolverContext): Any? {
145-
val field = context.field
146145
val parent = context.parent
147-
val variables = context.variables
148-
val parentId = context.parentId
146+
val parentKey = context.parentKey
149147

150-
val name = field.nameWithArguments(variables)
151-
if (!parent.containsKey(name)) {
152-
throw CacheMissException(parentId, name)
148+
val fieldKey = context.fieldKeyGenerator.getFieldKey(FieldKeyContext(context.parentType, context.field, context.variables))
149+
if (!parent.containsKey(fieldKey)) {
150+
throw CacheMissException(parentKey, fieldKey)
153151
}
154152

155153
if (parent is Record) {
156-
val lastUpdated = parent.dates?.get(name)
154+
val lastUpdated = parent.dates?.get(fieldKey)
157155
if (lastUpdated != null) {
158156
val maxStale = context.cacheHeaders.headerValue(ApolloCacheHeaders.MAX_STALE)?.toLongOrNull() ?: 0L
159157
if (maxStale < Long.MAX_VALUE) {
160158
val age = currentTimeMillis() / 1000 - lastUpdated
161159
if (maxAge + maxStale - age < 0) {
162-
throw CacheMissException(parentId, name, true)
160+
throw CacheMissException(parentKey, fieldKey, true)
163161
}
164162

165163
}
166164
}
167165
}
168166

169-
return parent[name]
167+
return parent[fieldKey]
170168
}
171169
}
172170

@@ -184,21 +182,21 @@ class ExpireDateCacheResolver : CacheResolver {
184182
parent: Map<String, @JvmSuppressWildcards Any?>,
185183
parentId: String,
186184
): Any? {
187-
val name = field.nameWithArguments(variables)
188-
if (!parent.containsKey(name)) {
189-
throw CacheMissException(parentId, name)
185+
val fieldKey = field.nameWithArguments(variables)
186+
if (!parent.containsKey(fieldKey)) {
187+
throw CacheMissException(parentId, fieldKey)
190188
}
191189

192190
if (parent is Record) {
193-
val expires = parent.dates?.get(name)
191+
val expires = parent.dates?.get(fieldKey)
194192
if (expires != null) {
195193
if (currentTimeMillis() / 1000 - expires >= 0) {
196-
throw CacheMissException(parentId, name, true)
194+
throw CacheMissException(parentId, fieldKey, true)
197195
}
198196
}
199197
}
200198

201-
return parent[name]
199+
return parent[fieldKey]
202200
}
203201
}
204202

0 commit comments

Comments
 (0)