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

Merged
merged 34 commits into from
Jun 18, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6b92ec8
Add `ArgBuilder` derivation for `oneOff` inputs
kyri-petrou Aug 20, 2023
1b37f6b
Add rendering and introspection of `oneOf` inputs
kyri-petrou Aug 20, 2023
8732526
Add tests for executing queries with `oneOf` inputs
kyri-petrou Aug 21, 2023
42b4d12
Don't introspect `isOneOf` in `IntrospectionClient`
kyri-petrou Aug 21, 2023
2ef9ed8
Add value type implementation
kyri-petrou Aug 21, 2023
b42e03b
Rollback irrelevant changes
kyri-petrou Aug 21, 2023
94bfc92
Fix Scala 3 derivation
kyri-petrou Aug 21, 2023
4a68055
Merge branch 'series/2.x' into one-of-input
kyri-petrou Aug 25, 2023
aa8cc9b
Fix merge errors
kyri-petrou Aug 25, 2023
9018352
PR comments
kyri-petrou Aug 27, 2023
e0c0e90
fmt
kyri-petrou Aug 27, 2023
648680c
Add schema & input validations
kyri-petrou Aug 27, 2023
047c324
Remove `nullable` methods and add schema validation tests
kyri-petrou Aug 27, 2023
6f9a481
PR comments
kyri-petrou Aug 28, 2023
a4f34ea
fmt
kyri-petrou Aug 29, 2023
28d1413
Allow OneOf inputs to have a single field
kyri-petrou Aug 29, 2023
f691680
Merge branch 'series/2.x' into one-of-input
kyri-petrou Sep 21, 2023
7dbc6fd
Fix merging errors and add mima exclusions
kyri-petrou Sep 21, 2023
b58e8ad
Merge branch 'series/2.x' into one-of-input
kyri-petrou Oct 15, 2023
ab94d86
Fix merging errors
kyri-petrou Oct 15, 2023
1e590c7
Merge main
kyri-petrou Jan 28, 2024
5ff87ad
Merge branch 'series/2.x' into one-of-input
kyri-petrou Feb 6, 2024
56fa54f
Disable mima
kyri-petrou Feb 6, 2024
6531319
Merge main and resolve merging errors
kyri-petrou Apr 27, 2024
364d255
Merge main
kyri-petrou May 24, 2024
855f856
Merge branch 'refs/heads/series/2.x' into one-of-input
kyri-petrou Jun 3, 2024
cd926b3
Reuse `hasAnnotation` macro
kyri-petrou Jun 3, 2024
2611c5b
Change `parentTypeName` to `parentType` on `__InputValue`
kyri-petrou Jun 3, 2024
2984b44
Remove `isOneOf` argument from `makeInputObject`
kyri-petrou Jun 3, 2024
9120cc7
Fix mima
kyri-petrou Jun 3, 2024
2c542b0
Micro-optimize validation
kyri-petrou Jun 3, 2024
4f7c3e7
Reimplement handling of OneOf inputs via a PartialFunction
kyri-petrou Jun 4, 2024
c327df7
Fix Scala 2.12
kyri-petrou Jun 4, 2024
186be67
Merge branch 'refs/heads/series/2.x' into one-of-input
kyri-petrou Jun 14, 2024
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
30 changes: 27 additions & 3 deletions core/src/main/scala-2/caliban/schema/ArgBuilderDerivation.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ package caliban.schema
import caliban.CalibanError.ExecutionError
import caliban.InputValue
import caliban.Value._
import caliban.schema.Annotations.GQLDefault
import caliban.schema.Annotations.GQLName
import caliban.schema.Annotations.{ GQLDefault, GQLName, GQLOneOfInput }
import magnolia._
import mercator.Monadic

Expand Down Expand Up @@ -38,7 +37,11 @@ trait CommonArgBuilderDerivation {
}
}

def dispatch[T](ctx: SealedTrait[ArgBuilder, T]): ArgBuilder[T] = input =>
def dispatch[T](ctx: SealedTrait[ArgBuilder, T]): ArgBuilder[T] =
if (ctx.annotations.contains(GQLOneOfInput())) makeOneOfBuilder(ctx)
else makeSumBuilder(ctx)

