Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 56 additions & 14 deletions graphql-apt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,74 @@ See the [feign-graphql README](../graphql/README.md) for usage examples.

## Generated Output

For a schema type like:
### Result types with inner records

Nested result types are generated as inner records scoped to each query result. This ensures each query gets exactly the fields it selects, even when different queries target the same GraphQL type.

For a query like:

```graphql
type User {
id: ID!
name: String!
email: String
status: Status!
{
starship(id: "1") {
id name
location { planet sector }
specs { lengthMeters classification }
}
}
```

The processor generates a single file with inner records:

```java
public record StarshipResult(String id, String name, Location location, Specs specs) {

public record Location(String planet, String sector) {}

public record Specs(Integer lengthMeters, String classification) {}

enum Status {
ACTIVE
INACTIVE
}
```

The processor generates:
Two different queries can select different fields from the same GraphQL type without conflict:

```java
// Query 1: selects location { planet }
public record CharByPlanet(String id, Location location) {
public record Location(String planet) {}
}

// Query 2: selects location { sector region }
public record CharByRegion(String id, Location location) {
public record Location(String sector, String region) {}
}
```

### Conflicting return type error

If two queries use the same return type name but select different fields, the processor reports compilation errors on both methods showing which fields each selects:

```
error: Conflicting return type 'CharResult': method selects [id, email] but method 'query1()' already selects [id, name]
CharResult query2();
^
error: Conflicting return type 'CharResult': method selects [id, name] but method 'query2()' selects [id, email]
CharResult query1();
^
```

### Input types and enums

Input types and enums are generated as top-level files since they represent the full schema type:

```java
public record User(String id, String name, String email, Status status) {}
public record CreateCharacterInput(String name, String email, Episode appearsIn) {}
```

```java
public enum Status {
ACTIVE,
INACTIVE
public enum Episode {
NEWHOPE,
EMPIRE,
JEDI
}
```

