Skip to content

Commit 0ae99e1

Browse files
oryan-blockclaude
andcommitted
Merge branch 'master' into bugfix/479
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 parents 15c2972 + 75842ac commit 0ae99e1

27 files changed

Lines changed: 478 additions & 75 deletions

‎.github/workflows/pull-request.yml‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,23 @@ jobs:
2424
restore-keys: ${{ runner.os }}-m2
2525
- name: Build with Maven
2626
run: mvn --batch-mode --update-snapshots verify
27+
28+
# The same jar has to work with the oldest supported graphql-java, so build and test against it too
29+
graphql-java-25:
30+
runs-on: ubuntu-latest
31+
steps:
32+
- name: Checkout
33+
uses: actions/checkout@v7
34+
- name: Setup java
35+
uses: actions/setup-java@v6
36+
with:
37+
java-version: '11'
38+
distribution: 'temurin'
39+
- name: Cache Maven packages
40+
uses: actions/cache@v6
41+
with:
42+
path: ~/.m2
43+
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
44+
restore-keys: ${{ runner.os }}-m2
45+
- name: Build with Maven
46+
run: mvn --batch-mode --update-snapshots verify -Dgraphql-java.version=25.1

‎pom.xml‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,11 @@
262262
<artifactId>kotlin-maven-plugin</artifactId>
263263
<groupId>org.jetbrains.kotlin</groupId>
264264
<version>${kotlin.version}</version>
265+
<configuration>
266+
<args>
267+
<arg>-Werror</arg>
268+
</args>
269+
</configuration>
265270
<executions>
266271
<execution>
267272
<id>compile</id>
@@ -286,8 +291,8 @@
286291
<artifactId>maven-compiler-plugin</artifactId>
287292
<version>3.16.0</version>
288293
<configuration>
289-
<source>11</source>
290-
<target>11</target>
294+
<release>11</release>
295+
<failOnWarning>true</failOnWarning>
291296
</configuration>
292297
</plugin>
293298

