Calling data loaders via nested data fetches is fairly simple, but often requires writing almost identical boilerplate code.
In addition, these types of data fetchers can be quite easily overlooked during implementation. This leads to incomplete results, even if the data loaders has been defined correctly.
This code generation library attempts to solve this problem by automatically generating such nested data fetches from DGS DTOs and data loader definitions. bee.fetched
is based on ksp
for lightweight, idiomatic code generation.
Note that this library builds upon DGS and is not intended for use with only graphql-java
.
The following shows the easiest way to incorporate bee.fetched
into a project.
settings.gradle.kts
pluginManagement {
resolutionStrategy {
eachPlugin {
when (requested.id.id) {
"bee.generative" -> useModule("com.beeproduced:bee.generative:<BEE_BUILT_VERSION>")
}
}
}
}
⚠️ Asbee.generative
is currently not published to the gradle plugin portal, the publication on maven central has no plugin marker and thus requires this workaround.
build.gradle.kts
:
plugins {
id("bee.generative")
id("com.google.devtools.ksp") version "1.9.10-1.0.13"
}
dependencies {
beeGenerative("com.beeproduced:bee.fetched:<BEE_BUILT_VERSION>")
}
// DGS codegen
tasks.withType<GenerateJavaTask> {
packageName = "<package-name>"
subPackageNameTypes = "<dto-package-name>"
...
}
// bee.fetched codegen
// Fetched scan packege must match DGS codegen path
// Fetched package name is where the generated nested datafetchers will be placed
beeGenerative {
arg("fetchedScanPackage", "<package-name>.<dto-package-name>")
arg("fetchedPackageName", "<package-name>.fetcher")
}
🪧 To see complete
bee.fetched
logs append--info
to a gradle run task likekspKotlin --rerun-tasks --info
.
Let's assume one has the following schema and wants to load the Waldo
type via a data loader.
extend type Query {
foo: Foo!
bar: Bar!
qux: Qux!
quux: Quux!
corge: Corge!
grault: Grault!
fred: Fred!
plugh: Plugh!
xyzzy: Xyzzy!
garply: Garply!
}
type Waldo {
waldo: String!
}
@BeeFetched(
mappings = [
FetcherMapping(Corge::class, DgsConstants.CORGE.Waldo, DgsConstants.CORGE.CorgeToWaldoId)
],
internalTypes = [
FetcherInternalType(Grault::class, TestController.MyGrault::class, DgsConstants.GRAULT.Waldo),
FetcherInternalType(Fred::class, TestController.MyFred::class, DgsConstants.FRED.Waldo),
FetcherInternalType(Plugh::class, TestController.MyPlugh::class, DgsConstants.PLUGH.Waldos),
FetcherInternalType(Xyzzy::class, TestController.MyXyzzy::class, DgsConstants.XYZZY.Waldos),
],
ignore = [
FetcherIgnore(Garply::class, DgsConstants.GARPLY.Waldo),
FetcherIgnore(Waldo::class)
],
safeMode = true,
safeModeOverrides = []
)
@DgsDataLoader(name = "Waldo")
class WaldoDataLoader : MappedBatchLoaderWithContext<String, Waldo> {
override fun load(
keys: Set<String>,
environment: BatchLoaderEnvironment,
): CompletionStage<Map<String, Waldo>> {
return CompletableFuture.supplyAsync {
keys.associateWith { Waldo(it) }
}
}
}
⚠️ Please do not forget to annotate the data loader with@BeeFetched
if one wants to utilise code generation.
🪧 The
@BeeFetched
annotation will be explained in the following step by step.
With the help of bee.fetched
all of the corresponding nested data fetchers including their data loader invocations can be automatically generated.
In the following schema waldo
and waldos
are the fields that should be loaded via a data loader. The library needs to identify the fields that contain the keys for the fields to be loaded.
A simple approach is used:
- Entities like
waldo
=> Search forwaldoId
- Collections like
waldos
=> Search forwaldoIds
orwaldosIds
- Not modelled but possible: A collection called
waldo
=> Search forwaldoIds
orwaldosIds
type Foo {
# Simple case for singular id
waldoId: ID!
waldo: Waldo
}
type Bar {
# Simple case for plural ids
waldoIds: [ID!]!
waldos: [Waldo!]
}
type Qux {
# Singular nullable id
waldoId: ID
waldo: Waldo
}
type Quux {
# Plural nullable id
waldoIds: [ID!]
waldos: [Waldo!]
}
When this approach is applicable to a DTO the library automatically generates nested data fetchers without additional configuration.
@DgsData(
parentType = "Foo",
field = "waldo",
)
public fun fooWaldo(dfe: DataFetchingEnvironment): CompletableFuture<Waldo?> {
val data = dfe.getSource<Foo>()
if (data.waldo != null) return CompletableFuture.completedFuture(data.waldo)
val dataLoader: DataLoader<String, Waldo> = dfe.getDataLoader("Waldo")
val id = data.waldoId
return dataLoader.load(id)
}
@DgsData(
parentType = "Bar",
field = "waldos",
)
public fun barWaldos(dfe: DataFetchingEnvironment): CompletableFuture<List<Waldo>?> {
val data = dfe.getSource<Bar>()
if (!data.waldos.isNullOrEmpty()) return CompletableFuture.completedFuture(data.waldos)
val dataLoader: DataLoader<String, Waldo> = dfe.getDataLoader("Waldo")
val ids = data.waldoIds
return dataLoader.loadMany(ids)
}
@DgsData(
parentType = "Qux",
field = "waldo",
)
public fun quxWaldo(dfe: DataFetchingEnvironment): CompletableFuture<Waldo?> {
val data = dfe.getSource<Qux>()
if (data.waldo != null) return CompletableFuture.completedFuture(data.waldo)
val dataLoader: DataLoader<String, Waldo> = dfe.getDataLoader("Waldo")
val id = data.waldoId
if (id == null) return CompletableFuture.completedFuture(data.waldo)
return dataLoader.load(id)
}
@DgsData(
parentType = "Quux",
field = "waldos",
)
public fun quuxWaldos(dfe: DataFetchingEnvironment): CompletableFuture<List<Waldo>?> {
val data = dfe.getSource<Quux>()
if (!data.waldos.isNullOrEmpty()) return CompletableFuture.completedFuture(data.waldos)
val dataLoader: DataLoader<String, Waldo> = dfe.getDataLoader("Waldo")
val ids = data.waldoIds
if (ids.isNullOrEmpty()) return CompletableFuture.completedFuture(data.waldos)
return dataLoader.loadMany(ids)
}
In the following case the library cannot determine the identifier and needs some manual assistance.
type Corge {
# Completely unrelated naming
corgeToWaldoId: ID!
waldo: Waldo
}
To do so, one must provide a FetcherMapping
via @BeeFetched
that maps Corge
's waldo
field to the id corgeToWaldoId
.
@BeeFetched(
mappings = [
FetcherMapping(Corge::class, DgsConstants.CORGE.Waldo, DgsConstants.CORGE.CorgeToWaldoId)
],
...
)
@DgsDataLoader(name = "Waldo")
class WaldoDataLoader : MappedBatchLoaderWithContext<String, Waldo>
🪧 One could also write
FetcherMapping(Corge::class, "waldo", "corgeToWaldoId")
, however this approach is not safe for changes.
This results in following generated code.
@DgsData(
parentType = "Corge",
field = "waldo",
)
public fun corgeWaldo(dfe: DataFetchingEnvironment): CompletableFuture<Waldo?> {
val data = dfe.getSource<Corge>()
if (data.waldo != null) return CompletableFuture.completedFuture(data.waldo)
val dataLoader: DataLoader<String, Waldo> = dfe.getDataLoader("Waldo")
val id = data.corgeToWaldoId
return dataLoader.load(id)
}
DGS supports the usage of internal types which means that identifiers may not be exposed in the schema. The library needs in these cases to know for which field the internal type is used to get the identifier. Identifier rules & mappings discussed in the last section also apply for internal types.
🪧 This also means that internal types can be not well-formed and may require additional
FetcherMappings
.
type Grault {
# Should be resolved with internal type id
waldo: Waldo
}
type Fred {
# Should be resolved with internal nullable id
waldo: Waldo
}
type Plugh {
# Should be resolved with internal type ids
waldos: [Waldo!]
}
type Xyzzy {
# Should be resolved with internal type nullable ids
waldos: [Waldo!]
}
In these cases, one must provide a FetcherInternalType
via @BeeFetched
that maps DTOs to their internal representation. Internal types can substitute the DTO for all of their fields or for just one specific field (in this case for example DgsConstants.GRAULT.Waldo
).
@BeeFetched(
internalTypes = [
FetcherInternalType(Grault::class, TestController.MyGrault::class, DgsConstants.GRAULT.Waldo),
FetcherInternalType(Fred::class, TestController.MyFred::class, DgsConstants.FRED.Waldo),
FetcherInternalType(Plugh::class, TestController.MyPlugh::class, DgsConstants.PLUGH.Waldos),
FetcherInternalType(Xyzzy::class, TestController.MyXyzzy::class, DgsConstants.XYZZY.Waldos),
],
...
)
@DgsDataLoader(name = "Waldo")
class WaldoDataLoader : MappedBatchLoaderWithContext<String, Waldo>
🪧 If
Grault
would have another fieldwaldo2: Waldo
the library would use theGrault
DTO and not theTestController.MyGrault
internal type as it is only configured forDgsConstants.GRAULT.Waldo
. LeavingDgsConstants.GRAULT.Waldo
empty or adding an additionalFetcherInternalType
forDgsConstants.GRAULT.Waldo2
would result in usage of the internal type.
This results in following generated code.
@DgsData(
parentType = "Grault",
field = "waldo",
)
public fun graultWaldo(dfe: DataFetchingEnvironment): CompletableFuture<Waldo?> {
val data = dfe.getSource<MyGrault>()
val dataLoader: DataLoader<String, Waldo> = dfe.getDataLoader("Waldo")
val id = data.waldoId
return dataLoader.load(id)
}
@DgsData(
parentType = "Fred",
field = "waldo",
)
public fun fredWaldo(dfe: DataFetchingEnvironment): CompletableFuture<Waldo?> {
val data = dfe.getSource<MyFred>()
val dataLoader: DataLoader<String, Waldo> = dfe.getDataLoader("Waldo")
val id = data.waldoId
if (id == null) throw IllegalStateException(
"Tried to load nullable key into non-nullable data loader"
)
return dataLoader.load(id)
}
@DgsData(
parentType = "Plugh",
field = "waldos",
)
public fun plughWaldos(dfe: DataFetchingEnvironment): CompletableFuture<List<Waldo>?> {
val data = dfe.getSource<MyPlugh>()
val dataLoader: DataLoader<String, Waldo> = dfe.getDataLoader("Waldo")
val ids = data.waldoIds
return dataLoader.loadMany(ids)
}
@DgsData(
parentType = "Xyzzy",
field = "waldos",
)
public fun xyzzyWaldos(dfe: DataFetchingEnvironment): CompletableFuture<List<Waldo>?> {
val data = dfe.getSource<MyXyzzy>()
val dataLoader: DataLoader<String, Waldo> = dfe.getDataLoader("Waldo")
val ids = data.waldoIds
if (ids == null) throw IllegalStateException(
"Tried to load nullable keys into non-nullable data loader"
)
return dataLoader.loadMany(ids)
}
⚠️ Be aware that trying to load nullable keys into a non-nullable data loader can result in an exception as it is undefined / undesirable behaviour.
If one needs a tailored nested data fetcher for a special case one can disable generation on a per type basis.
type Garply {
# Implementation should NOT be generated
waldo: Waldo
}
In the following, the generation of a nested data fetcher for Garply
's waldo
is disabled. Also the Waldo
type is generally disabled as it has no relevant fields and does not need to be analysed.
@BeeFetched(
ignore = [
FetcherIgnore(Garply::class, DgsConstants.GARPLY.Waldo),
FetcherIgnore(Waldo::class)
],
...
)
@DgsDataLoader(name = "Waldo")
class WaldoDataLoader : MappedBatchLoaderWithContext<String, Waldo>
🪧 As with
FetcherInternalType
, leavingDgsConstants.GARPLY.Waldo
empty would disallow the generation of allWaldo
fields on this type (which in this case makes no difference).
By default, bee.fetched
generates nested data fetcher with an early return when data is already present for the requested field. This feature is called safeMode
and can be illustrated as follows.
@DgsData(
parentType = "Foo",
field = "waldo",
)
public fun fooWaldo(dfe: DataFetchingEnvironment): CompletableFuture<Waldo?> {
val data = dfe.getSource<Foo>()
// Only present with `safeMode=true`
if (data.waldo != null) return CompletableFuture.completedFuture(data.waldo)
// =========================================================================
val dataLoader: DataLoader<String, Waldo> = dfe.getDataLoader("Waldo")
val id = data.waldoId
return dataLoader.load(id)
}
@DgsData(
parentType = "Bar",
field = "waldos",
)
public fun barWaldos(dfe: DataFetchingEnvironment): CompletableFuture<List<Waldo>?> {
val data = dfe.getSource<Bar>()
// Only present with `safeMode=true`
if (!data.waldos.isNullOrEmpty()) return CompletableFuture.completedFuture(data.waldos)
// ====================================================================================
val dataLoader: DataLoader<String, Waldo> = dfe.getDataLoader("Waldo")
val ids = data.waldoIds
return dataLoader.loadMany(ids)
}
If one does not want to utilise this feature one can disable the feature for all nested data fetchers.
@BeeFetched(
safeMode = false
...
)
@DgsDataLoader(name = "Waldo")
class WaldoDataLoader : MappedBatchLoaderWithContext<String, Waldo>
One can also configure the feature on a per field basis.
@BeeFetched(
safeModeOverrides = [
FetcherSafeModeOverride(Foo::class, DgsConstants.FOO.Waldo, false),
FetcherSafeModeOverride(Bar::class, DgsConstants.BAR.Waldos, false),
]
...
)
@DgsDataLoader(name = "Waldo")
class WaldoDataLoader : MappedBatchLoaderWithContext<String, Waldo>
🪧 This also works in the opposite direction: creating a safe fetcher when the safe mode is set to
false
.
An example on which this documentation is based on can be found under bee.fetched.test
in the root project. The tests for this library reside also in this example project.