Solving the N+1 problem
When fetching nested fields, a specific resolvers could be executed multiple times for each request since the parent object will execute it for all its children. This may pose a problem when the resolver has to do non-trivial work for each execution. For example, retrieving a row from a database. To solve this problem, Leto provides you with two tools: LookAhead and DataLoader.
LookAhead (Eager loading)
You can mitigate the N+1 problem by fetching all the necessary information from the parent's resolver so that when the nested fields are executed they just return the previously fetch items. This would prevent all SQL queries for nested fields since the parent resolver has all the information about the selected nested fields and can use this to execute a request that fetches the necessary columns or joins.
()
class Model {
final String id;
final String name;
final NestedModel? nested;
const Model(this.id, this.name, this.nested);
}
()
class NestedModel {
final String id;
final String name;
const NestedModel(this.id, this.name);
}
final modelRepo = ScopedRef.global(
(ScopedHolder scope) => ModelRepo();
);
class ModelRepo {
List<Model> getModels({bool withNested = false}) {
// request the database
// if `withNested` = true, join with the `nestedModel` table
throw Unimplemented();
}
}
()
FutureOr<List<Model>> getModels(Ctx ctx) {
final PossibleSelections lookahead = ctx.lookahead();
assert(!lookahead.isUnion);
final PossibleSelectionsObject lookaheadObj = lookahead.asObject;
final withNested = lookaheadObj.contains('nested');
final ModelRepo repo = modelRepo.get(ctx);
return repo.getModels(withNested: withNested);
}
With this implementation and given the following queries:
query getModelsWithNested {
getModels {
id
name
nested {
id
name
}
}
}
query getModelsBase {
getModels {
id
name
}
}
ModelRepo.getModels
will receive true
in the withNested
param for the getModelsWithNested
query since lookaheadObj.contains('nested')
will be true
. On the other hand, the withNested
param will be false
for the getModelsBase
query since the "nested" field was not selected.
In this way, ModelRepo.getModels
knows what nested fields it should return. It could add additional joins in a SQL query, for example.
The PossibleSelections
class has the information about all the nested selected fields when the type of the field is a Composite Type (Object, Interface or Union). When it's an Union, it will provide a map from the type name Object variants to the given variant selections. The @skip and @include directives are already taken into account. You can read more about the PossibleSelections
class in the source code.
DataLoader (Batching)
The code in Leto is a port of graphql/dataloader.
An easier to implement but probably less performant way of solving the N+1 problem is by using a DataLoader
. It allows you to batch multiple requests and execute the complete batch in a single function call.
()
class Model {
final String id;
final String name;
final int nestedId;
const Model(this.id, this.name, this.nestedId);
NestedModel nested(Ctx ctx) {
return modelNestedRepo.get(ctx).getNestedModel(nestedId);
}
}
class NestedModelRepo {
late final dataLoader = DataLoader.unmapped<String, NestedModel>(getNestedModelsFromIds);
Future<List<NestedModel>> getNestedModel(String id) {
// Batch the id, eventually `dataLoader` will execute
// `getNestedModelsFromIds` with a list of batched ids
return dataLoader.load(id);
}
Future<List<NestedModel>> getNestedModelsFromIds(List<String> ids) {
// Multiple calls to `Model.nested` will be batched and
// all ids will be passed in the `ids` argument
// request the database
final List<NestedModel> models = throw Unimplemented();
// Make a map from id to model instance
final Map<String, NestedModel> modelsMap = models.fold(
{}, (map, model) => map..[model.id] = model
);
// Return the models in the same order as the `ids` argument
return List.of(ids.map((id) => modelsMap[id]!));
}
}
final modelNestedRepo = ScopedRef.local(
(scope) => NestedModelRepo()
);
()
List<Model> getModels(Ctx ctx) {
return modelRepo.get(ctx).getModels();
}
The DataLoader has some options for configuring it. For example you can specify the maximum size of the batch (default: 2^53
or the maximum javascript integer), whether to batch requests or not (default: true
) and provide a custom batch schedule function, by default it will use Future.delayed(Duration.zero, executeBatch)
.
You can also configure caching by providing a custom cache implementation, a custom function that maps the key passed to DataLoader.load
to the cache's key or disabling caching in the DataLoader.
Combining LookAhead with DataLoader
You can use both, LookAhead and DataLoader at the same time. The keys provided to the DataLoader.load
function can be anything, so you could send the PossibleSelection
information, for example.