private def makeSumBuilder[T](ctx: SealedTrait[ArgBuilder, T]): ArgBuilder[T] = input =>
(input match {
case EnumValue(value) => Some(value)
case StringValue(value) => Some(value)
Expand All @@ -54,6 +57,27 @@ trait CommonArgBuilderDerivation {
}
case None => Left(ExecutionError(s"Can't build a trait from input $input"))
}

private def makeOneOfBuilder[A](ctx: SealedTrait[ArgBuilder, A]): ArgBuilder[A] =
new ArgBuilder[A] {

private def inputError(input: InputValue) =
ExecutionError(s"Invalid oneOf input $input for trait ${ctx.typeName.short}")

private val combined = ctx.subtypes.map(_.typeclass).toList.asInstanceOf[List[ArgBuilder[A]]] match {
case head :: tail =>
tail.foldLeft(head)(_ orElse _).orElse(input => Left(inputError(input)))
ghostdogpr marked this conversation as resolved.
Show resolved Hide resolved
case _ =>
(_ => Left(ExecutionError("OneOf Input Objects must have at least one subtype"))): ArgBuilder[A]
}

def build(input: InputValue): Either[ExecutionError, A] = input match {
case InputValue.ObjectValue(f) if f.size == 1 => combined.build(input)
case InputValue.ObjectValue(_) => Left(ExecutionError("Exactly one key must be specified for oneOf inputs"))
case _ => Left(inputError(input))
}
}

}

