-
Notifications
You must be signed in to change notification settings - Fork 661
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Scalar mapping and adapter configuration improvements #3779
Conversation
9796670
to
ce8bed2
Compare
sealed interface AdapterInitializer | ||
|
||
// The adapter will be instantiated in the generated code | ||
class SingletonAdapterInitializer(val qualifiedName: String): AdapterInitializer | ||
|
||
// The adapter will be used as-is (it's an object or a public val) | ||
class NoArgConstructorAdapterInitializer(val qualifiedName: String): AdapterInitializer | ||
|
||
// The adapter will be looked up in the `customScalarAdapters` parameter (same as current behavior) | ||
object RuntimeAdapterInitializer: AdapterInitializer |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know I'm the one who suggested this initially but I think we should avoid Kotlin APIs (like sealed classes) in the Gradle plugin API.
Mainly for Groovy users + because we use R8 to relocate all of the Kotlin-stdlib, this feels dangerous. I'd suggest something like this instead:
class Service {
fun mapScalar(graphQLName: String, kotlinName: String)
fun mapScalarUsingKotlinSingleton(graphQLName: String, kotlinName: String, singletonName: String)
fun mapScalarUsingKotlinConstructor(graphQLName: String, kotlinName: String, constructorName: String)
fun mapScalarToKotlinLong(graphQLName: String)
fun mapScalarToKotlinInt(graphQLName: String)
fun mapScalarToKotlinString(graphQLName: String)
fun mapScalarToKotlinBoolean(graphQLName: String)
fun mapScalarToKotlinMap(graphQLName: String)
fun mapScalarToJavaLong(graphQLName: String)
fun mapScalarToJavaInteger(graphQLName: String)
fun mapScalarToJavaString(graphQLName: String)
fun mapScalarToJavaBoolean(graphQLName: String)
fun mapScalarToJavaMap(graphQLName: String)
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed!
Plus I actually find the syntax easier to use 👍
Shouldn't mapScalarUsingKotlinSingleton
/ mapScalarUsingKotlinConstructor
be mapScalarUsingSingleton
/ mapScalarUsingConstructor
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't mapScalarUsingKotlinSingleton / mapScalarUsingKotlinConstructor be mapScalarUsingSingleton / mapScalarUsingConstructor?
I was wondering the same. Singleton
is a Kotlin concept so it will not work with Java but maybe we can throw a nice error if that happens?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was assuming this would work also in Java with static fields:
public class MyAdapters {
public static final Adapter<MyDate> DATE_ADAPTER = new Adapter<MyDate>() {...}
}
mapScalarUsingSingleton("Date", "com.example.MyDate", "com.example.MyAdapters.DATE_ADAPTER")
But now I'm realizing maybe the name "singleton" is not the most accurate?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we can "just" ask for a verbatim code block?
apollo {
mapScalarUsingAdapter("Date", "com.example.MyDate", "com.example.MyAdapters.DATE_ADAPTER")
mapScalarUsingAdapter("Date", "com.example.MyDate", "com.example.MyAdapterSingleton")
mapScalarUsingAdapter("Date", "com.example.MyDate", "com.example.MyAdapter()")
}
And use a literal (%L
) codeblock in KotlinPoet/JavaPoet. This will not add the nice imports but maybe that's ok?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I agree, the distinction is not that useful. The documentation can explain how it works, and the fact that an instance or an instantiation expression can both be used.
In that case, maybe we can keep the name mapScalar
?
/**
* Map a GraphQL scalar type to the Java/Kotlin type and adapter configured at runtime.
*
* For example:
* `mapScalar("Date", "com.example.Date")`
*/
fun mapScalar(graphQLName: String, targetName: String)
/**
* Map a GraphQL scalar type to the Java/Kotlin type and provided adapter expression.
*
* For example:
* `mapScalar("Date", "com.example.Date", "com.example.DateAdapter")` (an instance property or object)
* `mapScalar("Date", "com.example.Date", "com.example.DateAdapter()")` (instantiate the class on the fly)
*/
fun mapScalar(graphQLName: String, targetName: String, adatpterExpression: String)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mapScalarToKotlinMap
/ mapScalarToJavaMap
should be mapScalarToKotlinAny
/ mapScalarToJavaObject
or am I missing something?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, indeed. Until we have #3243, we can't use Map or other parametrized classes.
I've pushed the Kotlin implementation for review Remaining:
|
@@ -170,6 +170,11 @@ object ApolloCompiler { | |||
options.operationOutputFile.writeText(operationOutput.toJson(" ")) | |||
} | |||
|
|||
// Merge scalar mappings from upstream modules and this module's options | |||
val allScalarMapping = options.incomingCompilerMetadata.fold(options.scalarMapping.toMutableMap()) { acc, it -> | |||
acc.apply { putAll(it.scalarMapping) } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This feels dangerous. What if there's a conflicting mapping in 2 modules? Should we mandate that all scalar mappings are defined in the schema module?
I can't think of any use case where different modules could use different mappings but if something comes up we can always relax the restriction.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought it was already the case actually. The multi module doc says "The schema module and only the schema module must define customScalarsMapping".
Should I ensure there are no duplicates and throw if there are any? Or should we not merge them at all (not sure how that would work)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Better throw fast if we see something like this.
* Controls how scalar adapters are used in the generated code. | ||
*/ | ||
@JsonClass(generateAdapter = true, generator = "sealed:type") | ||
sealed interface AdapterInitializer : Serializable |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why does it need to be Serializable
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Got it. This is need by Gradle to snapshot the input properties.
I always feel weird implementing Serializable
since it's already serialized to Json. Also using Kotlin features like sealed interfaces and data classes in Gradle APIs feels dangerous too (think Groovy callers).
I would certainly have used 2 maps:
scalarsMapping: Map<String, String>
scalarAdapters: Map<String, String>
But I can't find a strong reason for the change so your choice!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes sorry I should have commented :). I agree, I'm not very fond of making them as Serializable
either. Using String
feels safer, I'll make the change.
@@ -132,8 +133,8 @@ internal class IrBuilder( | |||
visitedTypes.add(name) | |||
val typeDefinition = schema.typeDefinition(name) | |||
if (typeDefinition.isBuiltIn()) { | |||
// We don't generate builtin types | |||
continue | |||
// We don't generate builtin types, unless they are explicitly mapped |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// We don't generate builtin types, unless they are explicitly mapped | |
// We don't generate builtin types, unless they are explicitly mapped | |
// Because users need String.type (or others) to register their adapters |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thinking a bit more about this. I think it makes sense to keep the builtin types in apollo-api
always and not generate them. This means for builtin types that require a runtime adapter, users need to do something like:
apolloClientBuilder.addScalarAdapter(CompiledIDType, MyIDAdapter)
// instead of
// apolloClientBuilder.addScalarAdapter(ID.type, MyIDAdapter)
But I think this is such an advanced case that it's ok? It keeps the logic more symmetric. Builting types are in apollo-api
, others get generated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 I'll have a look!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In retrospect, CompiledIDType
should certainly have been BuiltinIDType
but that'll be for v4 😅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm after more digging, the CustomScalarAdapters
methods take instances of CustomScalarType
s, whereas the compiled types are ScalarType
- so apolloClientBuilder.addScalarAdapter(ID.type, MyIDAdapter)
for instance doesn't work.
We could make CustomScalarType
be a subclass of ScalarType
, and change CustomScalarAdapters
methods to take ScalarType
instead. But that looks like a breaking change 😅
Or we can add ScalarType
based methods (responseAdapterFor
, add
, addCustomScalarAdapter
) in addition to the existing CustomScalarType
based ones.
WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gosh this is hard. I'm not too much a fan of duplicating everything either. I think your initial approach of generating a CustomScalarType
for builtin types if they have a mapping was best then. CustomScalarType
will not be a super accurate name and the whole thing feels a bit asymmetrical but I don't see a better way.
Or should we always generate all builtin types? And mark CompiledStringType
, etc.. as deprecated? I was initially not too fond of this because it puts String
, Int
, etc.. as top level classes but maybe since they're using a specific package name, that'll be ok...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or should we always generate all builtin types?
I'll five it a go so we can see what it looks like :)
apollo-gradle-plugin/src/main/kotlin/com/apollographql/apollo3/gradle/api/Service.kt
Outdated
Show resolved
Hide resolved
service.customTypeMapping | ||
) | ||
.getOrNull()?.asScalarInfoMapping() ?: emptyMap() | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
throw if both customScalarsMapping
and scalarMapping
are set?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point!
type is IrModelType -> resolveAndAssert(ResolverKeyKind.Model, type.path) | ||
type is IrNamedType -> resolveAndAssert(ResolverKeyKind.SchemaType, type.name) | ||
else -> error("$type is not a schema type") | ||
}.copy(nullable = true) | ||
} | ||
|
||
private fun resolveIrScalarType(type: IrScalarType): ClassName { | ||
// Try mapping first, then built-ins, then fallback to Any | ||
return resolve(ResolverKeyKind.CustomScalarTarget, type.name) ?: when (type.name) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rename CustomScalarTarget
to ScalarTarget
?
"ID" -> CodeBlock.of("%M", KotlinSymbols.StringAdapter) | ||
"String" -> CodeBlock.of("%M", KotlinSymbols.StringAdapter) | ||
"Int" -> CodeBlock.of("%M", KotlinSymbols.IntAdapter) | ||
"Float" -> CodeBlock.of("%M", KotlinSymbols.DoubleAdapter) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd love this to be moved up a few layers so that the resolver can apply the same logic everywhere. Not really sure how to do this yet but I might investigate this in a follow up PR unless you have ideas how to do this already.
…anted adapter in the generated code directly
Co-authored-by: Martin Bonnin <[email protected]>
…alarAdapterInitializer and resolveCompiledType
…tances on the fly
…ti module scenarios
…ding) / not that useful
…Also don't expose it on the Service.
… code (#3793) * Mark compiled types as deprecated and no longer use them in generated code * Always generate types for built-in scalars * Fix MetadataTest * Prefix builtin scalar names with 'GraphQL' to avoid a clash in multiplatform * Schema object: revert using "__Schema" name by default and add a `generatedSchemaName` option. Revert to using compiled types for non scalar built-in types (__Schema, __Field, etc.).
…3795) * Apply same changes to Java codegen * Update Upload's kdoc about how to use it in the plugin conf * Java: mark compiled types as deprecated and no longer use them in generated code * Rebase on feature branch and apply last changes on KotlinResolver to JavaResolver
* rename custom-scalar test to scalar-adapters * fix KDoc for registerOperations * add a section in CONTRIBUTING.md about naming Gradle APIs * Kdoc tweaks * add a test to make sure we don't allow mapScalar + customScalarMapping at the same time * remove ScalarTarget as a resolver entry and instead use scalarMapping * fix Gradle tests
7094dbc
to
4c82ca9
Compare
Related to #3748.
For now here is a design document with possible changes.