Expand Down
188 changes: 165 additions & 23 deletions graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@
import java.io.PrintWriter;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import java.util.TreeSet;
Expand All @@ -56,6 +58,7 @@ public class TypeGenerator {
private final String targetPackage;
private final Set<String> generatedTypes = new HashSet<>();
private final Queue<String> pendingTypes = new ArrayDeque<>();
private final Map<String, ResultTypeUsage> resultTypeSignatures = new HashMap<>();

public TypeGenerator(
Filer filer,
Expand All @@ -75,20 +78,60 @@ public void generateResultType(
SelectionSet selectionSet,
ObjectTypeDefinition parentType,
Element element) {
if (generatedTypes.contains(className)) {
var signature = canonicalize(selectionSet);
var fields = describeFields(selectionSet);
var existing = resultTypeSignatures.get(className);
if (existing != null) {
if (!existing.signature.equals(signature)) {
messager.printMessage(
Diagnostic.Kind.ERROR,
"Conflicting return type '"
+ className
+ "': method selects ["
+ fields
+ "] but method '"
+ existing.element.getSimpleName()
+ "()' already selects ["
+ existing.fields
+ "]",
element);
messager.printMessage(
Diagnostic.Kind.ERROR,
"Conflicting return type '"
+ className
+ "': method selects ["
+ existing.fields
+ "] but method '"
+ element.getSimpleName()
+ "()' selects ["
+ fields
+ "]",
existing.element);
return;
}
return;
}
resultTypeSignatures.put(className, new ResultTypeUsage(signature, fields, element));

var tree = buildResultType(className, selectionSet, parentType);
if (tree == null) {
return;
}
generatedTypes.add(className);

writeResultRecord(tree, element);
processPendingTypes(element);
}

private ResultTypeDefinition buildResultType(
String className, SelectionSet selectionSet, ObjectTypeDefinition parentType) {
var fields = new ArrayList<RecordField>();
var innerTypes = new ArrayList<ResultTypeDefinition>();

for (var selection : selectionSet.getSelections()) {
if (!(selection instanceof Field)) {
if (!(selection instanceof Field field)) {
continue;
}
var field = (Field) selection;
var fieldName = field.getName();

var schemaDef = findFieldDefinition(parentType, fieldName);
if (schemaDef == null) {
continue;
Expand All @@ -99,8 +142,16 @@ public void generateResultType(

if (field.getSelectionSet() != null && !field.getSelectionSet().getSelections().isEmpty()) {
var nestedClassName = capitalize(fieldName);
generateNestedResultType(nestedClassName, field.getSelectionSet(), rawTypeName, element);
var nestedType = wrapType(fieldType, ClassName.get(targetPackage, nestedClassName));
var nestedObjectType =
registry.getType(rawTypeName, ObjectTypeDefinition.class).orElse(null);
if (nestedObjectType != null) {
var innerTree =
buildResultType(nestedClassName, field.getSelectionSet(), nestedObjectType);
if (innerTree != null) {
innerTypes.add(innerTree);
}
}
var nestedType = wrapType(fieldType, ClassName.get("", nestedClassName));
fields.add(toRecordField(fieldName, nestedType));
} else {
var javaType = typeMapper.map(fieldType);
Expand All @@ -109,8 +160,105 @@ public void generateResultType(
}
}

writeRecord(className, fields, element);
processPendingTypes(element);
var def = new ResultTypeDefinition();
def.className = className;
def.fields = fields;
def.innerTypes = innerTypes;
return def;
}

private void writeResultRecord(ResultTypeDefinition tree, Element element) {
var fqn = targetPackage.isEmpty() ? tree.className : targetPackage + "." + tree.className;
try {
var sourceFile = filer.createSourceFile(fqn, element);
try (var out = new PrintWriter(sourceFile.openWriter())) {
if (!targetPackage.isEmpty()) {
out.println("package " + targetPackage + ";");
out.println();
}

var imports = new TreeSet<String>();
collectAllImports(tree, imports);
if (!imports.isEmpty()) {
for (var imp : imports) {
out.println("import " + imp + ";");
}
out.println();
}

writeRecordBody(out, tree, "");
}
} catch (FilerException e) {
// Type already generated by another interface in the same compilation round
} catch (IOException e) {
messager.printMessage(
Diagnostic.Kind.ERROR,
"Failed to write generated type " + tree.className + ": " + e.getMessage(),
element);
}
}

private void writeRecordBody(PrintWriter out, ResultTypeDefinition tree, String indent) {
var params =
tree.fields.stream()
.map(f -> f.typeString + " " + f.name)
.collect(Collectors.joining(", "));

if (tree.innerTypes.isEmpty()) {
out.println(indent + "public record " + tree.className + "(" + params + ") {}");
} else {
out.println(indent + "public record " + tree.className + "(" + params + ") {");
out.println();
for (var inner : tree.innerTypes) {
writeRecordBody(out, inner, indent + " ");
out.println();
}
out.println(indent + "}");
}
}

private void collectAllImports(ResultTypeDefinition tree, Set<String> imports) {
for (var field : tree.fields) {
if (field.typeName != null) {
collectImportsFromTypeName(field.typeName, imports);
}
}
for (var inner : tree.innerTypes) {
collectAllImports(inner, imports);
}
}

private String canonicalize(SelectionSet selectionSet) {
var entries = new ArrayList<String>();
for (var selection : selectionSet.getSelections()) {
if (!(selection instanceof Field field)) {
continue;
}
var name = field.getName();
if (field.getSelectionSet() != null && !field.getSelectionSet().getSelections().isEmpty()) {
entries.add(name + "{" + canonicalize(field.getSelectionSet()) + "}");
} else {
entries.add(name);
}
}
entries.sort(String::compareTo);
return String.join(",", entries);
}

private String describeFields(SelectionSet selectionSet) {
var entries = new ArrayList<String>();
for (var selection : selectionSet.getSelections()) {
if (!(selection instanceof Field field)) {
continue;
}
var name = field.getName();
if (field.getSelectionSet() != null && !field.getSelectionSet().getSelections().isEmpty()) {
entries.add(name + " { " + describeFields(field.getSelectionSet()) + " }");
} else {
entries.add(name);
}
}
return String.join(", ", entries);
}

public void generateInputType(String className, String graphqlTypeName, Element element) {
Expand Down Expand Up @@ -143,20 +291,6 @@ public void generateInputType(String className, String graphqlTypeName, Element
processPendingTypes(element);
}

private void generateNestedResultType(
String className, SelectionSet selectionSet, String graphqlTypeName, Element element) {
if (generatedTypes.contains(className)) {
return;
}

var maybeDef = registry.getType(graphqlTypeName, ObjectTypeDefinition.class);
if (maybeDef.isEmpty()) {
return;
}

generateResultType(className, selectionSet, maybeDef.get(), element);
}

private void processPendingTypes(Element element) {
while (!pendingTypes.isEmpty()) {
var typeName = pendingTypes.poll();
Expand Down Expand Up @@ -378,6 +512,14 @@ private void writeRecord(String className, List<RecordField> fields, Element ele
}
}

record ResultTypeUsage(String signature, String fields, Element element) {}

static class ResultTypeDefinition {
String className;
List<RecordField> fields;
List<ResultTypeDefinition> innerTypes;
}

static class RecordField {
final String typeString;
final String name;
Expand Down
Loading