Skip to content

Commit 2bc7f3a

Browse files
committed
Improve documentation on batch loading
Closes gh-1168
1 parent b0499c3 commit 2bc7f3a

File tree

7 files changed

+342
-4
lines changed

7 files changed

+342
-4
lines changed

spring-graphql-docs/modules/ROOT/pages/controllers.adoc

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,15 @@ the simple class name of the input `List` element type. Both can be customized t
754754
annotation attributes. The type name can also be inherited from a class level
755755
`@SchemaMapping`.
756756

757+
[WARNING]
758+
====
759+
`@BatchMapping` is effectively a "shortcut" for the straightforward cases, when using `BatchLoaderRegistry`
760+
adds too much boilerplate for no real benefit.
761+
If your use case requires more information about the parent `@SchemaMapping` call (such as `@Argument` or context data),
762+
for example for filtering the entities to load, using the `BatchLoaderRegistry` is required.
763+
Please refer to the xref:request-execution.adoc#execution.batching.recipes[Batch Loading Recipes] section.
764+
====
765+
757766

758767
[[controllers.batch-mapping.signature]]
759768
=== Method Arguments
@@ -787,7 +796,8 @@ Batch mapping methods support the following arguments:
787796
methods through the `DataFetchingEnvironment`.
788797

789798
The `keyContexts` property of `BatchLoaderEnvironment` returns the
790-
localContext obtained from the `DataFetchingEnvironment` when the
799+
xref:controllers.adoc#controllers.schema-mapping.localcontext[localContext]
800+
obtained from the `DataFetchingEnvironment` when the
791801
`DataLoader` was called for each key.
792802

793803
|===

spring-graphql-docs/modules/ROOT/pages/request-execution.adoc

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -743,10 +743,10 @@ You can find the full details in the
743743
{graphql-java-docs}/batching/[GraphQL Java docs]. Below is a
744744
summary of how it works:
745745

746-
1. Register ``DataLoader``'s in the `DataLoaderRegistry` that can load entities, given unique keys.
747-
2. ``DataFetcher``'s can access ``DataLoader``'s and use them to load entities by id.
746+
1. Register ``DataLoader``s in the `DataLoaderRegistry` that can load entities, given unique keys.
747+
2. ``DataFetcher``s can access ``DataLoader``s and use them to load entities by id.
748748
3. A `DataLoader` defers loading by returning a future so it can be done in a batch.
749-
4. ``DataLoader``'s maintain a per request cache of loaded entities that can further
749+
4. ``DataLoader``s maintain a per request cache of loaded entities that can further
750750
improve efficiency.
751751

752752

@@ -804,6 +804,59 @@ to use it. It is possible to perform your own `DataLoader` registrations directl
804804
such registrations would forgo the above benefits.
805805

806806

