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

Fix ArgBuilder derivation for case classes containing only optional fields #2408

Merged
merged 5 commits into from
Sep 20, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package caliban.schema
import caliban.CalibanError.ExecutionError
import caliban.InputValue
import caliban.Value._
import caliban.schema.Annotations.{ GQLDefault, GQLName, GQLOneOfInput }
import caliban.schema.Annotations.{ GQLDefault, GQLName, GQLOneOfInput, GQLValueType }
import magnolia1._

import scala.collection.compat._
Expand All @@ -27,7 +27,6 @@ trait CommonArgBuilderDerivation {
}

def join[T](ctx: CaseClass[ArgBuilder, T]): ArgBuilder[T] = new ArgBuilder[T] {

private val params = {
val arr = Array.ofDim[(String, EitherExecutionError[Any])](ctx.parameters.length)
ctx.parameters.zipWithIndex.foreach { case (p, i) =>
Expand All @@ -40,14 +39,18 @@ trait CommonArgBuilderDerivation {

private val required = params.collect { case (label, default) if default.isLeft => label }

private val isValueType =
ctx.isValueClass || (ctx.annotations.exists(_.isInstanceOf[GQLValueType]) && params.length == 1)

override private[schema] val partial: PartialFunction[InputValue, Either[ExecutionError, T]] = {
case InputValue.ObjectValue(fields) if required.forall(fields.contains) => fromFields(fields)
}

def build(input: InputValue): Either[ExecutionError, T] =
input match {
case InputValue.ObjectValue(fields) => fromFields(fields)
case value => ctx.constructMonadic(p => p.typeclass.build(value))
case value if isValueType => ctx.constructMonadic(p => p.typeclass.build(value))
kyri-petrou marked this conversation as resolved.
Show resolved Hide resolved
case _ => Left(ExecutionError("expected an input object"))
kyri-petrou marked this conversation as resolved.
Show resolved Hide resolved
}

private[this] def fromFields(fields: Map[String, InputValue]): Either[ExecutionError, T] =
Expand Down
19 changes: 15 additions & 4 deletions core/src/main/scala-3/caliban/schema/ArgBuilderDerivation.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package caliban.schema
import caliban.CalibanError.ExecutionError
import caliban.InputValue.{ ListValue, VariableValue }
import caliban.Value.*
import caliban.schema.Annotations.{ GQLDefault, GQLName, GQLOneOfInput }
import caliban.schema.Annotations.{ GQLDefault, GQLName, GQLOneOfInput, GQLValueType }
import caliban.schema.macros.Macros
import caliban.{ CalibanError, InputValue }
import magnolia1.Macro as MagnoliaMacro
Expand Down Expand Up @@ -70,10 +70,19 @@ trait CommonArgBuilderDerivation {
case m: Mirror.ProductOf[A] =>
makeProductArgBuilder(
recurseProduct[A, m.MirroredElemLabels, m.MirroredElemTypes](),
MagnoliaMacro.paramAnns[A].toMap
MagnoliaMacro.paramAnns[A].toMap,
isValueType[A, m.MirroredElemLabels]
)(m.fromProduct)
}

transparent inline private def isValueType[A, Labels]: Boolean =
inline if (MagnoliaMacro.isValueClass[A]) true
else
inline erasedValue[Labels] match {
case _: Tuple1[?] => Macros.hasAnnotation[A, GQLValueType]
case _ => false
}

private def makeSumArgBuilder[A](
_subTypes: => List[(String, List[Any], ArgBuilder[Any])],
traitLabel: String
Expand Down Expand Up @@ -124,7 +133,8 @@ trait CommonArgBuilderDerivation {

private def makeProductArgBuilder[A](
_fields: => List[(String, ArgBuilder[Any])],
annotations: Map[String, List[Any]]
annotations: Map[String, List[Any]],
isValueType: Boolean
)(fromProduct: Product => A): ArgBuilder[A] = new ArgBuilder[A] {

private val params = Array.from(_fields.map { (label, builder) =>
Expand All @@ -143,7 +153,8 @@ trait CommonArgBuilderDerivation {
def build(input: InputValue): Either[ExecutionError, A] =
input match {
case InputValue.ObjectValue(fields) => fromFields(fields)
case value => fromValue(value)
case value if isValueType => fromValue(value)
case _ => Left(ExecutionError("expected an input object"))
kyri-petrou marked this conversation as resolved.
Show resolved Hide resolved
}

private def fromFields(fields: Map[String, InputValue]): Either[ExecutionError, A] = {
Expand Down
91 changes: 90 additions & 1 deletion core/src/test/scala/caliban/execution/ExecutionSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1505,8 +1505,97 @@ object ExecutionSpec extends ZIOSpecDefault {
ZIO.foldLeft(cases)(assertCompletes) { case (acc, (query, expected)) =>
api.interpreter
.flatMap(_.execute(query, variables = Map("args" -> ObjectValue(Map("intValue" -> IntValue(42))))))
.map(response => assertTrue(response.data.toString == expected))
.map(response => acc && assertTrue(response.data.toString == expected))
}
},
test("oneOf input with input object with all optional fields") {

case class AddPet(pet: Pet.Wrapper)
case class Queries(addPet: AddPet => Pet)

val api: GraphQL[Any] = graphQL(
RootResolver(
Queries(_.pet.pet)
)
)

val cases = List(
gqldoc("""{
addPet(pet: { cat: { name: "a" } }) {
__typename
... on Cat { name }
... on Dog { name }
}
}""") -> """{"addPet":{"__typename":"Cat","name":"a"}}""",
gqldoc("""{
addPet(pet: { dog: {} }) {
__typename
... on Cat { name }
... on Dog { name }
}
}""") -> """{"addPet":{"__typename":"Dog","name":null}}""",
gqldoc("""{
addPet(pet: { dog: { name: "b" } }) {
__typename
... on Cat { name }
... on Dog { name }
}
}""") -> """{"addPet":{"__typename":"Dog","name":"b"}}"""
)

ZIO.foldLeft(cases)(assertCompletes) { case (acc, (query, expected)) =>
api.interpreter
.flatMap(_.execute(query))
.map(response => acc && assertTrue(response.data.toString == expected))
}
}
)
}

// needs to be outside for Scala 2
sealed trait Pet
object Pet { parent =>

@GQLOneOfInput
@GQLName("Pet")
sealed trait Wrapper {
def pet: Pet
}
object Wrapper {
implicit val argBuilder: ArgBuilder[Wrapper] = ArgBuilder.gen
implicit val schema: Schema[Any, Wrapper] = Schema.gen
}

case class Cat(name: Option[String], numberOfLives: Option[Int]) extends Pet
object Cat {
@GQLName("Cat")
case class Wrapper(cat: Cat) extends parent.Wrapper {
override val pet = cat
}
object Wrapper {
implicit val argBuilder: ArgBuilder[Wrapper] = ArgBuilder.gen
implicit val schema: Schema[Any, Wrapper] = Schema.gen
}

implicit val argBuilder: ArgBuilder[Cat] = ArgBuilder.gen
implicit val schema: Schema[Any, Cat] = Schema.gen
}

case class Dog(name: Option[String], wagsTail: Option[Boolean]) extends Pet
object Dog {
@GQLName("Dog")
case class Wrapper(dog: Dog) extends parent.Wrapper {
override val pet = dog
}
object Wrapper {
implicit val argBuilder: ArgBuilder[Wrapper] = ArgBuilder.gen
implicit val schema: Schema[Any, Wrapper] = Schema.gen
}

implicit val argBuilder: ArgBuilder[Dog] = ArgBuilder.gen
implicit val schema: Schema[Any, Dog] = Schema.gen
}

implicit val argBuilder: ArgBuilder[Pet] = ArgBuilder.gen
implicit val schema: Schema[Any, Pet] = Schema.gen
}
10 changes: 10 additions & 0 deletions core/src/test/scala/caliban/schema/ArgBuilderSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ object ArgBuilderSpec extends ZIOSpecDefault {
)
)
),
suite("derived build")(
test("should fail when null is provided for case class with optional fields") {
case class Foo(value: Option[String])
val ab = ArgBuilder.gen[Foo]
assertTrue(
ab.build(NullValue).isLeft,
ab.build(ObjectValue(Map())).isRight
)
}
),
suite("buildMissing")(
test("works with derived case class ArgBuilders") {
sealed abstract class Nullable[+T]
Expand Down