This is a code generation tool that implements GraphQL schemas by tying schemas to underlying database models. Graphitron creates complete or partial data fetcher implementations from GraphQL schemas using Java and jOOQ.
Table of Contents
- Features
- Usage
- Maven Settings
- Directives
- Common directives
- Tables, joins and records
- Custom query conditions with @condition
- Example: Setup
- Example: No override on input parameter
- Example: No override on field with input parameters
- Example: Both field and parameters
- Example: With override on input parameter
- Example: With override on field with input parameters
- Example: With override on both field and parameters
- Example: Conditions on input type fields
- Example: Condition using flat record configuration
- Example: Condition using nested record configurations
- Example: Schema with listed input types and condition set on listed input field
- Example: Schema with listed input types and condition set on input field inside a list input
- Example: Condition with @nodeId argument
- Map enums with @enum
- Generate mutations with @mutation
- Custom logic with @service
- Custom table resolution with @tableMethod
- Exception handling with @error
- Custom field logic with @externalField
- Polymorphic queries (unions and interfaces)
- Batch lookups with @lookupKey
- Sorting
- Special interfaces
- Global node identification
- Graphitron integration tests
- Generate the GraphQL code and database queries needed to resolve a GraphQL schema with a set of directives
- In many cases, writing backend code can be skipped entirely!
- Need more advanced queries? Graphitron provides options for setting custom conditions, sorting, queries and more, while still taking care of the GraphQL-side of things
- If needed, code generation can be skipped for individual data fetchers
- Supports Apollo Federation
- Error handling
See the example project for a complete example of how to set up and use Graphitron, which also includes a README.md file detailing the setup and usage of the example project. This project is also the home of our integration tests, which ensure that Graphitron generates data fetchers that run as expected.
Graphitron provides a static generated class (graphitron.Graphitron) for accessing the generated results in a user-friendly way.
This will be available after the first run of graphitron-java-codegen. A nodeIdStrategy
can be passed to most of these methods.
Here are a few examples of how one can retrieve schema-related code for further use:
// Get the schema with all wiring included.
GraphQLSchema schema = Graphitron.getSchema(nodeIdStrategy);// Get the schema with Apollo Federation support. Use this to wire _service and _entities resolvers.
var registry = Graphitron.getTypeRegistry();
var wiringBuilder = Graphitron.getRuntimeWiringBuilder(nodeIdStrategy);
GraphQLSchema schema = Graphitron.getFederatedSchema(registry, wiringBuilder, nodeIdStrategy);// Get the wiring for the generated code.
RuntimeWiring runtimeWiring = Graphitron.getRuntimeWiring(nodeIdStrategy);// Get the wiring for the generated code as a builder. Equivalent to the getRuntimeWiring, but skips the .build() call at the end.
RuntimeWiring.Builder runtimeWiringBuilder = Graphitron.getRuntimeWiringBuilder(nodeIdStrategy);// Get the type registry. You need this to build a GraphQLSchema.
TypeDefinitionRegistry registry = Graphitron.getTypeRegistry();The generate Maven goal allows Graphitron to be called as part of the build pipeline. It generates all the classes that are set to generate in the schema.
Additionally, the watch goal can be used locally to watch GraphQL files for changes, and regenerate code without having to re-run generation manually each time. The watch feature is incomplete and currently has low utility.
The validate goal can be used to validate GraphQL schemas without generating code.
In order to find the schema files and any custom methods, Graphitron also provides some configuration options. The options are the same for both goals.
outputPath- The location where the code will be generated.outputPackage- The package path of the generated code.schemaFiles- Set of schema files which should be used for the generation process.userSchemaFiles- Set of schema files to provide to the user.jooqGeneratedPackage- The location of the jOOQ generated code.externalReferences- See Code references.externalReferenceImports- See Code references.globalRecordTransforms- See Code references.maxAllowedPageSize- The maximum number of items that can be returned from "Cursor Connections Specification" based data fetchers. And thus also the database query limit.scalars- Extra scalars that can be used in code generation and that will be added automatically to the wiring. Reflection is used to find all the scalar definitions of the provided class(es).
makeNodeStrategy- Enables support for global node identification in generated code. See Global node identification.experimental_requireTypeIdOnNode- Experimental flag that requires thetypeIdparameter on the node directive. This flag will be removed, but can temporarily be used to ensure all node types have a defined type ID.
recordValidation- Controls whether generated mutations should include validation of JOOQ records through the Jakarta Bean Validation specification.enabled- Flag indicating if Graphitron should generate record validation codeschemaErrorType- Name of the schema error to be returned in case of validation violations and IllegalArgumentExceptions. If null whileenabledis true all validation violations and IllegalArgumentExceptions will instead cause AbortExecutionExceptions to be thrown, leading to top-level GraphQL errors. Also, if the given error is not present in the schema as a returnable error for a specific mutation, validation violations and IllegalArgumentExceptions on this mutation will cause top-level GraphQL errors.
codeGenerationThresholds- Thresholds for code generation warnings and limits. If enabled, errors or warnings will appear if a generated jOOQ query exceed the thresholds.upperBoundLinesOfCode- Warning threshold for generated method line count.crashPointLinesOfCode- Hard limit for generated method line count; exceeding this causes a build failure.upperBoundNestingDepth- Warning threshold for nesting depth of correlated subqueries.crashPointNestingDepth- Hard limit for nesting depth; exceeding this causes a build failure.
validateOverlappingInputFields- Validates that input fields mapping to the same jOOQ column have consistent values at runtime.
optionalSelect- Controls optional select behavior for fields, which can improve performance by skipping unrequested fields.onSubqueryReferences- Enable optional selects for subquery references.onExternalFields- Enable optional selects for external fields.
useJdbcBatchingForDeletes- Enables JDBC batching for delete operations in generated mutations. Enabled by default. If disabled, improved delete query with returning clause will be generated.useJdbcBatchingForInserts- Enables JDBC batching for insert operations in generated mutations. Enabled by default. If disabled, improved insert query with returning clause will be generated.failOnMerge- When enabled, code generation will fail if an upsert mutation with MERGE is encountered. Disabled by default.generateUpsertAsStore- Use jOOQbatchStoreinstead ofbatchMergefor upsert mutations. Disabled by default. When enabled, existing records are fetched by primary key before mutation, and only the input-provided fields are applied onto them. This lets jOOQ decide whether to INSERT or UPDATE based on the record state.
See the pom.xml of graphitron-example-spec for an example on how to configure these settings.
In some instances you need to link custom java code to Graphitron's generated data fetchers. In order for Graphitron to find the external Java code it needs to be listed in the maven configuration described in this section.
- externalReferences - List of references to classes that can be applied through certain directives. Note that this is being deprecated in favor of using the className in the directives.
- externalReferenceImports - List of packages that should be searched for classNames used in directives.
Example of referencing a class through the configuration:
<externalReferences>
<element> <!--The name of this outer element does not matter.-->
<name>CUSTOMER_TRANSFORM</name>
<fullyQualifiedClassName>some.path.CustomerTransform</fullyQualifiedClassName>
</element>
</externalReferences>Example of importing a package through the configuration:
<externalReferenceImports>
<package>some.path</package>
</externalReferenceImports>Example of applying a global transform in the POM:
<globalRecordTransforms>
<element>
<fullyQualifiedClassName>some.path.CustomerTransform</fullyQualifiedClassName>
<method>someTransformMethod</method> <!-- Method in the referenced class to be applied. -->
<scope>ALL_MUTATIONS</scope> <!-- Only ALL_MUTATIONS is supported right now. -->
</element>
</globalRecordTransforms>Note that several of the code examples below use unaliased jOOQ tables for readability, while the code Graphitron generates only uses aliased tables.
Applying this to a type reference denotes a split in the generated jOOQ query. This means that we want a new, explicit data fetcher for the annotated field. Fields in the Query and Mutation types, as well as fields with arguments, do not require this directive as they always require explicit data fetchers.
In this example, the code generation creates the classes ATypeDBQueries and ATypeGeneratedDataFetcher, containing the data fetcher code required to fetch OtherType given AType. Note that this example would not work in practice as we have not specified any tables to map to yet.
type AType {
otherType: OtherType @splitQuery # Build new data fetcher/query for this field.
}
type OtherType {
name: String
}The actual code generated by Graphitron will be discussed in the next sections.
Note that for @table fields on @record types reachable from @service, @splitQuery is applied automatically. See Resolving @table fields after services.
When a database table has a foreign key that references itself (e.g., FILM.SEQUEL referencing FILM.FILM_ID), the field that traverses this self-reference must have the @splitQuery directive. Without it, the code generator cannot distinguish the self-referencing join from staying in the same table context.
type Film @table {
sequel: Film @splitQuery @reference(path: [{key: "FILM__SEQUEL_FKEY"}])
}Wrapper types that map to the same table without creating a join are unaffected:
type Film @table {
wrapper: FilmDetails # No @splitQuery needed — stays in the same table context
}
type FilmDetails @table(name: "FILM") {
title: String
}Reverse self-references (listing rows that reference the current row via a self-referencing FK) are not currently supported through automatic join resolution. Use a custom @condition as a workaround.
Set this on any query or mutation field that Graphitron would usually generate a new data fetcher for in order to cancel of it. Since graphql still requires the data fetchers to resolve the fields, they will have to be implemented and wired manually. If one is not specified, the field will always return null.
type Query {
aQuery: AType @notGenerated # Do not generate this query automatically.
}
type AType {
otherType: OtherType @splitQuery @notGenerated # Require a new data fetcher for this field, but do not generate it automatically.
}
type OtherType {
name: String
}By default, Graphitron assumes each field not annotated with the notGenerated or splitQuery directives to have a name equal to the column it corresponds to in the jOOQ table or service record. Specifying the name-parameter in the field directive overrides this behaviour. This directive applies to regular type fields, input type fields, arguments and enum values. For determining which table the field name should be taken from, see the table and reference directives.
type Query {
query(
argument: String @field(name: "ACTUAL_ARGUMENT_NAME") # @field applied on an argument.
): SomeType
}
type SomeType {
value: String @field(name: "ACTUAL_VALUE_NAME") # @field applied on a standard field.
}
input SomeInput {
value: String @field(name: "ACTUAL_VALUE_NAME") # @field applied on an input field.
}
enum SomeEnum { # @field applied on enum fields. Each of these must correspond to a jOOQ field.
E0 @field(name: "ACTUAL_E0")
E1 @field(name: "ACTUAL_E1")
E2 @field(name: "ACTUAL_E2")
}The javaName-parameter is an obscure special case for when a table/jOOQ-record needs to interact with a Java record. It acts as a second override, defaulting to the value of name when not set. A concrete example of where this parameter is required is when using records with conditions or queries in general where the contents of the record must be matched to jOOQ table fields.
type Query {
query(argument: SomeInput): SomeType
}
input SomeInput @record(record: {className: "some.path.SomeRecord"}) {
value: String @field(name: "ACTUAL_VALUE_NAME", javaName: "ACTUAL_JAVA_RECORD_FIELD_NAME")
}
type SomeType @table { … }The experimental_constructType directive constructs nested object types from columns in the parent table,
when the target type does not have the @table directive. This is useful when you want re-use nested types with different mappings,
or when mapping keys for non-resolvable federation entities. The logic is equivalent to that of the @field directive.
The selection parameter uses the GraphQL syntax to map fields to columns in the parent table:
fieldName: COLUMN_NAMEmaps the field to a specific, differently named column.fieldNameassumes the column has the same name as the field.- Fields that are not included in the selection will resolve to
null.
type Customer @table {
info: CustomerInfo @experimental_constructType(selection: "label: FIRST_NAME, detail: EMAIL")
}
type CustomerInfo { # Both fields are resolved from Customer.
label: String
detail: String
}Multiline input is also allowed:
type Customer @table {
info: CustomerInfo @experimental_constructType(selection: """
label: FIRST_NAME,
detail: EMAIL
""")
}Restrictions:
- The containing type must have access to a table, either by specifying
@tableor inheriting it from previous types. - The target type is forbidden from having a
@tabledirective set. - Can not be combined with
@fieldor@externalFieldon the same field. - Nested object types are not supported.
- All selection field names must exist in the target type.
The experimental_procedureCall directive resolves a field by calling a jOOQ-generated routine (a database function) inline
as part of the surrounding query. The function call composes into the same SELECT that builds the parent row, so its inputs
can be either columns on the current row or GraphQL arguments on the surrounding data-fetcher field.
The procedure parameter is the routine name as returned by jOOQ's Routine.getName(), matching the database routine name
(matched case-insensitively). When the same routine name exists in multiple database schemas, qualify it as schema.routine
(e.g. public.last_day) to disambiguate. If the procedure is unique across schemas, the name will suffice.
The arguments parameter maps the routine's IN parameter names to argument sources,
using the same selection syntax as @experimental_constructType:
- The key side is the routine parameter name exactly as returned by jOOQ's
Parameter.getName()- typically snake_case (e.g.p_inventory_id). - The value side names either a column on the current table or a GraphQL argument, depending on the directive's mode (see below).
- Argument order is deduced from the routine's IN parameter order; the map is keyed by parameter name, not positional.
- arguments may be omitted or empty only when the function has zero IN parameters.
The directive has two modes, distinguished structurally by the presence of the target parameter.
The directive sits on a scalar field. Argument values name columns on the surrounding type's table, and the function call fills the cell of the directive-bearing field.
type InventoryWithHeldBy implements Node @node @table(name: "INVENTORY") {
id: ID! @nodeId
inventoryHeldBy: Int @experimental_procedureCall(
procedure: "inventory_held_by_customer",
arguments: "p_inventory_id: INVENTORY_ID"
)
}Multiple arguments can be provided:
someField: Int @experimental_procedureCall(procedure: "some_func", arguments: "p_first: FIRST_COLUMN, p_second: SECOND_COLUMN")The directive sits on a data-fetcher field (a root field, a field with arguments, or a @splitQuery field) whose return type
is an object or a list of objects. The target parameter names a sibling scalar field on that return type whose cell the function
call fills. Argument values name top-level GraphQL arguments on the directive-bearing field.
type Query {
customerWithHeldBy(inventoryId: ID): Customer @experimental_procedureCall(
target: "heldBy",
procedure: "inventory_held_by_customer",
arguments: "p_inventory_id: inventoryId"
)
}
type Customer @table {
id: ID
heldBy: Int
}Mode discrimination is structural: setting target switches the meaning of every argument value from "column on the surrounding
table" to "GraphQL argument on the data-fetcher field." Mixing the two within a single directive is not allowed.
Restrictions (apply to both modes):
- Only scalar-returning functions (
Field<T>) are currently supported. OUT / INOUT parameters and record-returning procedures are not supported. - Every IN parameter of the routine must be mapped, and the routine's return type must match the cell's Java type (the directive-bearing field in inline mode, the target field in target mode).
- The routine name must resolve unambiguously, qualified with
schema.when needed. - Can not be combined with
@field,@externalField, or@referenceon the same field. - Can not be placed on interface fields.
Inline mode-specific:
- The containing type must have an associated table (via
@tableor inherited from an enclosing type). - The directive can not be placed on root
QueryorMutationfields, or on fields with non-scalar return types. - Every mapped argument value must name a column on the surrounding table.
Target mode-specific:
- The return type (or a type above it in the chain) must have an associated table.
- The return type must be an object, or a list of objects (interfaces and unions are not yet supported).
- The named target must be a scalar field on the return type, with no
@field,@externalField, or@experimental_procedureCallof its own. - Every mapped argument value must name a top-level GraphQL argument on the directive-bearing field. Nested input-field paths are not yet supported.
The table directive links the object, interface or input type to a jOOQ table and record. Any field-directives within this type will use this table as the source for the field mapping. This targets jOOQ generated classes, so the name parameter must match the table name in jOOQ if it differs from the database. The name parameter is optional, and does not need to be specified if the type name already equals the table name.
In the example below the generator would apply an implicit path join on the key between the two tables when building the subquery for the reference.
Note that this can only work if there is only one foreign key between the tables. For example, given tables from the
schema example below, the result will be TABLE_A.table_b(). If more than one key exists, a more complex configuration
is required, see reference.
Schema:
type Table_A @table { # Table name matches the type name, name is unnecessary.
someOtherType: OtherType
}
type OtherType @table(name: "TABLE_B") {
name: String
}Generated result (excluding row mapping and aliases):
select(
field(
select(TABLE_A.table_b().NAME)
.from(TABLE_A.table_b())
)
.from(TABLE_A)If a table type contains other types that do not set their own tables, the previous table type is used instead. This also applies to enum types.
There are, of course, many cases where the connection between two tables is more complex. In such cases, Graphitron requires some extra parameters to make the right connections. This is done through the reference directive, which contains the following parameters:
- path - This parameter contains an ordered list of reference elements that is used to compose the path from the table of this type to the table corresponding to the type of the field.
- reference element:
- table - This defaults to the table of the type that is referenced by the field the directive is set on. Setting this overrides whatever table may be set there. This must match a table name from jOOQs Table class.
- key - If there are multiple foreign keys from one type to another, then this parameter is required for defining which key that should be used. This must match a key name from jOOQs Keys class.
- condition - This parameter is used to place an additional constraint on the two tables, by referring to the correct entry in the POM XML. In the cases where there is no way to deduce the key between the tables and the key or table parameter is not set, this condition will be assumed to be an on condition to be used in a join operation between the tables.
Note that joins only apply to the field they are set on, as it is only applied to the field's subquery. If the field is a scalar type, it can be linked to a jOOQ column in another table using this directive. If the field is a GraphQL type, all fields within the type will use the resulting join operation.
Also note that setting both the table and key parameters in the same reference element is redundant if there is only one path between the tables, since either is enough to make the join.
The following examples will assume that this configuration is set so that Graphitron can find the referenced classes:
<externalReferenceImports>
<package>some.path</package>
</externalReferenceImports>For the first example we will apply this simple condition method on tables that do have a direct connection.
class CustomerConditions {
static org.jooq.Condition addressJoin(Customer customer, Address address) { … }
}The method returns a jOOQ condition, which will be added to the where conditions for the subquery.
Schema:
type Customer @table {
addresses: [Address!]! @splitQuery @reference(path: [{condition : {className: "some.path.CustomerConditions", method: "addressJoin"}}])
}Generated result:
.where(some.path.CustomerConditions.addressJoin(CUSTOMER, CUSTOMER.address()))The condition is thus an additional constraint applied on both tables. In a slightly different case where the tables are not directly connected, the join will behave differently. If the two tables do not have any foreign keys between them, or there are multiple, and the one to use was not specified, the generated result would follow a pattern like the code below.
.from(CUSTOMER)
.join(ADDRESS)
.on(some.path.CustomerConditions.addressJoin(CUSTOMER, ADDRESS))Providing only a key will yield the same result. Note that in this example the key and the reference directive itself are redundant since there is only one key between the tables. Assume the Address type has the table directive set.
Schema:
type Customer @table {
addresses: [Address!]! @splitQuery @reference(path: [{key: "CUSTOMER__CUSTOMER_ADDRESS_ID_FKEY"}])
}Generated result:
.from(CUSTOMER.address())Providing both a key and a condition will result in a sum of both the first and previous examples, meaning both a join on a key and one additional condition will be applied.
Using multiple reference elements will create additional joins like the previous examples have already shown. The example below is a simple illustration of this. Assume the Film type has the table directive set.
Schema:
type Payment @table {
# The path here is PAYMENT -> RENTAL -> INVENTORY -> FILM
film: Film! @splitQuery @reference(path: [{table: "RENTAL"}, {table: "INVENTORY"}])
}First, Graphitron defines aliases for these joins, one per step.
var payment = PAYMENT;
var payment_rental = payment.rental();
var payment_rental_inventory = payment_rental.inventory();
var payment_rental_inventory_film = payment_rental_inventory.film();Then, the aliases are applied where necessary. This is the generated subquery for the references. Assume that the Film type contains a field named "title".
select(select.optional("title", payment_rental_inventory_film.TITLE))
.from(payment_rental)
.join(payment_rental_inventory)
.join(payment_rental_inventory_film)The multitableReference directive specifies type-specific reference paths for fields that return multitable union or interface types. This directive defines the reference path from the source table to target table for each type.
Parameters:
- routes - A list of reference routes, one for each implementing type or union type
- typeName - The implementation type name. Note that this is case-sensitive.
- path - An ordered list of reference elements (same structure as reference directive)
Example:
type Payment @table {
staffAndCustomers: [PersonWithEmail] @splitQuery @multitableReference(routes: [
{typeName: "Staff", path: [{key: "PAYMENT__PAYMENT_STAFF_ID_FKEY"}]},
{typeName: "Customer", path: [{table: "CUSTOMER"}]}
])
}Notes:
- Cannot be combined with @reference directive
- Only applies to multitable unions/interfaces
- If routes are omitted for some types, Graphitron will attempt to infer the reference path
To either apply additional conditions or override the conditions added by default, use the condition directive. It can be applied to both input parameters and data fetcher fields, and the scope of the condition will match the element it is put on. It provides the following parameter options:
- condition - Reference class and method name (see code references)
- override - If true, disables any default checks that are added to the affected arguments, otherwise add the new condition in addition to the default ones.
If the condition should take a list of nested input types, they need to correspond to records. This can be achieved by either using the table directive or the record directive. The former will result in a mapping to a jOOQ record, while the latter will produce a mapping to the referenced Java record. Thus, the condition will take the created record as parameter, allowing for more complex conditions. Worth noting here is that jOOQ records can not contain other records. Also, to use a record directive on a nested input all the preceding inputs must also be Java records.
There are concrete examples of how conditions and Java records may look in our test project files, such as CustomerConditions and CustomerJavaInput.
The following examples will assume this configuration exists:
<externalReferenceImports>
<package>some.path</package>
</externalReferenceImports>Adds this condition in addition to the conditions automatically generated for this parameter. The method must have the table and the input parameter type as parameters.
Schema:
cityNames: [String!] @field(name: "CITY") @condition(condition: {className: "some.path.CityConditions", method: "cityMethod"})Resulting code:
.where(cityNames != null && cityNames.size() > 0 ? CITY.CITY.in(cityNames) : noCondition())
.and(some.path.CityConditions.cityMethod(CITY, cityNames))Adds this condition in addition to the conditions automatically generated for this parameter. The method must have the table and all input parameter types for this field as parameters.
Schema:
cities(
countryId: String! @field(name: "COUNTRY_ID"),
cityNames: [String!] @field(name: "CITY")
): [City] @condition(condition: {className: "some.path.CityConditions", method: "cityMethod"})Resulting code:
.where(CITY.COUNTRY_ID.eq(countryId))
.and(cityNames != null && cityNames.size() > 0 ? CITY.CITY.in(cityNames) : noCondition())
.and(some.path.CityConditions.cityMethod(CITY, countryId, cityNames))Removes none of the automatically generated conditions, but add conditions for both the field and parameter as shown in the two previous examples.
Replaces the automatically generated conditions for the input parameter with the specified condition. The method must have the table and the input parameter type as parameters.
Schema:
cityNames: [String!] @field(name: "CITY") @condition(condition: {className: "some.path.CityConditions", method: "cityMethod"}, override: true)Resulting code:
.where(some.path.CityConditions.cityMethod(CITY, cityNames))Replaces the automatically generated conditions for the field with the specified condition. The method must have the table and all input parameter types for this field as parameters.
Schema:
cities(
countryId: String! @field(name: "COUNTRY_ID"),
cityNames: [String!] @field(name: "CITY")
): [City] @condition(condition: {className: "some.path.CityConditions", method: "cityMethodAllElements"}, override: true)Resulting code:
.where(some.path.CityConditions.cityMethodAllElements(CITY, countryId, cityNames))Both manually defined conditions are included, but nothing else. Note that if override is set on the field condition the override value on the parameter becomes irrelevant, since the one on the field already removes all the default checks.
Schema:
cities(
countryId: String! @field(name: "COUNTRY_ID"),
cityNames: [String!] @field(name: "CITY") @condition(condition: {className: "some.path.CityConditions", method: "cityMethod"}, override: true)
): [City] @condition(condition: {className: "some.path.CityConditions", method: "cityMethodAllElements"}, override: true)Resulting code:
.where(some.path.CityConditions.cityMethod(CITY, cityNames))
.and(some.path.CityConditions.cityMethodAllElements(CITY, countryId, cityNames))In the following example we have conditions on input type parameters, where some have the override value set to true. Conditions placed on the outer levels of a nested input type, will extract all the scalar values within the type and use them as arguments. The default conditions will still be generated for the inner levels of a nested input type when they are not overridden by a parent condition.
Schema:
type Query {
query(staff: StaffInput! @condition(condition: {className: "some.path.StaffConditions", method: "staff"})
) : [Staff]
}
input StaffInput {
info: ContactInfoInput!
active: Boolean!
}
input ContactInfoInput {
name: NameInput! @condition(condition: {className: "some.path.StaffConditions", method: "name"}, override: true)
jobEmail: EmailInput! @condition(condition: {className: "some.path.StaffConditions", method: "email"})
}
input NameInput {
firstname: String! @field(name: "FIRST_NAME") @condition(condition: {className: "some.path.StaffConditions", method: "firstname"})
lastname: String! @field(name: "LAST_NAME") @condition(condition: {className: "some.path.StaffConditions", method: "lastname"})
}
input EmailInput {
email: String!
}Resulting code:
.where(some.path.StaffConditions.firstname(STAFF, staff.getInfo().getName().getFirstname()))
.and(some.path.StaffConditions.lastname(STAFF, staff.getInfo().getName().getLastname()))
.and(STAFF.EMAIL.eq(staff.getInfo().getJobEmail().getEmail()))
.and(STAFF.ACTIVE.eq(staff.getActive()))
.and(some.path.StaffConditions.staff(STAFF, staff.getInfo().getName().getFirstname(), staff.getInfo().getName().getLastname(), staff.getInfo().getJobEmail().getEmail(), staff.getActive()))
.and(some.path.StaffConditions.name(STAFF, staff.getInfo().getName().getFirstname(), staff.getInfo().getName().getLastname()))
.and(some.path.StaffConditions.email(STAFF, staff.getInfo().getJobEmail().getEmail()))This is the simplest case, when we have only one layer of input types. In the example below, the input type will be mapped to a jOOQ record that corresponds to the table. This has the advantage of not needing to define and reference a Java record, but the result is equivalent.
Schema:
cities(
cityInput: CityInput!
): [City] @condition(condition: {className: "some.path.CityConditions", method: "cityMethodAllElements"}, override: true)
input CityInput @table(name: "CITY") {
countryId: String! @field(name: "COUNTRY_ID")
}Currently, if one layer input is a record then the entire preceding structure must also be records. This is because the mapping from DTOs to records happens before the query is executed. The most relevant details to note here is that while jOOQ records are allowed inside a Java record definition, they must always be on "leaf" input types that do not have further record nesting. Additionally, jOOQ records can, of course, not contain Java records.
This pattern is required when using nested input types that use lists of the inner input types. Note the extra parameter for the field directive since we are using Java records in fetch queries.
Schema:
cities(
cityInput: CityInput1!
): [City] @condition(condition: {className: "some.path.CityConditions", method: "cityMethodAllElements"}, override: true)
# Can not skip record here even if we only want one of the other input types in our condition.
input CityInput1 @record(record: {className: "some.path.Layer1Record"}) {
# A condition can also be placed here, but this may be redundant given the condition above in this case.
cityNames: [String!] @field(name: "CITY", javaName: "javaCityNamesField") @condition(condition: {className: "some.path.CityConditions", method: "cityMethod"}, override: true)
countryId: String! @field(name: "COUNTRY_ID", javaName: "javaCountryField")
city2: [CityInput2]
city3: [CityInput3]
}
input CityInput2 @record(record: {className: "some.path.Layer2Record"}) {
countryId: String! @field(name: "COUNTRY_ID", javaName: "javaCountryField2")
}
input CityInput3 @table(name: "CITY") {
countryId: String! @field(name: "COUNTRY_ID")
}Resulting code:
.where(some.path.CityConditions.cityMethodAllElements(CITY, cityInputRecord))
.and(some.path.CityConditions.cityMethod(CITY, cityInputRecord.getCityNames()))Graphitron does not support more than one level of listed input types. To be able to generate code for the cases where we initially would generate several levels of lists, we instead must declare an override condition before the second listed input in the hierarchy. Then we must write subsequent checks manually in the method specified for the condition. In this case we specify an override condition on a field holding a list of input types. The condition method will then be passed this list of input types and further checks on this list will be done in the method.
type Query {
query(
inputs1: [Input1] @condition(condition: {className: "some.path.RecordStaffConditions", method: "input1"}, override: true)
) : [Staff]
}
input Input1 @record(record: {className: "some.path.JavaRecordStaffInput1"}) {
names: [NameInput]
active: Boolean
}
input NameInput @record(record: {className: "some.path.JavaRecordStaffName"}) {
firstname: String! @field(name: "FIRST_NAME")
lastname: String! @field(name: "LAST_NAME")
}Resulting_code:
.where(some.path.RecordStaffConditions.input1(STAFF, inputs1RecordList))Here we have a schema of several levels of input types and several of these are lists. Since Graphitron does not support more than one level of listed inputs, we must specify an override condition before the second listed input in the hierarchy. The difference from the previous example is that the condition is set on a field inside a listed input and before the second listed input.
Schema:
type Query {
query(
input3: Input3!
) : [Staff]
}
input Input3 @record(record: {className: "some.path.JavaRecordStaffInput3"}) {
inputs2: [Input2]
}
input Input2 @record(record: {className: "some.path.JavaRecordStaffInput2"}) {
input1: Input1 @condition(condition: {className: "some.path.StaffConditions", method: "input1"}, override: true)
}
input Input1 @record(record: {className: "some.path.JavaRecordStaffInput1"}) {
names: [NameInput]
active: Boolean
}
input NameInput @record(record: {className: "some.path.JavaRecordStaffName"}) {
firstname: String! @field(name: "FIRST_NAME")
lastname: String! @field(name: "LAST_NAME")
}Resulting_code:
.where(
input3Record.getInputs2() != null && input3Record.getInputs2().size() > 0 ?
DSL.row(DSL.trueCondition()).in(
input3Record.getInputs2().stream().map(internal_it_ ->
DSL.row(some.path.StaffConditions.input1(STAFF, internal_it_.getInput1()))
).toList()
) : DSL.noCondition()
)A @nodeId argument can be received as a decoded jOOQ record by declaring the corresponding
condition method parameter as the record type.
Schema:
customerWithNextCustomerId(
customerId: ID!
@nodeId(typeName: "Customer")
@condition(condition: {className: "some.path.CustomerConditions", method: "customerWithNextCustomerId"}, override: true)
): CustomerCondition method:
public static Condition customerWithNextCustomerId(Customer customer, CustomerRecord id) {
return customer.CUSTOMER_ID.eq(id.getCustomerId() + 1);
}Enums can be mapped in two ways. The field directive is already covered here. An alternative method is to set up a Java enum instead through a jOOQ converter. These can be referenced using the enum directive, by pointing to the appropriate entry.
enum SomeEnum @enum(enumReference: {className: "some.path.SomeEnumConverter"}) {
E0
E1
E2
}Mutations have some limitations when generated through Graphitron, as mutations can potentially take many inputs which may be related to multiple tables. Automatic generation of the entire data fetcher is currently only viable for simple cases where one input type representing one type of jOOQ record is handled. This limitation may be removed in the future for mutations other than delete mutations.
Use the @mutation directive and the accompanying typeName parameter to denote a mutation that should be fully generated. To specify which table should be affected, the table directive is used just like for the usual types used for queries. Unlike for queries, the directive is always required on input types in order for Graphitron to know which records should be mutated. As usual, field may also be applied to adjust the mapping of individual fields.
type Mutation {
editCustomerInputAndResponse(input: EditInput!): EditResponse! @mutation(typeName: UPDATE)
editCustomerWithCustomerResponse(input: EditInput!): EditResponseWithCustomer! @mutation(typeName: UPDATE)
}
input EditInput @table(name: "CUSTOMER") { # Use @table to specify which jOOQ record/table this corresponds to.
id: ID!
firstName: String @field(name : "FIRST_NAME") # Use @field to adjust the mapping to jOOQ fields.
email: String
}
type EditResponse {
id: ID! # Note, delete mutations need the return type to have a field of type "ID".
}
type EditResponseWithCustomer {
customer: Customer
}If all required fields for an insert or upsert operation are not set in the input type, a warning will be generated. In the future this may change to an exception instead, as an incomplete set of required fields will result in a data fetcher that compiles, but will always fail when executed.
Note that mutations need either the @mutation or the @service directive set, but not both, in order to be generated.
Important: The output from generated update and insert mutations are currently incorrect when using JDBC batching. The mutation output is being fetched and filtered based on the input fields rather than returning the actual mutated record(s). To enable improved queries with returning clauses, disable JDBC batching by setting
useJdbcBatchingForInsertsand/oruseJdbcBatchingForDeletestofalsein the query generation settings.
Note: By default, upsert mutations use SQL MERGE via jOOQ's
batchMerge. IfbatchStoreis preferred, enablegenerateUpsertAsStorein the query generation settings. This changes the generated code to fetch existing records first and usebatchStore.
The service directive allows for full customization of any database operations while still generating data fetchers. The directive points to a class entry in the POM XML. The method name is either specified through the directive or is assumed to be the same as the field name.
Note that any directives that would usually alter the database operation, such as conditions, are ignored for services.
There are concrete examples of how services may look in our test project files, such as CustomerService.
Schema:
type Query {
getCustomer: Customer! @service(service: {className: "some.path.CustomerService"})
fetchCustomer(
id: ID!
): Customer! @service(service: {className: "some.path.CustomerService", method: "getCustomer"}) # Example of a case where the method name does not match the field name.
}
type Mutation {
editCustomer1(
editInput: EditCustomerInput!
): Customer! @service(service: {className: "some.path.CustomerService"}) # Returning just an ID is allowed as well.
editCustomer2(
editInput: EditCustomerInput!
): Customer! @service(service: {className: "some.path.CustomerService", method: "editCustomerAndRespond"}) # Example of a case where the method name does not match the field name.
}
input EditCustomerInput @table(name: "CUSTOMER") { # @table specifies the jOOQ table/record to use as input to the service.
id: ID!
firstName: String @field(name : "FIRST_NAME") # @field specifies the expected field name either in the jOOQ table or in the custom return class.
}Using @service on non-root fields is supported when combined with the @splitQuery directive.
The service method must accept a Set of parent table record and return a Map from those parents to the resulting values.
Only the primary key fields in the parent table record will be populated with values.
The resulting values should be a type containing the data you need. Use jOOQ TableRecords if you want Graphitron to generate re-entry queries. In that case, Graphitron will only use the PK fields of those records. Other fields will be ignored.
Schema:
type City @table {
filmsFromCity: [Film!]! @splitQuery @service(service: {className: "some.path.MockService"})
}Required service code:
public class MockService {
public MockService(DSLContext context) { … }
public Map<CityRecord, List<FilmRecord>> filmsFromCity(Set<CityRecord> cityKeys) {
…
}
}Deprecated: Using a
RowN(e.g.,Row1<Integer>) matching the primary key (instead of aTableRecord) as the resolver key is deprecated. Support will be removed in 9.0.0.
When a @record type reachable from a @service field contains fields that point to @table types, Graphitron automatically generates re-entry queries to fetch those fields from the database. This applies whether the @record type is the direct return type of the service or is nested inside plain wrapper types.
No @splitQuery directive is needed, the behavior is implicit. The service only needs to return records with the necessary primary key fields populated.
The returned jOOQ record must include the primary key fields required for the re-entry query.
Schema:
type Query {
customerService: Customer @service(service: {className: "some.path.CustomerService", method: "customer"})
}
type Customer @table {
address: Address
}For Graphitron to be able to resolve the address field in the Customer type in this example,
the jOOQ record returned from the service must contain the primary key of the CUSTOMER table, in this case CUSTOMER_ID.
The Java record returned must have a table record type corresponding to the target type's table. Graphitron will then extract the primary key from the table record, and use it to look up the next object.
Schema:
type Query {
service: JavaRecordResult @service(...)
}
type JavaRecordResult @record(...) {
customer: Customer
}
type Customer @table {...}The Java record is then expected to have customer of type CustomerRecord, and its primary key fields to be given the correct value(s) in the service.
Note:
@splitQuerycan still be used explicitly and remains required for@tablefields on plain (non-@record) types.
When a service field directly returns a type with @table, Graphitron will automatically fetch all requested fields from the database using the primary key extracted from the service result. The service needs to return a record with the primary key populated, all other fields on the record are fetched from the database.
This applies to non-paginated service fields without @splitQuery where the return type has a @table directive. Both single and listed fields are supported. For listed fields, Graphitron extracts primary keys from all service results and performs a single batch query using WHERE pk IN (...).
Schema:
type Query {
customerService: Customer @service(service: {className: "some.path.CustomerService", method: "customer"})
customerServiceList: [Customer] @service(service: {className: "some.path.CustomerService", method: "customers"})
}
type Customer @table {
id: ID!
firstName: String @field(name: "FIRST_NAME")
email: String
}Required service code:
public class CustomerService {
public CustomerRecord customer() {
// Perform business logic, then return a record with the PK set.
// Graphitron uses the PK to fetch all requested fields from the database.
var customer = new CustomerRecord();
customer.setCustomerId(1);
return customer;
}
public List<CustomerRecord> customers() {
// Return records with only the PKs set.
// Graphitron batch-fetches all remaining fields in a single query.
var c1 = new CustomerRecord();
c1.setCustomerId(1);
var c2 = new CustomerRecord();
c2.setCustomerId(2);
return List.of(c1, c2);
}
}In these examples, even though the service only sets customerId, the generated code will execute SELECT queries using those IDs to fetch firstName, email, and any other requested fields from the CUSTOMER table. For listed fields, a single batch query is issued for all keys.
Note: If the return type does not have
@table, the service result is mapped directly to the GraphQL response type using the standard response mapping rules.
Multiple layers of jOOQ input types are also supported, but comes with its own limitations. Most notably, all records are sent to the service as a flat list of unorganised parameters. This means that all the hierarchy information is lost when reading them in the service. It is therefore recommended to avoid using this feature, and use Java records instead. The following example will illustrate this limitation.
Schema:
edit(someInput: InputA!): ID! @service(service: {className: "some.path.SomeService"}) # Assumes method name is the same as field.
input InputA @table {
b: InputB
}
input InputB @table { … }Generated code:
var editResult = service.edit(inputARecord, inputBRecord); // Sequential, independent of the input structure.When using services, custom input types may also be used. It is a referenced class entry, which must contain appropriate set methods. The name of the method used for setting the values is assumed to be the same as a fields name in the schema, unless the field directive overrides this. These classes can be nested, listed and may contain JOOQ-records.
Schema:
customer(editInput: EditCustomerInput!): ID! @service(service: {className: "some.path.CustomerService"})
input EditCustomerInput @record(record: {className: "some.path.JavaCustomerRecord"}) { # @record specifies the Java record to use. Setting @table here would not do anything.
id: ID! # We need a method with the name "setId" in the record class.
first: String @field(name: "FIRST_NAME") # We need a method with the name "setFirstName" in the record class. Overridden by @field.
}Required service code:
public class CustomerService {
// The correct java class is defined through the code reference in the configuration.
public String customer(JavaCustomerRecord person) { … }
}To distinguish between explicit null from omitted one can track the changed() status of the jOOQ record in the service code.
If the jOOQ record's changed() status is true for a given field, then the value was provided (even if it was null).
If changed() is false, the field was omitted from the request entirely.
Example service code:
public Customer updateCustomer(CustomerRecord record) {
if (record.changed(CUSTOMER.FIRST_NAME)) {
// Field was provided (value may be null or non-null)
} else {
// Field was omitted from the request entirely — do not update
}
}For @mutation insert operations, Graphitron's generated queries use this automatically: omitted fields fall back to the database column default, while explicitly provided null values are inserted as null.
Note: This feature is experimental and may change in future versions.
⚠️ Important: This pattern intentionally uses null on an Optional field to represent "omitted". This goes against conventional Java usage of Optional. See the table below for the three states.
For Java records (using @record), Graphitron does not have built-in changed() tracking.
Instead, you can use Optional<T> setter parameters to distinguish between three states for a nullable input field:
| Client sends | Java value | Meaning |
|---|---|---|
field: "hello" |
Optional.of("hello") |
A value was provided |
field: null |
Optional.empty() |
The field was explicitly set to null |
| (field omitted) | null |
The field was not included in the request |
No schema-level changes are needed. The feature is driven entirely by the setter signatures in the Java record class referenced via @record.
- Define your input type as usual in the GraphQL schema:
input EditFilmInput @record(record: {className: "com.example.EditFilmRecord"}) {
title: String!
description: String
rentalDuration: Int
}- Use
Optional<T>setter parameters in your Java record for fields that should track null vs omitted:
public class EditFilmRecord {
private String title;
private Optional<String> description;
private Optional<Integer> rentalDuration;
public void setTitle(String title) { this.title = title; }
// Optional<T> parameter signals Graphitron to wrap the value
public void setDescription(Optional<String> description) { this.description = description; }
public void setRentalDuration(Optional<Integer> rentalDuration) { this.rentalDuration = rentalDuration; }
// Getters...
}- Handle the three states in your service code:
public class FilmService {
public String editFilm(EditFilmRecord input) {
if (input.getDescription() == null) {
// Field was omitted — do not update
} else if (input.getDescription().isEmpty()) {
// Field was explicitly set to null — clear the value
} else {
// Field has a value — update it
String value = input.getDescription().get();
}
}
}Graphitron detects Optional<T> setter parameters at code generation time via reflection and generates mapper code that wraps values with Optional.ofNullable(...). The setter is only called when the field is present in the GraphQL request arguments, so omitted fields leave the Java record field as null.
Note: Many IDEs will give a warning about comparing optional values to null. In IntelliJ IDEA, this can be suppressed by adding @SuppressWarnings("OptionalAssignedToNull") to the method or class.
Using the contextArguments parameter on the service or condition directives, one can specify which context values should be passed to the referenced method.
The names of the context values correspond to the keys of the values as found in the
graphql.GraphQLContext for the current operation.
Graphitron will fetch these values and try to cast them to the matching datatypes in the method arguments.
Note that context arguments are always placed at the end of the method arguments, in the order specified by the contextArguments parameter.
Schema:
customer(name: String): Customer @service(service: {className: "some.path.CustomerService"}, contextArguments: "someCtxField")Required service code:
public class CustomerService {
// The context value will be cast to String as the last parameter type here is String.
// This inference is necessary because the GraphQLContext does not retain the type information.
// Context arguments always come last in the signature, even after pagination-related parameters.
public String customer(String name, String someCtxField) { … }
}By default, Graphitron inspects the return type of the service methods to decide how it should be mapped to the schema response type.
Schema:
type Mutation {
editCustomerWithResponse(
id: ID!
): EditCustomerResponse! @service(service: {className: "some.path.CustomerService", method: "editCustomerAndRespond"})
}
type EditCustomerResponse @record(record: {className: "some.path.JavaCustomerRecord"}) {
id: ID! # We need a method with the name "getId" in the record class.
first: String @field(name: "FIRST_NAME") # We need a method with the name "getFirstName" in the record class. Overridden by @field.
customer: Customer # Some node type.
}Required service code:
public class CustomerService {
// The only field that is required (except whatever the database requires) is the ID. The correct java class is defined through the code reference in the configuration.
public JavaCustomerRecord editCustomer(String id) { … }
}There are some rules that services must follow. Services that are not in a root type must return a map. The map should use the record ID as key, and the value corresponds to the return type of the previous data fetcher.
These rules vary slightly when using pagination. All paginated fields are treated as listed. In other words, root level services should return a list. If the service is not at the root level it should return a map with the ID as key and the value should be set to a list of records.
Schema pagination wrapping is handled automatically, but pagination parameters must be manually applied to any queries that are used in the service.
Schema:
type Query {
getCustomer(id: ID!): CustomerWrapper! @service(service: {className: "some.path.CustomerService"})
getCustomerPaginated(id: ID!, first: Int = 100, after: String): CustomerWrapperConnection! @service(service: {className: "some.path.CustomerService"})
}
type CustomerWrapperConnection { … }
type CustomerWrapperConnectionEdge { … }
type CustomerWrapper @record(record: {className: "some.path.JavaCustomerWrapper"}) {
id: ID! # We need a method with the name "getId" in the record class.
first: String @field(name: "FIRST_NAME") # We need a method with the name "getFirstName" in the record class. Overridden by @field.
customer: Customer # Some node type.
}Required service code:
public class CustomerService {
public JavaCustomerWrapper getCustomer(String id) { … }
public List<JavaCustomerWrapper> getCustomerPaginated(String id, int pageSize, String after) { … }
}If the queries were placed on a query not in the Query type:
Required service code:
public class CustomerService {
public Map<String, JavaCustomerWrapper> getCustomer(String id) { … }
public Map<String, List<JavaCustomerWrapper>> getCustomerPaginated(String id, int pageSize, String after) { … }
}Nesting of return types is also allowed. This example shows the more complex case, where two nested Java return record classes are used. Such a setup can also be applied to input types.
Schema:
something(id: ID!): ReturnA! @service(service: {className: "some.path.SomethingService"}) # Query or mutation.
type ReturnA @record(record: {className: "some.path.ReturnA"}) {
returnB: ReturnB
}
type ReturnB @record(record: {className: "some.path.ReturnB"}) {
someData: String @field(name: "INTERESTING_DATA")
}Required service code:
public class SomethingService {
public ReturnA something(String id) { … } // The service method that should be called. Note that the 'id' here corresponds to the 'id' in the schema.
}Required record code:
public class ReturnA {
public ReturnB getReturnB() { … } // Must have a method that returns something that can be mapped to 'ReturnB' in the schema.
}
public class ReturnB {
public String getInterestingData() { … } // Must have a method that returns something that can be mapped to 'someData' in the schema.
}The @tableMethod directive allows a field to use a jOOQ table returned from a custom Java method.
For example, you can annotate a field in your schema with the tableMethod directive, referencing a method like AddressTableMethod:
type Query {
address: Address @tableMethod(tableMethodReference: {className: "some.path.AddressTableMethod", method: "addressTableMethod"})
}Graphitron allows for simple error handling. In the schema a type is an error type if it implements the Error interface and has the @error directive set. Unions of such types are also considered error types.
The error directive serves to map specific Java exceptions to GraphQL errors. This directive is applied to error types in the schema and accepts a list of handlers with parameters, specifying how various exceptions should be mapped.
Here is an example of how the error directive can be used:
type MyError implements Error @error(handlers:
[
{
handler: DATABASE,
sqlState: "23514",
matches: "year_check",
description: "Release year must be between 1901 and 2155"
},
{
handler: DATABASE,
code: "20997",
description: "You are not allowed to do this like that"
},
{
handler: GENERIC,
className: "org.example.YouAreNotAllowedException",
matches: "stop doing this",
description: "You are not allowed to do this like that"
}
]) {
path: [String!]!
message: String!
}In this instance, certain exceptions are mapped to be handled as MyError. The parameters inside the handler object are explained as follows:
handler- Determines the error handler to use. Presently, there are two options available, DATABASE and GENERIC.className- Specifies the fully qualified name of the exception class. This field is required for the GENERIC handler and defaults toorg.jooq.exception.DataAccessExceptionif not provided for the DATABASE handler.code- For theDATABASEhandler, this is the vendor-specific database error code (SQLException.getErrorCode()). The meaning and usefulness of this value varies between databases. For example, Oracle uses specific error codes while PostgreSQL always returns0. Not in use for the GENERIC handler.sqlState- For theDATABASEhandler, this is the standardized SQL state code (SQLException.getSQLState()). SQL state codes follow the SQL standard and are supported across databases, though the level of specificity varies. For example, PostgreSQL returns specific codes like"23514"(check constraint violation), while Oracle returns the generic"23000"for all constraint violations. Not in use for the GENERIC handler.matches- Can be used to specify a string that the exception message must contain in order to be handled.description- A description of the error to be returned to the user. If not provided, the exception message will be used instead.
Note:
codeandsqlStatecan be used together when you need to match on both. When both are specified, the exception must match on all provided criteria. Which parameter is most useful depends on your database, check your database's documentation for how it reports error codes and SQL states.
The @externalField directive indicates that the annotated field is retrieved using a static extension method implemented in Java. This is typically used when the field's value requires custom logic implemented in Java.
Requirements:
- Method has to include the table class it's extending as its single parameter - additional parameters is not supported.
- Method needs to return the generic type
Field - Parameter type of the generic type needs to match scalar type used in your field in the GraphQL schema, i.e:
Field<TYPE_MATCHING_SCALAR_TYPE> - Code reference should be added to
externalReferenceImportsin yourpom.xml. See code references for details on configuration. - The
typewhere externalField is used needs to have a table associated to it
Say you have a GraphQL schema like this, where you have defined a type with an associated database table, which returns a one field:
type Film @table(name: "FILM") {
isEnglish: Boolean @externalField
}You want to write custom logic for the isEnglish-field and therefore add the externalField-directive.
The example below show such custom logic can be implemented:
public static Field<Boolean> isEnglish(Film film) {
return DSL.iif(
film.ORIGINAL_LANGUAGE_ID.eq(1),
inline(true),
inline(false)
);
}The static extension method is required to have the table class it's extending as a parameter, which is Film in this example.
It also needs to return the generic type Field, with the actual type within matching the scalar type from the schema.
In this example you see that the scalar type in the schema is Boolean, which means that the generic type needs to look
like this Field<Boolean>.
The file containing this method needs to be discoverable for Graphitron.
You need to provide the path for the file within the tag externalReferenceImports in your pom.xml:
<externalReferenceImports>
<package>some.path.MyClass</package>
</externalReferenceImports>Graphitron will then use this custom logic in the select part of the database query, as shown below:
public class QueryDBQueries {
public static Film queryForQuery(DSLContext ctx, SelectionSet select) {
var _film = FILM.as("film_2952383337");
return ctx
.select(DSL.row(some.path.MyClass.isEnglish(_film)).mapping(Functions.nullOnAllNull(Film::new)))
.from(_film)
.fetchOne(it -> it.into(Film.class));
}
}Note that jOOQ records do not have space for extra fields, and any usage of external fields where such fields must be stored in a jOOQ record will fail.
Graphitron supports generating queries for interfaces and unions, allowing polymorphic return types.
Single table interfaces are interfaces where every implementation is in the same table, and the type of the interface is determined by a discriminator column. Graphitron supports generating queries for this type of interface on the Query-type.
In order to define a single table interface and its types, the discriminate and discriminator directives are used, in addition to the table directive.
For an interface to be considered a single table interface, both the table and discriminate directives must be set:
interface Employee @table(name: "EMPLOYEE") @discriminate(on: "EMPLOYEE_TITLE") {
…
}The discriminate directive determines the discriminator column for the interface.
In the example above, the column EMPLOYEE_TITLE in table EMPLOYEE is the discriminator column.
Each implementation of the interface must have the table and discriminator-directives set:
type TechLead implements Employee @table(name: "EMPLOYEE") @discriminator(value: "TECH_LEAD") {
…
}The table directive parameter must be the same as for the interface, and the discriminator directive indicates which value the discriminator column has if the row is of type TechLead.
In other words, if EMPLOYEE.EMPLOYEE_TITLE is "TECH_LEAD", the row is of type TechLead.
With the example above, Graphitron can generate queries like these in the Query-type:
type Query {
employees: [Employee]
employees(first: Int = 100, after: String): EmployeeConnection
}Queries with input and query conditions are also supported.
- Types implementing multiple single table interfaces are supported if they have the same discriminator value for all interfaces
- The discriminator column must return a string type
- Every reference field in the interface must have the same configuration in every implementing type
- Other reference fields sharing the same name must also have the same configuration across the implementing types
Multitable interfaces are interfaces where the implementations are spread across tables, and a row's type is determined by its table. Graphitron supports generating queries for this type of interface on the Query-type.
No special directives are required on the interface definition. Any directives on fields in the interface will be ignored, and should instead be placed on the fields in the implementing type.
interface Titled {
title: String
}For every implementing type, the table directive is required.
type Film implements Titled @table(name: "FILM") {
title: String @field(name: …)
…
}
type Book implements Titled @table(name: "BOOK") {
title: String
…
}With the example above, Graphitron can generate queries like these in the Query-type:
type Query {
titled: [Titled]
titled(first: Int = 100, after: String): TitledConnection
}Graphitron supports union queries on the Query type. All the union's subtypes need to have the table directive set.
Schema setup:
union LanguageStaffUnion = Language | Staff
type Language @table {
name: String
…
}
type Staff @table {
id: Int @field(name: "STAFF_ID")
…
}
Example:
type Query {
languageOrStaff: [LanguageStaffUnion]
}Conditions on queries returning multitable interfaces or unions are also supported. Since the table is passed as a parameter to the condition method and each implementing type has a different table, a unique method for each implementation is necessary. These methods must all share the same method signature, except for the first table parameter.
type Query {
titled(prefix: String): [Titled] @condition(condition: {className: "some.path.TitledConditions", method: "titledMethod"})
}class TitledConditions {
static Condition titledMethod(Film film, String prefix) {…}
static Condition titledMethod(Book book, String prefix) {…}
}The lookupKey directive enables batch lookups: you send a list of values and get back a list of results
in the same order, where each position contains either the matching object or null if nothing was found.
This is useful when you need to fetch multiple objects by some identifying value in a single query.
For example, given this schema:
type Query {
filmsByTitle(titles: [String] @lookupKey): [Film] @table(name: "film")
}A client could query:
query {
filmsByTitle(titles: ["ACADEMY DINOSAUR", "NONEXISTENT FILM", "ACE GOLDFINGER"])
}And receive results preserving the input order, with null for unmatched keys:
{
"filmsByTitle": [
{ "title": "ACADEMY DINOSAUR", "releaseYear": 2006 },
null,
{ "title": "ACE GOLDFINGER", "releaseYear": 2006 }
]
}If at least one argument is marked with lookupKey, the query automatically becomes a lookup. The key values should uniquely identify rows — if multiple rows match the same key, only one is returned. Only arguments for root-level fields or their referenced input types can be keys.
- All keys must be one-dimensional listed types. It does not make sense to invoke this logic for fetching single objects.
- More than one key may be used at once, but each key must always have the same number of values. In addition, each value in a key must be correlated with the values of any other keys at the same indexes. This can be enforced by wrapping the keys in input types.
The keys can be wrapped with input types and can be set on input type references, but they must always end up being a one-dimensional list. In other words, a list of input types which itself contains a list of keys will not work, and the key values themselves can never be lists.
You can use multiple keys together. Each key list must have the same length, and values at the same index are correlated:
type Query {
staffLookup(firstNames: [String] @lookupKey, lastNames: [String] @lookupKey): [Staff]
}type Query {
# @lookupKey on a scalar list argument. Other arguments are still used as usual.
query0(argument0: [String] @lookupKey, argument1: String): [SomeType]
# @lookupKey on an input type applies to all its fields.
query1(argument: [In] @lookupKey): [SomeType]
# @lookupKey can also be placed on individual fields in the input type.
query2(argument: [InKey]): [SomeType]
# Multiple keys are supported.
query3(argument0: [String] @lookupKey, argument1: [Int] @lookupKey): [SomeType]
# Nested input types are supported. Every leaf field becomes a key.
query4(argument: [InNested] @lookupKey): [SomeType]
# Invalid: two layers of lists are not allowed.
badQuery(argument: [InList] @lookupKey): [SomeType]
}
input In {
field0: String
field1: Int
}
input InKey {
field: String @lookupKey
}
input InList {
field: [String]
}
input InNested {
field: In
}If no ordering is specified, paginated queries default to sorting by primary key in ascending order.
You can control sorting behavior with the @orderBy and @defaultOrder directives.
Incorporating the orderBy functionality in your GraphQL schema allow API users to sort query results based on specific fields.
Step 1: Define Order Input Types
Define an input type for orderBy. This input type must include exactly one direction enum field (with ASC/DESC values) and
exactly one orderBy enum field (whose values have @order directives). The field names are flexible, Graphitron identifies each field by its type, not its name:
input FilmOrder {
direction: OrderDirection!
orderByField: FilmOrderByField!
}The OrderDirection enum specifies the sort order:
enum OrderDirection {
ASC
DESC
}Step 2: Define Order By fields
Next, define the OrderByField enum. This should include all the fields on which sorting should be allowed.
The @order directive specifies how each enum value maps to database sorting. There are three modes:
Index-based sorting (backed by a database index):
enum FilmOrderByField {
LANGUAGE @order(index: "IDX_FK_LANGUAGE_ID")
TITLE @order(index: "IDX_TITLE")
}Note: A single OrderByField can involve more than one field in the database, e.g.: STORE_ID_FILM_ID @order(index: "idx_store_id_film_id")
Field-based sorting (with optional collation, no index required):
enum PersonOrderByField {
NAME_NORSK @order(fields: [
{name: "LAST_NAME", collate: "xdanish_ai"},
{name: "FIRST_NAME", collate: "xdanish_ai"}
])
STATUS @order(fields: [{name: "ACTIVE"}])
}Primary key sorting (uses the table's primary key):
enum PersonOrderByField {
ID @order(primaryKey: true)
}Exactly one of index, fields, or primaryKey must be set on each enum value.
For index-based sorting, ensure index code generation is enabled in jOOQ's generator config:
<database>
<includeIndexes>true</includeIndexes>
…
</database>Graphitron will look for the indexes using their names as specified by the @order directive. Exceptions will be thrown if no matching index is found for the corresponding database table.
Step 3: Add orderBy argument to Query
Add the orderBy argument to your query. Use the orderBy directive to indicate that the input should be handled according to the orderBy-functionality:
type Query {
films(orderBy: FilmOrder @orderBy, first: Int = 100, after: String): FilmConnection
}The @defaultOrder directive specifies the default sort order for a field when no @orderBy input is provided.
Exactly one of index, fields, or primaryKey must be set.
Parameters:
- index - Name of the database index to use for sorting
- fields - Field(s) to sort by, with optional collation (see FieldSort)
- primaryKey - Use the table's primary key for sorting (default:
false) - direction - Sort direction:
ASCorDESC(default:ASC)
If an @orderBy input is also present on the field and the user provides an orderBy value, the user's value takes precedence over the default.
Index-based:
type Query {
films(orderBy: FilmOrder @orderBy, first: Int = 100, after: String): FilmConnection @defaultOrder(index: "IDX_TITLE")
}Field-based (with optional collation):
type Query {
customers(first: Int = 100, after: String): CustomerConnection @defaultOrder(fields: [
{name: "LAST_NAME", collate: "case_insensitive"},
{name: "FIRST_NAME", collate: "case_insensitive"}
])
}Primary key:
type Query {
customers(first: Int = 100, after: String): CustomerConnection @defaultOrder(primaryKey: true, direction: DESC)
}Currently, Graphitron reserves two interface names for special purposes, namely Node and Error.
The Node interface must contain an ID field. Any type that implements this interface will have a Node data fetcher generated. This is designed to be compatible with Global Object Identification.
interface Node {
id: ID!
}An interface used to enforce certain fields that use the service directive.
interface Error {
path: [String!]!
message: String!
}This may be removed/changed in the near future as it may not be flexible enough.
If your schema includes Global Object Identification,
you need to have an instance of a class extending NodeIdStrategy,
optionally overriding methods for custom ID encoding/decoding, and pass it to the wiring builder in order to resolve node queries on your server.
@Singleton
public class MyNodeIdStrategy extends NodeIdStrategy {
// Optionally override methods for custom ID encoding/decoding
}Pass it to the runtime wiring:
Graphitron.getRuntimeWiring(nodeIdStrategy);The node directive marks a type as globally identifiable by its ID, following your custom node ID strategy. To use this directive, the type must:
The node directive supports two parameters for ID configuration:
- typeId: Sets the type identifier which is embedded in the ID and used to determine the correct type given an ID. Defaults to the GraphQL type name.
- keyColumns: The ordered table columns to include in the ID. If omitted, Graphitron uses the table's primary key.
- Note: If the primary key changes, the generated ID will also change. Therefore, in order to ensure stable IDs, we recommend hard coding the primary key columns.
Given this schema:
type Customer implements Node @node @table {
id: ID! @nodeId
}Graphitron resolves the primary key columns at generation time and passes them to the createId method in your node strategy:
nodeIdStrategy.createId("Customer", CUSTOMER.CUSTOMER_ID)And with a custom configuration like this:
type Customer implements Node @node(typeId: "C", keyColumns: ["CUSTOMER_ID", "EMAIL"]) @table {
id: ID! @nodeId
}Graphitron will instead pass these arguments:
nodeIdStrategy.createId("C", CUSTOMER.CUSTOMER_ID, CUSTOMER.EMAIL)Note: This directive is not currently supported when combined with services.
The nodeId directive must be placed on all node ID fields to indicate that they represent globally unique IDs, according to your node ID strategy.
The directive accepts one parameter:
typeName— The name of the node type the ID refers to.- The type name is case-sensitive.
- The provided type must have the @node directive.
The typeName parameter of the nodeId directive is optional. If omitted, Graphitron will try to deduce the node type like this:
- For object fields without the reference directive: uses the containing type (if it is a node type)
- For fields with the reference directive or jOOQ record input fields: uses the node type with the same table (if unambiguous)
It is possible to reference another type's node ID by specifying the typeName parameter.
The typeName implies a reference to another table, so this is often combined with the reference directive.
For example, if you have a Customer type that has a field referring to an Address ID:
type Customer implements Node @node @table {
id: ID! @nodeId
addressId: ID @nodeId(typeName: "Address") # Implicit reference to the 'ADDRESS' table
}
type Address implements Node @node @table {
id: ID! @nodeId
}For internal testing purposes Graphitron uses predefined input schemas combined with expected file results. When altering the generator, these files likely have to be adjusted as well. These test schemas can also be read as further examples on how to use the various directives.
Graphitron uses the Sakila test database to generate the jOOQ types needed for tests.
These are generated to src/test/java when running maven. These files are ignored by Git, and they are only generated
when they do not exist already or the property testdata.schema.version in pom.xml is updated. In other words,
updating the property will refresh the jOOQ types.
This is typically only done when altering the database to add new tables or keys.