‎renovate.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@
1010
"org.jetbrains.kotlin"
1111
]
1212
},
13+
{
14+
"description": "Stay on Kotlin 2.3.10, no more upgrades until detekt figures itself out",
15+
"matchPackagePrefixes": [
16+
"org.jetbrains.kotlin:"
17+
],
18+
"allowedVersions": "<=2.3.10"
19+
},
1320
{
1421
"description": "Align antlr version with graphql-java",
1522
"matchPackagePrefixes": [

‎src/main/kotlin/graphql/kickstart/tools/ScannedSchemaObjects.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package graphql.kickstart.tools
33
import graphql.kickstart.tools.resolver.FieldResolver
44
import graphql.kickstart.tools.util.BiMap
55
import graphql.kickstart.tools.util.JavaType
6+
import graphql.language.EnumTypeDefinition
67
import graphql.language.FieldDefinition
78
import graphql.language.ObjectTypeDefinition
89
import graphql.language.SDLNamedDefinition
@@ -18,7 +19,8 @@ internal data class ScannedSchemaObjects(
1819
val customScalars: CustomScalarMap,
1920
val rootInfo: RootTypeInfo,
2021
val fieldResolversByType: Map<ObjectTypeDefinition, MutableMap<FieldDefinition, FieldResolver>>,
21-
val unusedDefinitions: Set<TypeDefinition<*>>
22+
val unusedDefinitions: Set<TypeDefinition<*>>,
23+
val directiveOnlyEnums: Set<EnumTypeDefinition>
2224
)
2325

2426
internal typealias TypeClassDictionary = BiMap<TypeDefinition<*>, JavaType>

‎src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt‎

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -174,15 +174,51 @@ internal class SchemaClassScanner(
174174
?: error("No ${TypeDefinition::class.java.simpleName} for type name $inputTypeName")
175175
when (typeDefinition) {
176176
is ScalarTypeDefinition -> handleFoundScalarType(typeDefinition)
177-
is EnumTypeDefinition -> handleDictionaryTypes(listOf(typeDefinition)) {
178-
"Enum type '${it.name}' is used in a directive, but no class could be found for that type name. Please pass a class for type '${it.name}' in the parser's dictionary."
179-
}
177+
is EnumTypeDefinition -> handleDirectiveEnum(typeDefinition)
180178
is InputObjectTypeDefinition -> handleDictionaryTypes(listOf(typeDefinition)) {
181179
"Input object type '${it.name}' is used in a directive, but no class could be found for that type name. Please pass a class for type '${it.name}' in the parser's dictionary."
182180
}
183181
}
184182
}
185183

184+
/**
185+
* Directive argument values never reach resolver methods, so an enum used in a directive doesn't need a class. Without one
186+
* it goes into [unvalidatedTypes] and, unless a field uses it too, is built with its value names as values, see
187+
* [validateAndCreateResult].
188+
*/
189+
private fun handleDirectiveEnum(type: EnumTypeDefinition) {
190+
if (dictionary.containsKey(type)) {
191+
return
192+
}
193+
194+
val initialEntry = initialDictionary[type.name]
195+
if (initialEntry != null) {
196+
handleFoundType(type, initialEntry.get(), DictionaryReference())
197+
} else {
198+
unvalidatedTypes.add(type)
199+
}
200+
}
201+
202+
/**
203+
* The names of the types used by the fields, arguments and input fields of [definitions], including fields added by extensions.
204+
*/
205+
private fun typeNamesUsedByFields(definitions: Set<TypeDefinition<*>>): Set<String> {
206+
val names = definitions.map { it.name }.toSet()
207+
return (definitions + extensionDefinitions + inputExtensionDefinitions)
208+
.filter { it.name in names }
209+
.flatMap { definition ->
210+
when (definition) {
211+
is ImplementingTypeDefinition<*> -> definition.fieldDefinitions.flatMap { field ->
212+
field.inputValueDefinitions.map { it.type } + field.type
213+
}
214+
is InputObjectTypeDefinition -> definition.inputValueDefinitions.map { it.type }
215+
else -> emptyList()
216+
}
217+
}
218+
.mapNotNull { (it.unwrap() as TypeName).name }
219+
.toSet()
220+
}
221+
186222
private fun validateAndCreateResult(rootTypeHolder: RootTypesHolder): ScannedSchemaObjects {
187223
enumsWithoutClass
188224
.filter { dictionary[it.key]?.javaType == null }
@@ -206,8 +242,8 @@ internal class SchemaClassScanner(
206242
dictionary
207243
.filter {
208244
it.value.javaType != null
209-
&& it.value.typeClass() != java.lang.Object::class.java
210-
&& !java.util.Map::class.java.isAssignableFrom(it.value.typeClass())
245+
&& it.value.typeClass() != Any::class.java
246+
&& !Map::class.java.isAssignableFrom(it.value.typeClass())
211247
&& it.key !is InputObjectTypeDefinition
212248
&& it.key !is UnionTypeDefinition
213249
}.mapValuesTo(it) { it.value.javaType }
@@ -254,7 +290,13 @@ internal class SchemaClassScanner(
254290

255291
val definitions = observedDefinitions + extensionDefinitions + inputExtensionDefinitions + directiveDefinitions
256292

257-
return ScannedSchemaObjects(dictionary, definitions, scalars, rootInfo, fieldResolversByType.toMap(), unusedDefinitions)
293+
// The enums in unvalidatedTypes come from directives without a class, so they can only be built when no field uses them.
294+
// A field hands the enum's values to user code even when the scanner found no class through it, e.g. an input field bound
295+
// to a Map or an unimplemented field's argument.
296+
val typeNamesUsedByFields = typeNamesUsedByFields(observedDefinitions)
297+
val directiveOnlyEnums = unvalidatedTypes.filterIsInstance<EnumTypeDefinition>().filter { it.name !in typeNamesUsedByFields }.toSet()
298+
299+
return ScannedSchemaObjects(dictionary, definitions, scalars, rootInfo, fieldResolversByType.toMap(), unusedDefinitions, directiveOnlyEnums)
258300
}
259301

260302
private fun validateRootResolversWereUsed(rootType: RootType?, fieldResolvers: List<FieldResolver>) {
@@ -321,7 +363,7 @@ internal class SchemaClassScanner(
321363
* Scan a new object for types that haven't been mapped yet.
322364
*/
323365
private fun scanQueueItemForPotentialMatches(item: QueueItem) {
324-
val resolverInfo = if (item.clazz == Object::class.java) {
366+
val resolverInfo = if (item.clazz == Any::class.java) {
325367
getResolverInfoFromTypeDictionary(item.type.name)
326368
?: throw SchemaClassScannerError("The GraphQL schema type '${item.type.name}' maps to a field of type java.lang.Object however there is no matching entry for this type in the type dictionary. You may need to add this type to the dictionary before building the schema.")
327369
} else {
@@ -357,7 +399,7 @@ internal class SchemaClassScanner(
357399
}
358400

359401
private fun isResolverForSupertype(resolverInfo: NormalResolverInfo, dataClass: Class<*>) =
360-
resolverInfo.dataClassType != Object::class.java && resolverInfo.dataClassType.isAssignableFrom(dataClass)
402+
resolverInfo.dataClassType != Any::class.java && resolverInfo.dataClassType.isAssignableFrom(dataClass)
361403

362404
private fun scanResolverInfoForPotentialMatches(type: ObjectTypeDefinition, resolverInfo: ResolverInfo) {
363405
type.getExtendedFieldDefinitions(extensionDefinitions).forEach { field ->
@@ -394,7 +436,7 @@ internal class SchemaClassScanner(
394436
if (options.preferGraphQLResolver && realEntry.hasResolverRef()) {
395437
log.warn("The real entry ${realEntry.joinReferences()} is a GraphQLResolver so ignoring this one ${javaType.unwrap()} $reference")
396438
} else {
397-
if (java.util.Map::class.java.isAssignableFrom(javaType.unwrap())) {
439+
if (Map::class.java.isAssignableFrom(javaType.unwrap())) {
398440
throw SchemaClassScannerError("Two different property map classes used for type ${type.name}:\n${realEntry.joinReferences()}\n\n- ${javaType}:\n| ${reference.getDescription()}")
399441
}
400442
throw SchemaClassScannerError("Two different classes used for type ${type.name}:\n${realEntry.joinReferences()}\n\n- ${javaType.typeName}:\n| ${reference.getDescription()}")

‎src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import graphql.language.*
1212
import graphql.schema.*
1313
import graphql.schema.idl.RuntimeWiring
1414
import graphql.schema.idl.ScalarInfo
15-
import graphql.schema.visibility.NoIntrospectionGraphqlFieldVisibility
15+
import graphql.schema.visibility.BlockedFields
1616
import org.slf4j.LoggerFactory
1717
import kotlin.reflect.KClass
1818

@@ -39,6 +39,7 @@ class SchemaParser internal constructor(
3939
private val rootInfo = scanResult.rootInfo
4040
private val fieldResolversByType = scanResult.fieldResolversByType
4141
private val unusedDefinitions = scanResult.unusedDefinitions
42+
private val directiveOnlyEnums = scanResult.directiveOnlyEnums
4243

4344
private val extensionDefinitions = definitions.filterIsInstance<ObjectTypeExtensionDefinition>()
4445
private val inputExtensionDefinitions = definitions.filterIsInstance<InputObjectTypeExtensionDefinition>()
@@ -68,7 +69,10 @@ class SchemaParser internal constructor(
6869
*/
6970
fun parseSchemaObjects(): SchemaObjects {
7071
if (!options.introspectionEnabled) {
71-
codeRegistryBuilder.fieldVisibility(NoIntrospectionGraphqlFieldVisibility.NO_INTROSPECTION_FIELD_VISIBILITY)
72+
// What the deprecated NoIntrospectionGraphqlFieldVisibility does. Its suggested replacement,
73+
// Introspection.enabledJvmWide, would also turn introspection off for every other schema in the JVM.
74+
// TODO deprecate introspectionEnabled and remove this in 15.0.0 (#858)
75+
codeRegistryBuilder.fieldVisibility(BlockedFields.newBlock().addPattern("__.*").build())
7276
}
7377
// this overrides the above introspection enabled setting obviously... todo: add documentation
7478
options.fieldVisibility?.let { codeRegistryBuilder.fieldVisibility(it) }
@@ -195,9 +199,10 @@ class SchemaParser internal constructor(
195199

196200
private fun createEnumObject(definition: EnumTypeDefinition): GraphQLEnumType {
197201
val name = definition.name
198-
val type = dictionary[definition]
199-
?: throw SchemaError("Expected enum with name '$name' but found none!")
200-
if (!type.unwrap().isEnum) throw SchemaError("Type '$name' is declared as an enum in the GraphQL schema but is not a Java enum!")
202+
// enums only used by directives can do without a class, see SchemaClassScanner.handleDirectiveEnum
203+
val enumClass = dictionary[definition]?.unwrap()
204+
if (enumClass == null && definition !in directiveOnlyEnums) throw SchemaError("Expected enum with name '$name' but found none!")
205+
if (enumClass != null && !enumClass.isEnum) throw SchemaError("Type '$name' is declared as an enum in the GraphQL schema but is not a Java enum!")
201206

202207
val builder = GraphQLEnumType.newEnum()
203208
.name(name)
@@ -208,8 +213,12 @@ class SchemaParser internal constructor(
208213
.apply {
209214
definition.enumValueDefinitions.forEach { valueDefinition ->
210215
val enumName = valueDefinition.name
211-
val enumValue = type.unwrap().enumConstants.find { (it as Enum<*>).name == enumName }
212-
?: throw SchemaError("Expected value for name '$enumName' in enum '${type.unwrap().simpleName}' but found none!")
216+
val enumValue = if (enumClass == null) {
217+
enumName
218+
} else {
219+
enumClass.enumConstants.find { (it as Enum<*>).name == enumName }
220+
?: throw SchemaError("Expected value for name '$enumName' in enum '${enumClass.simpleName}' but found none!")
221+
}
213222

214223
value(
215224
GraphQLEnumValueDefinition.newEnumValueDefinition()
@@ -427,6 +436,7 @@ class SchemaParser internal constructor(
427436
}
428437

429438
// TODO remove this once directives are fully replaced with applied directives
439+
@Suppress("DEPRECATION") // legacy directive argument values
430440
private fun buildDirectives(
431441
directives: List<Directive>,
432442
directiveLocation: Introspection.DirectiveLocation
@@ -528,7 +538,7 @@ class SchemaParser internal constructor(
528538
is NonNullType -> GraphQLNonNull(determineType(expectedType, typeDefinition.type, allowedTypeReferences, inputObjects))
529539
is InputObjectTypeDefinition -> {
530540
log.info("Create input object")
531-
createInputObject(typeDefinition, inputObjects, referencingInputObjects as MutableSet<String>)
541+
createInputObject(typeDefinition, inputObjects, referencingInputObjects)
532542
}
533543
is TypeName -> {
534544
val scalarType = customScalars[typeDefinition.name]

‎src/main/kotlin/graphql/kickstart/tools/SchemaParserBuilder.kt‎

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package graphql.kickstart.tools
22

33
import graphql.language.Definition
44
import graphql.language.Document
5+
import graphql.language.SourceLocation
6+
import graphql.parser.InvalidSyntaxException
57
import graphql.parser.MultiSourceReader
68
import graphql.parser.Parser
79
import graphql.parser.ParserEnvironment
@@ -11,6 +13,7 @@ import graphql.schema.idl.RuntimeWiring
1113
import graphql.schema.idl.SchemaDirectiveWiring
1214
import org.antlr.v4.runtime.RecognitionException
1315
import org.antlr.v4.runtime.misc.ParseCancellationException
16+
import java.text.NumberFormat
1417
import kotlin.Int.Companion.MAX_VALUE
1518
import kotlin.reflect.KClass
1619

@@ -20,7 +23,7 @@ import kotlin.reflect.KClass
2023
class SchemaParserBuilder {
2124

2225
private val dictionary = SchemaParserDictionary()
23-
private val schemaString = StringBuilder()
26+
private val schemaStrings = mutableListOf<Source>()
2427
private val files = mutableListOf<String>()
2528
private val resolvers = mutableListOf<GraphQLResolver<*>>()
2629
private val scalars = mutableListOf<GraphQLScalarType>()
@@ -49,10 +52,14 @@ class SchemaParserBuilder {
4952
* Add a GraphQL schema string directly.
5053
*/
5154
fun schemaString(string: String) = this.apply {
52-
if (schemaString.isNotEmpty()) {
53-
schemaString.append("\n")
54-
}
55-
schemaString.append(string)
55+
schemaStrings.add(Source(string, null))
56+
}
57+
58+
/**
59+
* Add a GraphQL schema string directly, naming its source in parse errors and source locations.
60+
*/
61+
fun schemaString(string: String, sourceName: String) = this.apply {
62+
schemaStrings.add(Source(string, sourceName))
5663
}
5764

5865
/**
@@ -172,10 +179,10 @@ class SchemaParserBuilder {
172179

173180
private fun parseDocuments(): List<Document> {
174181
try {
175-
val documents = files.map { parseDocument(readFile(it), it) }.toMutableList()
182+
val documents = files.map { parseDocument(listOf(Source(readFile(it), it))) }.toMutableList()
176183

177-
if (schemaString.isNotBlank()) {
178-
documents.add(parseDocument(schemaString.toString()))
184+
if (schemaStrings.any { it.text.isNotBlank() }) {
185+
documents.add(parseDocument(schemaStrings))
179186
}
180187

181188
return documents
@@ -189,16 +196,37 @@ class SchemaParserBuilder {
189196
}
190197
}
191198

192-
private fun parseDocument(input: String, sourceName: String? = null): Document {
193-
val sourceReader = MultiSourceReader
194-
.newMultiSourceReader()
195-
.string(input, sourceName)
199+
private fun parseDocument(sources: List<Source>): Document {
200+
// graphql-java's MultiSourceReader numbers the last line of the last source from the start of the first one.
201+
// Ending every source with a line break leaves only the end of input on that line, see syntaxErrorInSource.
202+
val separated = if (sources.size > 1) sources.map { it.endingWithLineBreak() } else sources
203+
val sourceReader = MultiSourceReader.newMultiSourceReader()
204+
.apply { separated.forEach { string(it.text, it.name) } }
196205
.trackData(true).build()
197206
val environment = ParserEnvironment
198207
.newParserEnvironment()
199208
.document(sourceReader)
200209
.parserOptions(parserOptions).build()
201-
return parser.parseDocument(environment)
210+
try {
211+
return parser.parseDocument(environment)
212+
} catch (e: InvalidSyntaxException) {
213+
throw syntaxErrorInSource(e, separated.last())
214+
}
215+
}
216+
217+
/**
218+
* Makes the line of an unexpected end of input relative to the last source and adds the source name to the message.
219+
*/
220+
private fun syntaxErrorInSource(e: InvalidSyntaxException, lastSource: Source): InvalidSyntaxException {
221+
val location = e.location ?: return e
222+
val line = if (e.offendingToken == "<EOF>") lastSource.text.lines().size else location.line
223+
if (line == location.line && location.sourceName == null) return e
224+
225+
// graphql-java formats the line for the default locale, e.g. "1,103"
226+
val number = NumberFormat.getInstance()
227+
val message = e.message.orEmpty().replaceFirst(" ${number.format(location.line)} ", " ${number.format(line)} ")
228+
val inSource = location.sourceName?.let { " in $it" }.orEmpty()
229+
return SchemaSyntaxException(message + inSource, SourceLocation(line, location.column, location.sourceName), e)
202230
}
203231

204232
private fun readFile(filename: String) =
@@ -209,6 +237,10 @@ class SchemaParserBuilder {
209237
* Build the parser with the supplied schema and dictionary.
210238
*/
211239
fun build() = SchemaParser(scan(), options, runtimeWiringBuilder.build())
240+
241+
private data class Source(val text: String, val name: String?) {
242+
fun endingWithLineBreak() = if (text.endsWith("\n")) this else copy(text = "$text\n")
243+
}
212244
}
213245

214246
class InvalidSchemaError(
@@ -218,3 +250,6 @@ class InvalidSchemaError(
218250
override val message: String
219251
get() = "Invalid schema provided (${recognitionException.javaClass.name}) at: ${recognitionException.offendingToken}"
220252
}
253+
254+
internal class SchemaSyntaxException(message: String, location: SourceLocation, e: InvalidSyntaxException) :
255+
InvalidSyntaxException(message, location, e.offendingToken, e.sourcePreview, e)

0 commit comments

Comments
 (0)