807+
808+
[[execution.batching.recipes]]
809+
=== Batch Loading Recipes
810+
811+
For straightforward cases, the xref:controllers.adoc#controllers.batch-mapping[@BatchMapping] annotation is often
812+
the best choice, with minimal boilerplate. For more advanced use cases, the `BatchLoaderRegistry` offers more flexibility.
813+
814+
xref:request-execution.adoc#execution.batching.dataloader[As outlined above], ``DataLoader``s will queue `load()` calls
815+
and might dispatch them all at once, or in batches. This means that a single dispatch can load entities for different
816+
`@SchemaMapping` calls and different GraphQL contexts. Because loaded entities will be cached by their key by GraphQL Java,
817+
for the entire lifetime of the request, developers should consider different strategies to optimize memory consumption vs. number of I/O calls.
818+
819+
For the next section, we will consider the following schema for loading information about friends.
820+
Notice that we can filter friends and only load friends with a particular favorite beverage.
821+
822+
[source,graphql,subs="verbatim,quotes"]
823+
----
824+
include::ROOT:{include-resources}/execution/batching/recipes/friendsAndBeverages.graphqls[]
825+
----
826+
827+
We can approach this problem by first loading all friends for a given person in the `DataLoader`,
828+
then filter out unnecessary ones at the `@SchemaMapping` level. This will load more `Person` instances
829+
in the `DataLoader` cache and use more memory, but it's likely to perform less I/O calls.
830+
831+
include-code::FriendsControllerFiltering[tag=sample]
832+
<1> fetch all friends and do not apply filter, caching Person by their id
833+
<2> load all friends, then apply the given filter
834+
835+
This is well suited for small groups of well-connected friends and for popular beverages.
836+
If instead we're dealing with large groups of friends and few friends in common, or more niche beverages,
837+
we risk loading large amounts of data in memory for just a few entries actually sent to the client.
838+
839+
Here, we can use a different strategy by batch loading entities with a composed key: the person and the chosen filter.
840+
This approach will load just enough entities in memory, at the cost of possible duplicates `Person` in the cache
841+
and more I/O operations.
842+
843+
include-code::FriendsControllerComposedKey[tag=sample]
844+
<1> because this key contains both the person and the filter, we will need to fetch the same friend multiple times
845+
846+
In both cases, the query:
847+
848+
[source,graphql,subs="verbatim,quotes"]
849+
----
850+
include::ROOT:{include-resources}/execution/batching/recipes/query.graphql[]
851+
----
852+
853+
Will yield the following result:
854+
855+
[source,json,subs="verbatim,quotes"]
856+
----
857+
include::ROOT:{include-resources}/execution/batching/recipes/result.json[]
858+
----
859+
807860
[[execution.batching.testing]]
808861
=== Testing Batch Loading
809862

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Copyright 2025-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.graphql.docs.execution.batching.recipes;
18+
19+
import java.util.Collection;
20+
import java.util.HashMap;
21+
import java.util.List;
22+
import java.util.Map;
23+
import java.util.concurrent.CompletableFuture;
24+
25+
import org.dataloader.DataLoader;
26+
import reactor.core.publisher.Mono;
27+
28+
import org.springframework.graphql.data.method.annotation.Argument;
29+
import org.springframework.graphql.data.method.annotation.QueryMapping;
30+
import org.springframework.graphql.data.method.annotation.SchemaMapping;
31+
import org.springframework.graphql.execution.BatchLoaderRegistry;
32+
import org.springframework.stereotype.Controller;
33+
34+
@Controller
35+
public class FriendsControllerComposedKey {
36+
37+
private final Map<Integer, Person> people = Map.of(
38+
1, new Person(1, "Rossen", "coffee", List.of(2, 3)),
39+
2, new Person(2, "Brian", "tea", List.of(1, 3)),
40+
3, new Person(3, "Donna", "tea", List.of(1, 2, 4)),
41+
4, new Person(4, "Brad", "coffee", List.of(1, 2, 3, 5)),
42+
5, new Person(5, "Andi", "coffee", List.of(1, 2, 3, 4))
43+
);
44+
45+
// tag::sample[]
46+
public FriendsControllerComposedKey(BatchLoaderRegistry registry) {
47+
registry.forTypePair(FriendFilterKey.class, Person[].class).registerMappedBatchLoader((keys, env) -> {
48+
// @fold:on return dataStore.load(keys);
49+
Map<FriendFilterKey, Person[]> result = new HashMap<>();
50+
keys.forEach((key) -> { // <2>
51+
Person[] friends = key.person().friendsId().stream()
52+
.map(this.people::get)
53+
.filter((friend) -> key.friendsFilter().matches(friend))
54+
.toArray(Person[]::new);
55+
result.put(key, friends);
56+
});
57+
return Mono.just(result);
58+
// @fold:off
59+
});
60+
}
61+
62+
@QueryMapping
63+
public Person me() {
64+
return /**/ this.people.get(2);
65+
}
66+
67+
@QueryMapping
68+
public Collection<Person> people() {
69+
return /**/ this.people.values();
70+
}
71+
72+
@SchemaMapping
73+
public CompletableFuture<Person[]> friends(Person person, @Argument FriendsFilter filter, DataLoader<FriendFilterKey, Person[]> dataLoader) {
74+
return dataLoader.load(new FriendFilterKey(person, filter));
75+
}
76+
77+
public record FriendsFilter(String favoriteBeverage) {
78+
boolean matches(Person friend) {
79+
return friend.favoriteBeverage.equals(this.favoriteBeverage);
80+
}
81+
}
82+
83+
public record FriendFilterKey(Person person, FriendsFilter friendsFilter) { // <1>
84+
}
85+
86+
// end::sample[]
87+
88+
public record Person(Integer id, String name, String favoriteBeverage, List<Integer> friendsId) {
89+
}
90+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright 2025-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.graphql.docs.execution.batching.recipes;
18+
19+
20+
import java.util.Collection;
21+
import java.util.HashMap;
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.concurrent.CompletableFuture;
25+
26+
import org.dataloader.DataLoader;
27+
import reactor.core.publisher.Mono;
28+
29+
import org.springframework.graphql.data.method.annotation.Argument;
30+
import org.springframework.graphql.data.method.annotation.QueryMapping;
31+
import org.springframework.graphql.data.method.annotation.SchemaMapping;
32+
import org.springframework.graphql.execution.BatchLoaderRegistry;
33+
import org.springframework.stereotype.Controller;
34+
35+
@Controller
36+
public class FriendsControllerFiltering {
37+
38+
private final Map<Integer, Person> people = Map.of(
39+
1, new Person(1, "Rossen", "coffee", List.of(2, 3)),
40+
2, new Person(2, "Brian", "tea", List.of(1, 3)),
41+
3, new Person(3, "Donna", "tea", List.of(1, 2, 4)),
42+
4, new Person(4, "Brad", "coffee", List.of(1, 2, 3, 5)),
43+
5, new Person(5, "Andi", "coffee", List.of(1, 2, 3, 4))
44+
);
45+
46+
// tag::sample[]
47+
48+
public FriendsControllerFiltering(BatchLoaderRegistry registry) {
49+
registry.forTypePair(Integer.class, Person.class).registerMappedBatchLoader((personIds, env) -> {
50+
Map<Integer, Person> friends = new HashMap<>();
51+
personIds.forEach((personId) -> friends.put(personId, this.people.get(personId))); // <1>
52+
return Mono.just(friends);
53+
});
54+
}
55+
56+
@QueryMapping
57+
public Person me() {
58+
return /**/ this.people.get(2);
59+
}
60+
61+
@QueryMapping
62+
public Collection<Person> people() {
63+
return /**/ this.people.values();
64+
}
65+
66+
@SchemaMapping
67+
public CompletableFuture<List<Person>> friends(Person person, @Argument FriendsFilter filter, DataLoader<Integer, Person> dataLoader) {
68+
return dataLoader
69+
.loadMany(person.friendsId())
70+
.thenApply(filter::apply); // <2>
71+
}
72+
73+
public record FriendsFilter(String favoriteBeverage) {
74+
75+
List<Person> apply(List<Person> friends) {
76+
return friends.stream()
77+
.filter((person) -> person.favoriteBeverage.equals(this.favoriteBeverage))
78+
.toList();
79+
}
80+
}
81+
82+
// end::sample[]
83+
84+
public record Person(Integer id, String name, String favoriteBeverage, List<Integer> friendsId) {
85+
}
86+
87+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
type Query {
2+
me: Person
3+
people: [Person]
4+
}
5+
6+
input FriendsFilter {
7+
favoriteBeverage: String
8+
}
9+
10+
type Person {
11+
id: ID!
12+
name: String
13+
favoriteBeverage: String
14+
friends(filter: FriendsFilter): [Person]
15+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
query {
2+
me {
3+
name
4+
friends(filter: {favoriteBeverage: "tea"}) {
5+
name
6+
favoriteBeverage
7+
}
8+
}
9+
people {
10+
name
11+
friends(filter: {favoriteBeverage: "coffee"}) {
12+
name
13+
favoriteBeverage
14+
}
15+
}
16+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
{
2+
"data": {
3+
"me": {
4+
"name": "Brian",
5+
"friends": [
6+
{
7+
"name": "Donna",
8+
"favoriteBeverage": "tea"
9+
}
10+
]
11+
},
12+
"people": [
13+
{
14+
"name": "Andi",
15+
"friends": [
16+
{
17+
"name": "Rossen",
18+
"favoriteBeverage": "coffee"
19+
},
20+
{
21+
"name": "Brad",
22+
"favoriteBeverage": "coffee"
23+
}
24+
]
25+
},
26+
{
27+
"name": "Brad",
28+
"friends": [
29+
{
30+
"name": "Rossen",
31+
"favoriteBeverage": "coffee"
32+
},
33+
{
34+
"name": "Andi",
35+
"favoriteBeverage": "coffee"
36+
}
37+
]
38+
},
39+
{
40+
"name": "Donna",
41+
"friends": [
42+
{
43+
"name": "Rossen",
44+
"favoriteBeverage": "coffee"
45+
},
46+
{
47+
"name": "Brad",
48+
"favoriteBeverage": "coffee"
49+
}
50+
]
51+
},
52+
{
53+
"name": "Brian",
54+
"friends": [
55+
{
56+
"name": "Rossen",
57+
"favoriteBeverage": "coffee"
58+
}
59+
]
60+
},
61+
{
62+
"name": "Rossen",
63+
"friends": []
64+
}
65+
]
66+
}
67+
}

0 commit comments

Comments
 (0)