From 2d736c375b611e443759df20d14580f54449121f Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Fri, 27 Feb 2026 08:16:24 -0300 Subject: [PATCH 1/3] Generate nested result types as inner records in graphql-apt Signed-off-by: Marvin Froeder --- graphql-apt/README.md | 65 +++- .../java/feign/graphql/apt/TypeGenerator.java | 151 ++++++-- .../apt/GraphqlSchemaProcessorTest.java | 330 +++++++++++++----- .../test/resources/scalar-test-schema.graphql | 6 +- .../src/test/resources/test-schema.graphql | 144 ++++---- 5 files changed, 498 insertions(+), 198 deletions(-) diff --git a/graphql-apt/README.md b/graphql-apt/README.md index db67c2418..c2fa517d6 100644 --- a/graphql-apt/README.md +++ b/graphql-apt/README.md @@ -14,32 +14,69 @@ 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 a compilation error: + +``` +Conflicting return type 'CharResult': different queries select different fields for this type +``` + +### 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 } ``` diff --git a/graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java b/graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java index 3b70e8480..68c633896 100644 --- a/graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java +++ b/graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java @@ -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; @@ -56,6 +58,7 @@ public class TypeGenerator { private final String targetPackage; private final Set generatedTypes = new HashSet<>(); private final Queue pendingTypes = new ArrayDeque<>(); + private final Map resultTypeSignatures = new HashMap<>(); public TypeGenerator( Filer filer, @@ -75,20 +78,41 @@ public void generateResultType( SelectionSet selectionSet, ObjectTypeDefinition parentType, Element element) { - if (generatedTypes.contains(className)) { + var signature = canonicalize(selectionSet); + var existing = resultTypeSignatures.get(className); + if (existing != null) { + if (!existing.equals(signature)) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "Conflicting return type '" + + className + + "': different queries select different fields for this type", + element); + return; + } return; } - generatedTypes.add(className); + resultTypeSignatures.put(className, signature); + + var tree = buildResultType(className, selectionSet, parentType); + if (tree == null) { + return; + } + + writeResultRecord(tree, element); + processPendingTypes(element); + } + private ResultTypeDefinition buildResultType( + String className, SelectionSet selectionSet, ObjectTypeDefinition parentType) { var fields = new ArrayList(); + var innerTypes = new ArrayList(); 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; @@ -99,8 +123,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); @@ -109,8 +141,89 @@ 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(); + 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 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(); + 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); } public void generateInputType(String className, String graphqlTypeName, Element element) { @@ -143,20 +256,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(); @@ -378,6 +477,12 @@ private void writeRecord(String className, List fields, Element ele } } + static class ResultTypeDefinition { + String className; + List fields; + List innerTypes; + } + static class RecordField { final String typeString; final String name; diff --git a/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java b/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java index cd272b2f2..689f79fe3 100644 --- a/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java +++ b/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java @@ -37,18 +37,18 @@ void validMutationGeneratesTypes() { @GraphqlSchema("test-schema.graphql") interface MyApi { @GraphqlQuery(\""" - mutation createUser($input: CreateUserInput!) { - createUser(input: $input) { id name email status } + mutation createCharacter($input: CreateCharacterInput!) { + createCharacter(input: $input) { id name email appearsIn } }\""") - CreateUserResult createUser(CreateUserInput input); + CreateCharacterResult createCharacter(CreateCharacterInput input); } """); var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); assertThat(compilation).succeeded(); - assertThat(compilation).generatedSourceFile("test.CreateUserResult"); - assertThat(compilation).generatedSourceFile("test.CreateUserInput"); + assertThat(compilation).generatedSourceFile("test.CreateCharacterResult"); + assertThat(compilation).generatedSourceFile("test.CreateCharacterInput"); } @Test @@ -88,7 +88,7 @@ void missingSchemaReportsError() { @GraphqlSchema("nonexistent-schema.graphql") interface NoSchemaApi { - @GraphqlQuery("{ user(id: \\"1\\") { id } }") + @GraphqlQuery("{ character(id: \\"1\\") { id } }") Object query(); } """); @@ -100,7 +100,7 @@ interface NoSchemaApi { } @Test - void nestedTypesAreGenerated() { + void nestedTypesAreGeneratedAsInnerRecords() { var source = JavaFileObjects.forSourceString( "test.NestedApi", @@ -113,17 +113,20 @@ void nestedTypesAreGenerated() { @GraphqlSchema("test-schema.graphql") interface NestedApi { @GraphqlQuery(\""" - { user(id: "1") { id name address { street city country } } } + { character(id: "1") { id name location { planet sector region } } } \""") - UserResult getUser(); + CharacterResult getCharacter(); } """); var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); assertThat(compilation).succeeded(); - assertThat(compilation).generatedSourceFile("test.UserResult"); - assertThat(compilation).generatedSourceFile("test.Address"); + assertThat(compilation).generatedSourceFile("test.CharacterResult"); + assertThat(compilation) + .generatedSourceFile("test.CharacterResult") + .contentsAsUtf8String() + .contains("public record Location(String planet, String sector, String region) {}"); } @Test @@ -140,18 +143,18 @@ void enumsAreGeneratedAsJavaEnums() { @GraphqlSchema("test-schema.graphql") interface EnumApi { @GraphqlQuery(\""" - mutation updateStatus($id: ID!, $status: Status!) { - updateStatus(id: $id, status: $status) { id status } + mutation updateEpisode($id: ID!, $episode: Episode!) { + updateEpisode(id: $id, episode: $episode) { id appearsIn } }\""") - StatusResult updateStatus(String id, Status status); + EpisodeResult updateEpisode(String id, Episode episode); } """); var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); assertThat(compilation).succeeded(); - assertThat(compilation).generatedSourceFile("test.StatusResult"); - assertThat(compilation).generatedSourceFile("test.Status"); + assertThat(compilation).generatedSourceFile("test.EpisodeResult"); + assertThat(compilation).generatedSourceFile("test.Episode"); } @Test @@ -167,15 +170,15 @@ void listTypesMapToJavaList() { @GraphqlSchema("test-schema.graphql") interface ListApi { - @GraphqlQuery("{ user(id: \\"1\\") { id name tags } }") - UserWithTagsResult getUser(); + @GraphqlQuery("{ character(id: \\"1\\") { id name tags } }") + CharacterWithTagsResult getCharacter(); } """); var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); assertThat(compilation).succeeded(); - assertThat(compilation).generatedSourceFile("test.UserWithTagsResult"); + assertThat(compilation).generatedSourceFile("test.CharacterWithTagsResult"); } @Test @@ -192,29 +195,29 @@ void multipleMethodsSharingInputType() { @GraphqlSchema("test-schema.graphql") interface SharedApi { @GraphqlQuery(\""" - mutation createUser($input: CreateUserInput!) { - createUser(input: $input) { id name } + mutation createCharacter($input: CreateCharacterInput!) { + createCharacter(input: $input) { id name } }\""") - CreateResult1 createUser1(CreateUserInput input); + CreateResult1 createCharacter1(CreateCharacterInput input); @GraphqlQuery(\""" - mutation createUser($input: CreateUserInput!) { - createUser(input: $input) { id email } + mutation createCharacter($input: CreateCharacterInput!) { + createCharacter(input: $input) { id email } }\""") - CreateResult2 createUser2(CreateUserInput input); + CreateResult2 createCharacter2(CreateCharacterInput input); } """); var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); assertThat(compilation).succeeded(); - assertThat(compilation).generatedSourceFile("test.CreateUserInput"); + assertThat(compilation).generatedSourceFile("test.CreateCharacterInput"); assertThat(compilation).generatedSourceFile("test.CreateResult1"); assertThat(compilation).generatedSourceFile("test.CreateResult2"); } @Test - void deeplyNestedOrganizationQuery() { + void deeplyNestedStarshipQuery() { var source = JavaFileObjects.forSourceString( "test.DeepApi", @@ -228,31 +231,39 @@ void deeplyNestedOrganizationQuery() { interface DeepApi { @GraphqlQuery(\""" { - organization(id: "1") { + starship(id: "1") { id name - address { street city coordinates { latitude longitude } } - departments { + location { planet sector coordinates { latitude longitude } } + squadrons { id name - lead { id name status } + leader { id name appearsIn } members { id name email } - subDepartments { id name tags { key value } } - tags { key value } + subSquadrons { id name traits { key value } } + traits { key value } } - metadata { foundedYear industry categories { name tags { key value } } } + specs { lengthMeters classification weapons { name traits { key value } } } } }\""") - OrgResult getOrganization(); + StarshipResult getStarship(); } """); var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); assertThat(compilation).succeeded(); - assertThat(compilation).generatedSourceFile("test.OrgResult"); - assertThat(compilation).generatedSourceFile("test.Address"); - assertThat(compilation).generatedSourceFile("test.Coordinates"); - assertThat(compilation).generatedSourceFile("test.Departments"); - assertThat(compilation).generatedSourceFile("test.Metadata"); + assertThat(compilation).generatedSourceFile("test.StarshipResult"); + assertThat(compilation) + .generatedSourceFile("test.StarshipResult") + .contentsAsUtf8String() + .contains("public record Location("); + assertThat(compilation) + .generatedSourceFile("test.StarshipResult") + .contentsAsUtf8String() + .contains("public record Squadrons("); + assertThat(compilation) + .generatedSourceFile("test.StarshipResult") + .contentsAsUtf8String() + .contains("public record Specs("); } @Test @@ -269,26 +280,26 @@ void complexMutationWithNestedInputs() { @GraphqlSchema("test-schema.graphql") interface ComplexMutationApi { @GraphqlQuery(\""" - mutation createOrg($input: CreateOrgInput!) { - createOrganization(input: $input) { + mutation createStarship($input: CreateStarshipInput!) { + createStarship(input: $input) { id name - departments { id name subDepartments { id name } } + squadrons { id name subSquadrons { id name } } } }\""") - CreateOrgResult createOrg(CreateOrgInput input); + CreateStarshipResult createStarship(CreateStarshipInput input); } """); var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); assertThat(compilation).succeeded(); - assertThat(compilation).generatedSourceFile("test.CreateOrgResult"); - assertThat(compilation).generatedSourceFile("test.CreateOrgInput"); - assertThat(compilation).generatedSourceFile("test.DepartmentInput"); - assertThat(compilation).generatedSourceFile("test.TagInput"); - assertThat(compilation).generatedSourceFile("test.AddressInput"); - assertThat(compilation).generatedSourceFile("test.OrgMetadataInput"); - assertThat(compilation).generatedSourceFile("test.CategoryInput"); + assertThat(compilation).generatedSourceFile("test.CreateStarshipResult"); + assertThat(compilation).generatedSourceFile("test.CreateStarshipInput"); + assertThat(compilation).generatedSourceFile("test.SquadronInput"); + assertThat(compilation).generatedSourceFile("test.TraitInput"); + assertThat(compilation).generatedSourceFile("test.LocationInput"); + assertThat(compilation).generatedSourceFile("test.ShipSpecsInput"); + assertThat(compilation).generatedSourceFile("test.WeaponInput"); } @Test @@ -305,14 +316,14 @@ void searchWithComplexFilterInput() { @GraphqlSchema("test-schema.graphql") interface SearchApi { @GraphqlQuery(\""" - query searchOrgs($criteria: OrgSearchCriteria!) { - searchOrganizations(criteria: $criteria) { + query searchStarships($criteria: StarshipSearchCriteria!) { + searchStarships(criteria: $criteria) { id name - departments { id name lead { id name } tags { key value } } - metadata { foundedYear categories { name parentCategory { name } } } + squadrons { id name leader { id name } traits { key value } } + specs { lengthMeters weapons { name parentWeapon { name } } } } }\""") - SearchResult searchOrganizations(OrgSearchCriteria criteria); + SearchResult searchStarships(StarshipSearchCriteria criteria); } """); @@ -320,9 +331,9 @@ query searchOrgs($criteria: OrgSearchCriteria!) { assertThat(compilation).succeeded(); assertThat(compilation).generatedSourceFile("test.SearchResult"); - assertThat(compilation).generatedSourceFile("test.OrgSearchCriteria"); - assertThat(compilation).generatedSourceFile("test.DepartmentFilterInput"); - assertThat(compilation).generatedSourceFile("test.TagInput"); + assertThat(compilation).generatedSourceFile("test.StarshipSearchCriteria"); + assertThat(compilation).generatedSourceFile("test.SquadronFilterInput"); + assertThat(compilation).generatedSourceFile("test.TraitInput"); } @Test @@ -340,18 +351,18 @@ void listReturnTypeGeneratesElementType() { @GraphqlSchema("test-schema.graphql") interface ListReturnApi { @GraphqlQuery(\""" - query listUsers($filter: UserFilter) { - users(filter: $filter) { id name email status } + query listCharacters($filter: CharacterFilter) { + characters(filter: $filter) { id name email appearsIn } }\""") - List listUsers(UserFilter filter); + List listCharacters(CharacterFilter filter); } """); var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); assertThat(compilation).succeeded(); - assertThat(compilation).generatedSourceFile("test.UserListResult"); - assertThat(compilation).generatedSourceFile("test.UserFilter"); + assertThat(compilation).generatedSourceFile("test.CharacterListResult"); + assertThat(compilation).generatedSourceFile("test.CharacterFilter"); } @Test @@ -368,10 +379,10 @@ void existingExternalTypeSkipsGeneration() { @GraphqlSchema("test-schema.graphql") interface ExternalTypeApi { @GraphqlQuery(\""" - mutation createUser($input: CreateUserInput!) { - createUser(input: $input) { id name email } + mutation createCharacter($input: CreateCharacterInput!) { + createCharacter(input: $input) { id name email } }\""") - CreateResult createUser(feign.graphql.GraphqlQuery input); + CreateResult createCharacter(feign.graphql.GraphqlQuery input); } """); @@ -382,7 +393,7 @@ mutation createUser($input: CreateUserInput!) { } @Test - void userWithOrganizationMultipleLevelReuse() { + void characterWithStarshipMultipleLevelReuse() { var source = JavaFileObjects.forSourceString( "test.ReuseApi", @@ -396,31 +407,31 @@ void userWithOrganizationMultipleLevelReuse() { interface ReuseApi { @GraphqlQuery(\""" { - user(id: "1") { - id name status - address { street city country coordinates { latitude longitude } } - organization { + character(id: "1") { + id name appearsIn + location { planet sector region coordinates { latitude longitude } } + starship { id name - address { street city coordinates { latitude longitude } } - departments { id name members { id name } } + location { planet sector coordinates { latitude longitude } } + squadrons { id name members { id name } } } } }\""") - FullUserResult getFullUser(); + FullCharacterResult getFullCharacter(); @GraphqlQuery(\""" - query listUsers($filter: UserFilter) { - users(filter: $filter) { id name email tags } + query listCharacters($filter: CharacterFilter) { + characters(filter: $filter) { id name email tags } }\""") - UserListResult listUsers(UserFilter filter); + CharacterListResult listCharacters(CharacterFilter filter); } """); var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); assertThat(compilation).succeeded(); - assertThat(compilation).generatedSourceFile("test.FullUserResult"); - assertThat(compilation).generatedSourceFile("test.Status"); + assertThat(compilation).generatedSourceFile("test.FullCharacterResult"); + assertThat(compilation).generatedSourceFile("test.Episode"); } @Test @@ -440,15 +451,15 @@ interface ScalarApi { @Scalar("DateTime") default String dateTime(String raw) { return raw; } - @GraphqlQuery("{ event(id: \\"1\\") { id name startTime endTime } }") - EventResult getEvent(); + @GraphqlQuery("{ battle(id: \\"1\\") { id name startTime endTime } }") + BattleResult getBattle(); } """); var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); assertThat(compilation).succeeded(); - assertThat(compilation).generatedSourceFile("test.EventResult"); + assertThat(compilation).generatedSourceFile("test.BattleResult"); } @Test @@ -464,8 +475,8 @@ void missingScalarAnnotationReportsError() { @GraphqlSchema("scalar-test-schema.graphql") interface MissingScalarApi { - @GraphqlQuery("{ event(id: \\"1\\") { id name startTime } }") - EventResult getEvent(); + @GraphqlQuery("{ battle(id: \\"1\\") { id name startTime } }") + BattleResult getBattle(); } """); @@ -503,8 +514,8 @@ interface ScalarDefinitions { @GraphqlSchema("scalar-test-schema.graphql") interface ChildApi extends ScalarDefinitions { - @GraphqlQuery("{ event(id: \\"1\\") { id name startTime } }") - EventResult getEvent(); + @GraphqlQuery("{ battle(id: \\"1\\") { id name startTime } }") + BattleResult getBattle(); } """); @@ -512,6 +523,149 @@ interface ChildApi extends ScalarDefinitions { javac().withProcessors(new GraphqlSchemaProcessor()).compile(parentSource, source); assertThat(compilation).succeeded(); - assertThat(compilation).generatedSourceFile("test.EventResult"); + assertThat(compilation).generatedSourceFile("test.BattleResult"); + } + + @Test + void conflictingReturnTypesReportsError() { + var source = + JavaFileObjects.forSourceString( + "test.ConflictApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface ConflictApi { + @GraphqlQuery(\""" + { character(id: "1") { id name } } + \""") + CharResult query1(); + + @GraphqlQuery(\""" + { character(id: "2") { id email } } + \""") + CharResult query2(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).failed(); + assertThat(compilation).hadErrorContaining("Conflicting return type 'CharResult'"); + } + + @Test + void sameReturnTypeSameFieldsSucceeds() { + var source = + JavaFileObjects.forSourceString( + "test.SameFieldsApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface SameFieldsApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + CharResult query1(); + + @GraphqlQuery(\""" + { character(id: "2") { id name email } } + \""") + CharResult query2(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.CharResult"); + } + + @Test + void innerClassContentIsCorrect() { + var source = + JavaFileObjects.forSourceString( + "test.InnerApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface InnerApi { + @GraphqlQuery(\""" + { + starship(id: "1") { + id name + location { planet coordinates { latitude longitude } } + specs { lengthMeters classification } + } + }\""") + ShipResult getShip(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.ShipResult").contentsAsUtf8String(); + + contents.contains( + "public record ShipResult(String id, String name, Location location, Specs specs)"); + contents.contains("public record Location(String planet, Coordinates coordinates)"); + contents.contains("public record Coordinates(Double latitude, Double longitude) {}"); + contents.contains("public record Specs(Integer lengthMeters, String classification) {}"); + } + + @Test + void differentQueriesDifferentNestedFields() { + var source = + JavaFileObjects.forSourceString( + "test.DiffNestedApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface DiffNestedApi { + @GraphqlQuery(\""" + { character(id: "1") { id location { planet } } } + \""") + CharByPlanet queryByPlanet(); + + @GraphqlQuery(\""" + { character(id: "2") { id location { sector region } } } + \""") + CharByRegion queryByRegion(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.CharByPlanet"); + assertThat(compilation).generatedSourceFile("test.CharByRegion"); + + assertThat(compilation) + .generatedSourceFile("test.CharByPlanet") + .contentsAsUtf8String() + .contains("public record Location(String planet) {}"); + + assertThat(compilation) + .generatedSourceFile("test.CharByRegion") + .contentsAsUtf8String() + .contains("public record Location(String sector, String region) {}"); } } diff --git a/graphql-apt/src/test/resources/scalar-test-schema.graphql b/graphql-apt/src/test/resources/scalar-test-schema.graphql index d8aa82dd0..aca3bccec 100644 --- a/graphql-apt/src/test/resources/scalar-test-schema.graphql +++ b/graphql-apt/src/test/resources/scalar-test-schema.graphql @@ -2,11 +2,11 @@ scalar DateTime type Query { - event(id: ID!): Event - events: [Event] + battle(id: ID!): Battle + battles: [Battle] } -type Event { +type Battle { id: ID! name: String! startTime: DateTime! diff --git a/graphql-apt/src/test/resources/test-schema.graphql b/graphql-apt/src/test/resources/test-schema.graphql index ec88456d8..0a4ec7680 100644 --- a/graphql-apt/src/test/resources/test-schema.graphql +++ b/graphql-apt/src/test/resources/test-schema.graphql @@ -1,64 +1,68 @@ +# Star Wars schema based on https://graphql.org/learn/schema/ +# Adapted for feign-graphql-apt test purposes + type Query { - user(id: ID!): User - users(filter: UserFilter): [User] - organization(id: ID!): Organization - searchOrganizations(criteria: OrgSearchCriteria!): [Organization] + character(id: ID!): Character + characters(filter: CharacterFilter): [Character] + starship(id: ID!): Starship + searchStarships(criteria: StarshipSearchCriteria!): [Starship] } type Mutation { - createUser(input: CreateUserInput!): User - updateStatus(id: ID!, status: Status!): User - createOrganization(input: CreateOrgInput!): Organization + createCharacter(input: CreateCharacterInput!): Character + updateEpisode(id: ID!, episode: Episode!): Character + createStarship(input: CreateStarshipInput!): Starship } -type User { +type Character { id: ID! name: String! email: String - status: Status! - address: Address + appearsIn: Episode! + location: Location tags: [String] - organization: Organization + friends: [Character] + starship: Starship } -type Organization { +type Starship { id: ID! name: String! - address: Address - departments: [Department] - metadata: OrgMetadata + location: Location + squadrons: [Squadron] + specs: ShipSpecs } -type Department { +type Squadron { id: ID! name: String! - lead: User - members: [User] - subDepartments: [Department] - tags: [Tag] + leader: Character + members: [Character] + subSquadrons: [Squadron] + traits: [Trait] } -type Tag { +type Trait { key: String! value: String! } -type OrgMetadata { - foundedYear: Int - industry: String - categories: [Category] +type ShipSpecs { + lengthMeters: Int + classification: String + weapons: [Weapon] } -type Category { +type Weapon { name: String! - parentCategory: Category - tags: [Tag] + parentWeapon: Weapon + traits: [Trait] } -type Address { - street: String - city: String - country: String +type Location { + planet: String + sector: String + region: String coordinates: Coordinates } @@ -67,30 +71,30 @@ type Coordinates { longitude: Float } -input CreateUserInput { +input CreateCharacterInput { name: String! email: String! - status: Status - address: AddressInput + appearsIn: Episode + location: LocationInput tags: [String] - orgId: ID + starshipId: ID } -input UserFilter { +input CharacterFilter { nameContains: String - status: Status - tagFilter: TagFilterInput + appearsIn: Episode + traitFilter: TraitFilterInput } -input TagFilterInput { +input TraitFilterInput { keys: [String] matchAll: Boolean } -input AddressInput { - street: String - city: String - country: String +input LocationInput { + planet: String + sector: String + region: String coordinates: CoordinatesInput } @@ -99,52 +103,52 @@ input CoordinatesInput { longitude: Float } -input CreateOrgInput { +input CreateStarshipInput { name: String! - address: AddressInput - departments: [DepartmentInput] - metadata: OrgMetadataInput + location: LocationInput + squadrons: [SquadronInput] + specs: ShipSpecsInput } -input DepartmentInput { +input SquadronInput { name: String! - leadUserId: ID - subDepartments: [DepartmentInput] - tags: [TagInput] + leaderCharacterId: ID + subSquadrons: [SquadronInput] + traits: [TraitInput] } -input TagInput { +input TraitInput { key: String! value: String! } -input OrgMetadataInput { - foundedYear: Int - industry: String - categories: [CategoryInput] +input ShipSpecsInput { + lengthMeters: Int + classification: String + weapons: [WeaponInput] } -input CategoryInput { +input WeaponInput { name: String! - parentCategoryName: String - tags: [TagInput] + parentWeaponName: String + traits: [TraitInput] } -input OrgSearchCriteria { +input StarshipSearchCriteria { nameContains: String - industries: [String] - minFoundedYear: Int - departmentFilter: DepartmentFilterInput + classifications: [String] + minLengthMeters: Int + squadronFilter: SquadronFilterInput } -input DepartmentFilterInput { +input SquadronFilterInput { nameContains: String minMembers: Int - tags: [TagInput] + traits: [TraitInput] } -enum Status { - ACTIVE - INACTIVE - PENDING +enum Episode { + NEWHOPE + EMPIRE + JEDI } From 0d04c15ea2567f1f4b8d424e83b96b12c725182c Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Fri, 27 Feb 2026 08:27:30 -0300 Subject: [PATCH 2/3] Improve conflict error: show field selections and both method names Signed-off-by: Marvin Froeder --- .../java/feign/graphql/apt/TypeGenerator.java | 45 +++++++++++++++++-- .../apt/GraphqlSchemaProcessorTest.java | 4 ++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java b/graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java index 68c633896..ce30adff6 100644 --- a/graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java +++ b/graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java @@ -58,7 +58,7 @@ public class TypeGenerator { private final String targetPackage; private final Set generatedTypes = new HashSet<>(); private final Queue pendingTypes = new ArrayDeque<>(); - private final Map resultTypeSignatures = new HashMap<>(); + private final Map resultTypeSignatures = new HashMap<>(); public TypeGenerator( Filer filer, @@ -79,20 +79,39 @@ public void generateResultType( ObjectTypeDefinition parentType, Element element) { var signature = canonicalize(selectionSet); + var fields = describeFields(selectionSet); var existing = resultTypeSignatures.get(className); if (existing != null) { - if (!existing.equals(signature)) { + if (!existing.signature.equals(signature)) { messager.printMessage( Diagnostic.Kind.ERROR, "Conflicting return type '" + className - + "': different queries select different fields for this type", + + "': 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, signature); + resultTypeSignatures.put(className, new ResultTypeUsage(signature, fields, element)); var tree = buildResultType(className, selectionSet, parentType); if (tree == null) { @@ -226,6 +245,22 @@ private String canonicalize(SelectionSet selectionSet) { return String.join(",", entries); } + private String describeFields(SelectionSet selectionSet) { + var entries = new ArrayList(); + 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) { if (generatedTypes.contains(className)) { return; @@ -477,6 +512,8 @@ private void writeRecord(String className, List fields, Element ele } } + record ResultTypeUsage(String signature, String fields, Element element) {} + static class ResultTypeDefinition { String className; List fields; diff --git a/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java b/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java index 689f79fe3..1ab5408fc 100644 --- a/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java +++ b/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java @@ -555,6 +555,10 @@ interface ConflictApi { assertThat(compilation).failed(); assertThat(compilation).hadErrorContaining("Conflicting return type 'CharResult'"); + assertThat(compilation).hadErrorContaining("'query1()'"); + assertThat(compilation).hadErrorContaining("'query2()'"); + assertThat(compilation).hadErrorContaining("id, name"); + assertThat(compilation).hadErrorContaining("id, email"); } @Test From aa57d1f32cf2efcede179407005e5fe82ec58556 Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Fri, 27 Feb 2026 08:30:06 -0300 Subject: [PATCH 3/3] Update README with improved conflict error message format Signed-off-by: Marvin Froeder --- graphql-apt/README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/graphql-apt/README.md b/graphql-apt/README.md index c2fa517d6..cc6935233 100644 --- a/graphql-apt/README.md +++ b/graphql-apt/README.md @@ -58,10 +58,15 @@ public record CharByRegion(String id, Location location) { ### Conflicting return type error -If two queries use the same return type name but select different fields, the processor reports a compilation 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: ``` -Conflicting return type 'CharResult': different queries select different fields for this type +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