trait ArgBuilderDerivation extends CommonArgBuilderDerivation {
Expand Down
30 changes: 23 additions & 7 deletions core/src/main/scala-2/caliban/schema/SchemaDerivation.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package caliban.schema

import caliban.CalibanError.ValidationError
import caliban.Value._
import caliban.introspection.adt._
import caliban.parsing.adt.Directive
Expand Down Expand Up @@ -40,7 +41,7 @@ trait CommonSchemaDerivation[R] {
if ((ctx.isValueClass || isValueType(ctx)) && ctx.parameters.nonEmpty) {
if (isScalarValueType(ctx)) makeScalar(getName(ctx), getDescription(ctx))
else ctx.parameters.head.typeclass.toType_(isInput, isSubscription)
} else if (isInput)
} else if (isInput) {
makeInputObject(
Some(ctx.annotations.collectFirst { case GQLInputName(suffix) => suffix }
.getOrElse(customizeInputTypeName(getName(ctx)))),
Expand All @@ -54,14 +55,15 @@ trait CommonSchemaDerivation[R] {
if (p.typeclass.optional) p.typeclass.toType_(isInput, isSubscription)
else p.typeclass.toType_(isInput, isSubscription).nonNull,
p.annotations.collectFirst { case GQLDefault(v) => v },
Some(p.annotations.collect { case GQLDirective(dir) => dir }.toList).filter(_.nonEmpty)
Some(p.annotations.collect { case GQLDirective(dir) => dir }.toList).filter(_.nonEmpty),
Some(ctx.typeName.short)
)
)
.toList,
Some(ctx.typeName.full),
Some(getDirectives(ctx))
)
else
} else
makeObject(
Some(getName(ctx)),
getDescription(ctx),
Expand Down Expand Up @@ -130,11 +132,13 @@ trait CommonSchemaDerivation[R] {
case _ => false
}

if (isEnum && subtypes.nonEmpty && !isInterface && !isUnion)
val isOneOfInput = ctx.annotations.contains(GQLOneOfInput())

if (isEnum && subtypes.nonEmpty && !isInterface && !isUnion && !isOneOfInput) {
makeEnum(
Some(getName(ctx)),
getDescription(ctx),
subtypes.collect { case (__Type(_, Some(name), description, _, _, _, _, _, _, _, _, _), annotations) =>
subtypes.collect { case (__Type(_, Some(name), description, _, _, _, _, _, _, _, _, _, _), annotations) =>
__EnumValue(
name,
description,
Expand All @@ -146,15 +150,27 @@ trait CommonSchemaDerivation[R] {
Some(ctx.typeName.full),
Some(getDirectives(ctx.annotations))
)
else if (!isInterface)
} else if (isOneOfInput && isInput) {
makeInputObject(
Some(ctx.annotations.collectFirst { case GQLInputName(suffix) => suffix }
.getOrElse(customizeInputTypeName(getName(ctx)))),
getDescription(ctx),
ctx.subtypes.toList.flatMap { p =>
p.typeclass.toType_(isInput = true).inputFields.getOrElse(Nil).map(_.nullable)
},
Some(ctx.typeName.full),
Some(List(Directive("oneOf"))),
isOneOf = true
)
} else if (!isInterface) {
makeUnion(
Some(getName(ctx)),
getDescription(ctx),
subtypes.map { case (t, _) => fixEmptyUnionObject(t) },
Some(ctx.typeName.full),
Some(getDirectives(ctx.annotations))
)
else {
} else {
val impl = subtypes.map(_._1.copy(interfaces = () => Some(List(toType(isInput, isSubscription)))))
val commonFields = () =>
impl
Expand Down
66 changes: 49 additions & 17 deletions core/src/main/scala-3/caliban/schema/ArgBuilderDerivation.scala
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
package caliban.schema

import caliban.CalibanError.ExecutionError
import caliban.{ CalibanError, InputValue }
import caliban.{ schema, CalibanError, InputValue }
import caliban.Value.*
import caliban.schema.macros.Macros
import caliban.schema.Annotations.GQLDefault
import caliban.schema.Annotations.GQLName
import caliban.schema.Annotations.{ GQLDefault, GQLExcluded, GQLName }

import scala.deriving.Mirror
import scala.compiletime.*
Expand Down Expand Up @@ -34,10 +33,16 @@ trait CommonArgBuilderDerivation {
inline def derived[A]: ArgBuilder[A] =
inline summonInline[Mirror.Of[A]] match {
case m: Mirror.SumOf[A] =>
makeSumArgBuilder[A](
recurse[A, m.MirroredElemLabels, m.MirroredElemTypes](),
constValue[m.MirroredLabel]
)
inline if (Macros.hasOneOfInputAnnotation[A]) {
makeOneOfBuilder[A](
recurse[A, m.MirroredElemLabels, m.MirroredElemTypes](),
constValue[m.MirroredLabel]
)
} else
makeSumArgBuilder[A](
recurse[A, m.MirroredElemLabels, m.MirroredElemTypes](),
constValue[m.MirroredLabel]
)

case m: Mirror.ProductOf[A] =>
makeProductArgBuilder(
Expand All @@ -49,7 +54,7 @@ trait CommonArgBuilderDerivation {
private def makeSumArgBuilder[A](
_subTypes: => List[(String, List[Any], ArgBuilder[Any])],
_traitLabel: => String
) = new ArgBuilder[A] {
): ArgBuilder[A] = new ArgBuilder[A] {
private lazy val subTypes = _subTypes
private lazy val traitLabel = _traitLabel
private val emptyInput = InputValue.ObjectValue(Map())
Expand All @@ -73,24 +78,51 @@ trait CommonArgBuilderDerivation {
}
}

private def makeOneOfBuilder[A](
_subTypes: => List[(String, List[Any], ArgBuilder[Any])],
_traitLabel: => String
): ArgBuilder[A] = new ArgBuilder[A] {
private lazy val traitLabel = _traitLabel

private lazy val combined: ArgBuilder[A] =
_subTypes.map(_._3).asInstanceOf[List[ArgBuilder[A]]] match {
case head :: tail =>
tail.foldLeft(head)(_ orElse _).orElse((input: InputValue) => Left(inputError(input)))
case _ =>
(_: InputValue) => Left(ExecutionError("OneOf Input Objects must have at least one subtype"))
}

private def inputError(input: InputValue) =
ExecutionError(s"Invalid oneOf input $input for trait $traitLabel")

def build(input: InputValue): Either[ExecutionError, A] = input match {
case InputValue.ObjectValue(f) if f.size == 1 => combined.build(input)
case InputValue.ObjectValue(_) => Left(ExecutionError("Exactly one key must be specified for oneOf inputs"))
case _ => Left(inputError(input))
}
}

private def makeProductArgBuilder[A](
_fields: => List[(String, List[Any], ArgBuilder[Any])],
_annotations: => Map[String, List[Any]]
)(fromProduct: Product => A) = new ArgBuilder[A] {
private lazy val fields = _fields
)(fromProduct: Product => A): ArgBuilder[A] = new ArgBuilder[A] {

private lazy val annotations = _annotations

private lazy val fields = _fields.map { (label, _, builder) =>
val labelList = annotations.get(label)
val default = labelList.flatMap(_.collectFirst { case GQLDefault(v) => v })
val finalLabel = labelList.flatMap(_.collectFirst { case GQLName(name) => name }).getOrElse(label)
(finalLabel, default, builder)
}

def build(input: InputValue): Either[ExecutionError, A] =
fields.view.map { (label, _, builder) =>
fields.view.map { (label, default, builder) =>
input match {
case InputValue.ObjectValue(fields) =>
val labelList = annotations.get(label)
def default = labelList.flatMap(_.collectFirst { case GQLDefault(v) => v })
val finalLabel = labelList.flatMap(_.collectFirst { case GQLName(name) => name }).getOrElse(label)
fields.get(finalLabel).fold(builder.buildMissing(default))(builder.build)
case InputValue.ObjectValue(fields) => fields.get(label).fold(builder.buildMissing(default))(builder.build)
case value => builder.build(value)
}
}.foldLeft[Either[ExecutionError, Tuple]](Right(EmptyTuple)) { case (acc, item) =>
}.foldLeft[Either[ExecutionError, Tuple]](Right(EmptyTuple)) { (acc, item) =>
item match {
case Right(value) => acc.map(_ :* value)
case Left(e) => Left(e)
Expand Down
45 changes: 35 additions & 10 deletions core/src/main/scala-3/caliban/schema/SchemaDerivation.scala
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,14 @@ trait CommonSchemaDerivation {
annotations: List[Any]
)(ordinal: A => Int): Schema[R, A] = new Schema[R, A] {

private lazy val members = _members
// Vector has ~O(1) performance for `.apply` as opposed to List's O(n)
private lazy val members = _members.map(v => (v._1, v._2, v._3)).toVector

private lazy val subTypes = members.map { case (label, subTypeAnnotations, schema, _) =>
private lazy val membersOrdered = members.sortBy(_._1).toList

private lazy val subTypes = membersOrdered.map { (label, subTypeAnnotations, schema) =>
(label, schema.toType_(), subTypeAnnotations)
}.sortBy { case (label, _, _) => label }
}

private lazy val isEnum = subTypes.forall { (_, t, _) =>
t.fields(__DeprecatedArgs(Some(true))).forall(_.isEmpty)
Expand All @@ -103,9 +106,14 @@ trait CommonSchemaDerivation {
case _ => false
}

private lazy val isOneOfInput = annotations.contains(GQLOneOfInput())

def toType(isInput: Boolean, isSubscription: Boolean): __Type =
if (!isInterface && !isUnion && subTypes.nonEmpty && isEnum) mkEnum(annotations, info, subTypes)
else if (!isInterface)
if (!isInterface && !isUnion && membersOrdered.nonEmpty && isEnum && !isOneOfInput)
mkEnum(annotations, info, subTypes)
else if (isOneOfInput && isInput) {
mkOneOfInput(annotations, membersOrdered, info)
} else if (!isInterface)
makeUnion(
Some(getName(annotations, info)),
getDescription(annotations),
Expand All @@ -119,7 +127,7 @@ trait CommonSchemaDerivation {
}

def resolve(value: A): Step[R] = {
val (label, _, schema, _) = members(ordinal(value))
val (label, _, schema) = members(ordinal(value))
if (isEnum) PureStep(EnumValue(label)) else schema.resolve(value)
}
}
Expand Down Expand Up @@ -163,7 +171,7 @@ trait CommonSchemaDerivation {
head._3.resolve(value.asInstanceOf[Product].productElement(head._4))
} else {
val fieldsBuilder = Map.newBuilder[String, Step[R]]
fields.foreach { case (label, _, schema, index) =>
fields.foreach { (label, _, schema, index) =>
val fieldAnnotations = paramAnnotations.getOrElse(label, Nil)
lazy val step = schema.resolve(value.asInstanceOf[Product].productElement(index))
fieldsBuilder += getName(fieldAnnotations, label) -> {
Expand Down Expand Up @@ -227,13 +235,13 @@ trait CommonSchemaDerivation {
makeEnum(
Some(getName(annotations, info)),
getDescription(annotations),
subTypes.collect { case (name, __Type(_, _, description, _, _, _, _, _, _, _, _, _), annotations) =>
subTypes.collect { case (name, __Type(_, _, description, _, _, _, _, _, _, _, _, _, _), annotations) =>
__EnumValue(
name,
description,
getDeprecatedReason(annotations).isDefined,
getDeprecatedReason(annotations),
Some(annotations.collect { case GQLDirective(dir) => dir }.toList).filter(_.nonEmpty)
Some(annotations.collect { case GQLDirective(dir) => dir }).filter(_.nonEmpty)
)
},
Some(info.full),
Expand Down Expand Up @@ -285,13 +293,30 @@ trait CommonSchemaDerivation {
if (schema.optional) schema.toType_(isInput, isSubscription)
else schema.toType_(isInput, isSubscription).nonNull,
getDefaultValue(fieldAnnotations),
Some(getDirectives(fieldAnnotations)).filter(_.nonEmpty)
Some(getDirectives(fieldAnnotations)).filter(_.nonEmpty),
Some(info.short)
)
},
Some(info.full),
Some(getDirectives(annotations))
)

private def mkOneOfInput[R](
annotations: List[Any],
members: List[(String, List[Any], Schema[R, Any])],
info: TypeInfo
) =
makeInputObject(
Some(getInputName(annotations).getOrElse(customizeInputTypeName(getName(annotations, info)))),
getDescription(annotations),
members.flatMap { (_, _, schema) =>
schema.toType_(isInput = true).inputFields.getOrElse(Nil).map(_.nullable)
},
Some(info.full),
Some(List(Directive("oneOf"))),
isOneOf = true
)

private def mkObject[R](
annotations: List[Any],
fields: List[(String, List[Any], Schema[R, Any], Int)],
Expand Down
8 changes: 6 additions & 2 deletions core/src/main/scala-3/caliban/schema/macros/Macros.scala
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package caliban.schema.macros

import caliban.schema.Annotations.GQLExcluded
import caliban.schema.Annotations.{ GQLExcluded, GQLOneOfInput }

import scala.quoted.*
import scala.compiletime.*

private[caliban] object Macros {
// this code was inspired from WIP in magnolia
Expand All @@ -15,6 +14,7 @@ private[caliban] object Macros {
inline def isFieldExcluded[P, T]: Boolean = ${ isFieldExcludedImpl[P, T] }
inline def isEnumField[P, T]: Boolean = ${ isEnumFieldImpl[P, T] }
inline def implicitExists[T]: Boolean = ${ implicitExistsImpl[T] }
inline def hasOneOfInputAnnotation[P]: Boolean = ${ hasOneOfInputAnnotationImpl[P] }

def annotationsImpl[T: Type](using qctx: Quotes): Expr[List[Any]] = {
import qctx.reflect.*
Expand Down Expand Up @@ -88,4 +88,8 @@ private[caliban] object Macros {
Expr(TypeRepr.of[P].typeSymbol.flags.is(Flags.Enum) && TypeRepr.of[T].typeSymbol.flags.is(Flags.Enum))
}

def hasOneOfInputAnnotationImpl[T: Type](using q: Quotes): Expr[Boolean] = {
import q.reflect.*
Expr(TypeRepr.of[T].typeSymbol.annotations.exists(_.tpe.typeSymbol.name == "GQLOneOfInput"))
}
}
Loading