Skip to content

Nested data fetchers

Commonly, the datafetcher for a nested field requires properties from its parent object to load its data.

Take the following schema example.

type Query {
  shows: [Show]
}

type Show {
  # The showId may or may not be there, depending on the scenario.
  showId: ID
  title: String
  reviews: [Review]
}

type Review {
  starRating: Int
}

Let's assume our backend already has methods available to Shows and Reviews from a datastore. Note that for this example, the getShows method does not return reviews. The getReviewsForShow method loads reviews for a show, given the show id.

interface ShowsService {
  List<Show> getShows(); //Does not include reviews
  List<Review> getReviewsForShow(int showId);   
}

For this scenario, you likely want to have two datafetchers, one for shows and one for reviews. There are different options for implementing the datafetcher, which each has pros and cons depending on the scenario. We'll go over the different scenarios and options.

The easy case - Using getSource

In the example schema the Show type has a showId. Having the showId available makes loading reviews in a separate datafetcher very easy. The DataFetcherEnvironment has a getSource() method that returns the parent loaded for a field.

@DgsData(parentType = "Query", field = "shows")
List<Show> shows() {
  return showsService.getShows();
}

@DgsData(parentType = "Show", field = "reviews")
List<Review> reviews(DgsDataFetchingEnvironment dfe) {
  Show show = dfe.getSource();
  return showsService.getReviewsForShow(show.getShowId());
} 

This example is the easiest and most common scenario, but only possible if the showId field is available on the Show type.

No showId - Use an internal type

Sometimes you don't want to expose the showId field in the schema, or our types are not set up to carry this field for other reasons. For example, for 1:1 and N:1 it's not that common to model the relationship as a key in the Java model. Whatever the reason is, the scenario we look at here is that we don't have the showId available on Show.

If we remove showId from the schema and use codegen, the generated Show type will not have showId field either. Not having the showId field makes loading reviews a bit more complicated, because now we can't get the showId from the Show type using getSource().

The getShowsForService(int showId) method indicates that internally (probably in the datastore), a show does have an id. In such a scenario, we likely have a different internal representation of Show than exposed in the API. For the remainder of the example, we'll call this the InternalShow type which the ShowsService returns.

interface ShowsService {
  List<InternalShow> getShows(); //Does not include reviews
  List<Review> getReviewsForShow(int showId);   
}

class InternalShow {
  int showId;
  String title;

  // getters and setters
}

However, the Show type in the GraphQL schema does not have a showId.

type Show {
  title: String
  reviews: [Review]
}

The good news is that you can have fields set on your internal instances either not in the schema, or not queried. The framework drops this extra data while creating a response.

We could create an extra ShowWithId wrapper class that either extends or composes the (generated) Show type, and adds a showId field.

class ShowWithId {
  String showId;
  Show show;

  //Delegate all show fields
  String getTitle() {
    return show.getTitle();
  }

  static ShowWithId fromInternalShow(InternalShow internal) {
    //Create Show instance and store id.
  }
  ....
}

The shows datafetcher should return the wrapper type instead of just Show.

@DgsData(parentType = "Query", field = "shows")
List<ShowWithId> shows() {
  return showsService.getShows().stream()
    .map(ShowWithId::fromInternalShow)
    .collect(Collectors.toList());
}

As said, the extra field doesn't affect the response to the client at all.

No showId - Use local context

Using wrapper types works well when the schema type and internal type are mostly similar. An alternative way is to use "local context". A datafetcher can return a DataFetcherResult<T>, which contains data, errors and localContext. The data and errors fields are the data and errors you would normally return directly from your datafetcher. The localContext field can hold any data you want to pass down to child datafetchers. The localContext can be retrieved in the child datafetcher from the DataFetchingEnvironment and is passed down to the next level child datafetchers if not overwritten.

In the following example the shows datafetcher creates a DataFetcherResult that holds the list of Show instances (not the internal type). The localContext is set to a map with each show as key, and the showId as value.

@DgsData(parentType = "Query", field = "shows")
public DataFetcherResult<List<Show>> shows(@InputArgument("titleFilter") String titleFilter) {
    List<InternalShow> internalShows = getShows(titleFilter);

    List<Show> shows = internalShows.stream()
        .map(s -> Show.newBuilder().title(s.getTitle()).build())
        .collect(Collectors.toList());
        return DataFetcherResult.<List<Show>>newResult()
            .data(shows)
            .localContext(internalShows.stream()
            .collect(Collectors.toMap(s -> Show.newBuilder().title(s.getTitle()).build(), InternalShow::getId)))
            .build();

}

private List<InternalShow> getShows(String titleFilter) {
    if (titleFilter == null) {
        return showsService.shows();
    }

    return showsService.shows().stream().filter(s -> s.getTitle().contains(titleFilter)).collect(Collectors.toList());
}

The reviews datafetcher can now use a combination of the getSource and getLocalContext methods to get the showId for a show.

@DgsData(parentType = "Show", field = "reviews")
public CompletableFuture<List<Review>> reviews(DgsDataFetchingEnvironment dfe) {

    Map<Show, Integer> shows = dfe.getLocalContext();

    Show show = dfe.getSource();
    return showsService.getReviewsForShow(shows.get(show));
}

A benefit of this approach is that in contrast with getSource, the localContext gets passed down to the next level of child datafechers as well.

Pre-loading

Suppose our internal datastore allows us to load shows and reviews together efficiently, for example using a SQL join query. In that case, it can be more efficient to pre-load reviews in the shows datafetcher. In the shows datafetcher we can check if the reviews field was included in the query, and only if it is, load the reviews. Depending on the Java/Kotlin types we use, the Show type may or may not have a reviews field. If we use DGS codegen it will, and we can just set the reviews field when creating the Show instances in the shows datafetcher. If the type returned by the shows datafetcher does not have a reviews field, we can again use the localContext to pass on the review data to a reviews datafetcher. Below is an example of pre-loading and using localContext.

@DgsData(parentType = "Query", field = "shows")
public DataFetcherResult<List<Show>> shows(DataFetchingEnvironment dfe) {
    List<Show> shows = showsService.shows();
    if (dfe.getSelectionSet().contains("reviews")) {

        Map<Integer, List<Review>> reviewsForShows = reviewsService.reviewsForShows(shows.stream().map(Show::getId).collect(Collectors.toList()));

        return DataFetcherResult.<List<Show>>newResult()
            .data(shows)
            .localContext(reviewsForShows)
            .build();
    } else {
        return DataFetcherResult.<List<Show>>newResult().data(shows).build();
    }
}

@DgsData(parentType = "Show", field = "reviews")
public List<Review> reviews(DgsDataFetchingEnvironment dfe) {
    Show show = dfe.getSource();

    //Load the reviews from the pre-loaded localContext.
    Map<Integer, List<Review>> reviewsForShows = dfe.getLocalContext();
    return reviewsForShows.get(show.getId());
}