Skip to content

Build enums used only by directives without a Java class - #852

Open
oryan-block wants to merge 2 commits into
masterfrom
bugfix/823
Open

oryan-block wants to merge 2 commits into
masterfrom
bugfix/823

Conversation

@oryan-block

Copy link
Copy Markdown
Collaborator

Fixes #823

Checklist

  • Pull requests follows the contribution guide
  • New or modified functionality is covered by tests

Description

An enum that's only used as a directive argument fails the schema build unless you register a Java enum for it. This is the schema from the issue:

enum Purpose { SECURITY EXECUTION }

directive @link(url: String!, for: Purpose) repeatable on SCHEMA

type Query { hello: String }

SchemaClassScannerError: Enum type 'Purpose' is used in a directive, but no class could be found for that type name. Please pass a class for type 'Purpose' in the parser's dictionary.

It fails even when the directive is never applied. The common case is Fed2's @link(for: link__Purpose), where people end up writing a dummy link__Purpose enum just to get the schema to build.

SchemaClassScanner#handleDirectives scans the arguments of every directive definition and sent enums through handleDictionaryTypes, which throws when the dictionary has no class for them. SchemaParser#createEnumObject also required a Java enum for every enum. But directive argument values are never passed to resolver methods, so there's nothing for the class to be matched against.

Now the scanner handles directive enums in handleDirectiveEnum. If there's a dictionary class for the enum it's used, as before. Otherwise the enum goes into unvalidatedTypes and directiveEnumsWithoutClass. Once scanning is done, validateAndCreateResult keeps only the ones that no field, argument or input field of a type in the schema uses, and passes them to the parser as ScannedSchemaObjects#directiveOnlyEnums. That check includes fields added by extend type/extend input and types found through includeUnusedTypes. createEnumObject builds those enums with their value names as values, which is what graphql-java does for enums without a class. "Expected enum with name" and "is not a Java enum" still apply to every other enum.

The field-use check came out of review. My first version built any classless directive enum from its names, even when a Map-bound input field or the argument of a field with no resolver also used it. That meant adding an unrelated directive could turn a schema that failed to build into one that built and handed Strings to user code. Now any use outside directive definitions needs a class, the same as on master.

Left out:

  • Input objects used only by directive arguments still need a class. I kept to the enum case from the issue and away from the input object scanning in Register input field types that have no Java property #842.
  • An enum reached through a directive's input object counts as used by that input object's fields, so it still needs a class, as on master.
  • There's no dedicated error for an enum used by a directive and by a field the scanner can't find a class through. It falls through to the parser's "Expected enum with name 'Purpose' but found none!". Register input field types that have no Java property #842 gives the Map-bound input case a clearer message.
  • extend enum is still ignored, the same as for enums backed by a class.

createDirectives, determineInputType and buildAppliedDirectives aren't touched, so the overlap with #843 is a few nearby lines in SchemaParser. #842 will have small textual conflicts in the scanner's field declarations and in validateAndCreateResult. Both PRs fail on an enum used by a Map-bound input field, but #842 throws its own SchemaClassScannerError before the parser runs. Whichever lands second has to update the expected exception in scanner requires a class for enums used by directives and by input fields without a property.

There are 11 new tests in SchemaClassScannerDirectiveTest. The build tests cover the issue's schema exactly as written, applied schema and field directives using the enum's default and an explicit value (checked through getValue(), a full introspection query and SchemaPrinter), the Fed2 link-v1.0 definitions with an Import scalar, and an enum class found through an unused type with includeUnusedTypes. The guards check that an enum used by both a directive and a resolver keeps its Java constants, that a String parameter still gives "is not a Java enum", that an Object parameter or an Any? property on an unused type still gives "Expected enum with name", that a dictionary class is used for a directive-only enum, and that a Map-bound input field or an unimplemented field's argument in extend type Query still needs a class. I ran the test file against master: the 4 build tests fail with the error above, and so do the 3 guards for unused types, Map inputs and extensions, since master throws its scanner error, not the parser's. The other guards and the existing tests pass on master. clean verify passes.

Behaviour change: an enum that only directive definitions use, with no dictionary class, now builds instead of failing. Its values are the value names as Strings, so appliedDirective.getArgument("for").getValue<...>() in a SchemaDirectiveWiring returns "SECURITY", not an enum constant. Passing the enum in the dictionary (.dictionary(Purpose::class)) brings the constants back. These enums are now in the built schema and no longer show up in getUnusedDefinitions(). The scanner error "Enum type '...' is used in a directive, but no class could be found for that type name" is gone. Schemas that hit it because the enum is also used by a field without a class still fail, now with the parser's "Expected enum with name 'Purpose' but found none!". Fed2 users who load federation's definitions still have to pass its custom scalars (Import, FieldSet and so on) to the parser.

🤖 Generated with Claude Code

oryan-block and others added 2 commits October 12, 2026 15:27
An enum used only as a directive argument, such as Federation 2's
@link(for: Purpose), failed the schema build with "Enum type 'Purpose'
is used in a directive, but no class could be found" unless a Java enum
was passed in the dictionary. That happened even when the directive was
never applied, since the scanner checks the arguments of every directive
definition, and createEnumObject could only build an enum from a class.

Directive argument values are never passed to resolver methods, so such
an enum doesn't need a class. When the scanner finds no class for it,
the enum is now built with its value names as values, which is what
graphql-java does without an EnumValuesProvider. Applied directives and
SchemaDirectiveWiring implementations then get "SECURITY" from
getValue() rather than an enum constant. Passing the enum class in the
dictionary still gives enum constants.

The decision is made once scanning is done, so a class found later, for
example through an unused type with includeUnusedTypes, is still used.
Enums that fields or arguments use keep their class as before, and an
enum matched to String, Object or a Map still fails in the parser.

Federation's bundled definitions still need their custom scalars, such
as Import and FieldSet, passed to the parser.

One case does change for resolvers. An enum that a directive uses and
that is also used by an input object field without a Java property,
e.g. an input bound to a Map, now builds with the value names, so the
Map gets "SECURITY". Without the directive that schema still fails.
A test pins this.

Fixes #823

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous commit built an enum from its value names whenever a directive used it and the scanner found no class for it. That also covered enums whose values do reach user code without going through a resolver parameter the scanner can match: input fields with no Java property, such as an input bound to a Map, and arguments of unimplemented fields served by a missing resolver data fetcher. Those schemas fail without the directive, but declaring an unrelated directive made them build, and the Map or the data fetcher got "SECURITY" instead of an enum constant.

Now an enum is only built from its value names if no field, argument or input field of a type in the schema uses it, including fields added by extensions and types found later through includeUnusedTypes. Otherwise it needs a class as before, and the parser fails with "Expected enum with name 'Purpose' but found none!". This replaces the check against the scanner's dictionary, since any enum the scanner matched to a class was matched through such a field.

The test that pinned the Map-bound input case now expects the error, and new tests cover an unimplemented field's argument added by an extension and an unused type's Object property.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enums used only as directive arguments require a Java class

1 participant