Skip to content
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

Add support for @oneOf inputs #1846

Open
wants to merge 25 commits into
base: series/2.x
Choose a base branch
from
Open

Add support for @oneOf inputs #1846

wants to merge 25 commits into from

Conversation

kyri-petrou
Copy link
Collaborator

@kyri-petrou kyri-petrou commented Aug 21, 2023

VERY early draft for adding support for the @oneOf inputs, just to discuss whether we're on the right track. See current version of the RFC here

Some of the assumptions I made:

  1. We probably want to require an annotation to mark an input sealed trait as a oneOf. I think that makes sense since the spec on @oneOf is very strict, so it's very unlikely that these sealed traits will be used for any return types
  2. Since in Scala 3 we control derivation, I enforced the checks that the sealed trait is a valid @oneOf via macros. I think it's nicer UX if we give compile-time errors instead of runtime ones for invalid types. However, in Scala 2 we don't control the derivation so we have to enforce these checks during runtime. I understand if we want to unify the validation, so I'm happy to remove the macros
  3. I couldn't find anything in the spec (might have missed it though) but I assumed we don't want to mark inputs as @oneOf if they have only 1 field (kind of defeats the purpose?)

TODO:

  • Add validations for @oneOf inputs in Validator (both against the Schema & user inputs)
  • Add tests where @oneOf inputs are a mix of input objects and Scalars
  • Add documentation
  • Discuss on what we should do with the client

Comment on lines 67 to 70
builders.view
.map(_.build(input))
.find(_.isRight)
.getOrElse(Left(ExecutionError(s"Invalid oneOf input $fields for trait ${ctx.typeName.short}}")))
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open to suggestions on how we can make this more efficient. I can't figure out a way to extract the field argument here unfortunately :(

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't there a possibility that some types can successfully be parsed as another type of the oneof?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We get the subclass name though right? I think we could recapitalize the type name Dog => dog as the default strategy, and allow people to override that explicitly with an annotation

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't there a possibility that some types can successfully be parsed as another type of the oneof?

@ghostdogpr No because we ensure that the argument names of the leaf cases are unique

I think we could recapitalize the type name Dog => dog as the default strategy, and allow people to override that explicitly with an annotation

@paulpdaniels I think this could work, but just to be on the same page. Let's say we want to implement this:

input BarInput {
  a: String!
  b: Int!
}

input FooInput @oneOf {
  stringArg: String
  barArg: BarInput
}

Currently, we can do it as:

case class Bar(a: String, b: Int)

sealed trait Foo
object Foo:
  case class ArgA(stringArg: String) extends Foo
  case class ArgB(barArg: Bar) extends Foo

With your recommendation @paulpdaniels, we need to do the following:

sealed trait Foo
object Foo:
  @GQLValueType
  @GQLOneOfFieldName("stringArg")  // Otherwise it'll be named `argA`
  case class ArgA(stringArg: String) extends Foo
  
  @GQLOneOfFieldName("barArg")
  case class Bar(a: String, b: Int) extends Foo

  // Alternatively:
  @GQLInputName("BarInput")
  case class BarArg(a: String, b: Int) extends Foo

If this is what you meant, I like it. It reuses the existing annotations, and prevents having to create an extra case class as in the case of case class ArgB(barArg: Bar) extends Foo in my first example

EDIT: At first I used the GQLName annotation, but on second thought, we might want to introduce another annotation as I think if we use @GQLName to rename the field it'll cause confusion since we normally use it to rename the type. Open to suggestion for a better annotation name :)

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GQLNameis already used for renaming fields though

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Annoyingly, this would be the easiest way to define a schema / arg builder for a @oneOf input, but then it's really annoying to extract the argument that was passed:

@GQLOneOfInput
case class Foo(
  stringArg: Option[String],
  barArg: Option[Bar]
)

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't we use __Type#origin to find during validation that 2 fields come from the same object and fail in that case?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately no. For Scalars, we don't set a value for the origin field. I actually tried setting it manually only for oneOf input arguments and it didn't work either, it caused issues with the validateClashingTypes validation.

The only way I could figure out how to propagate the type info was to add a field on __InputValue and exclude that field from any schema generation: https://github.com/ghostdogpr/caliban/pull/1846/files#diff-7fd5a1e4c9e85d511a507e4a9d0ddb63b131a5be6decb8feb3f95d0c00e5f31cR16

It's a bit of a hack unfortunately, but it works. Other than binary compatibility (which we're breaking with this PR anyways), do we see any other issues with doing this?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems okay to me, I don't mind that hack so much if things stay simple on the user side.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also rewrote the ArgBuilders for OneOf inputs to use fallback orElse. Under the hood it's still the same as iterating through them I suppose but at least this feels more Caliban-y 😅

# Conflicts:
#	core/src/test/scala/caliban/RenderingSpec.scala
Copy link
Owner

@ghostdogpr ghostdogpr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks very nice!

Comment on lines 67 to 70
builders.view
.map(_.build(input))
.find(_.isRight)
.getOrElse(Left(ExecutionError(s"Invalid oneOf input $fields for trait ${ctx.typeName.short}}")))
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't there a possibility that some types can successfully be parsed as another type of the oneof?

core/src/main/scala-3/caliban/schema/macros/Macros.scala Outdated Show resolved Hide resolved
# Conflicts:
#	core/src/main/scala-2/caliban/schema/ArgBuilderDerivation.scala
#	core/src/main/scala-3/caliban/schema/ArgBuilderDerivation.scala
#	core/src/main/scala/caliban/rendering/DocumentRenderer.scala
#	core/src/main/scala/caliban/validation/Validator.scala
#	core/src/test/scala/caliban/RenderingSpec.scala
@kyri-petrou kyri-petrou marked this pull request as ready for review September 21, 2023 01:29
# Conflicts:
#	build.sbt
#	core/src/main/scala-2/caliban/schema/SchemaDerivation.scala
#	core/src/main/scala-3/caliban/schema/SchemaDerivation.scala
#	core/src/main/scala-3/caliban/schema/macros/Macros.scala
#	core/src/main/scala/caliban/introspection/Introspector.scala
#	core/src/main/scala/caliban/introspection/adt/__InputValue.scala
#	core/src/main/scala/caliban/introspection/adt/__Type.scala
#	core/src/main/scala/caliban/schema/Schema.scala
#	core/src/main/scala/caliban/validation/Validator.scala
#	core/src/test/scala/caliban/introspection/IntrospectionSpec.scala
@kyri-petrou kyri-petrou added the breaking change The PR contains binary incompatible changes label Nov 19, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
breaking change The PR contains binary incompatible changes
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants