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 9 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
27 changes: 24 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())) makeOneOffBuilder(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,24 @@ trait CommonArgBuilderDerivation {
}
case None => Left(ExecutionError(s"Can't build a trait from input $input"))
}

private def makeOneOffBuilder[A](ctx: SealedTrait[ArgBuilder, A]): ArgBuilder[A] =
kyri-petrou marked this conversation as resolved.
Show resolved Hide resolved
new ArgBuilder[A] {
private lazy val builders = ctx.subtypes.map(_.typeclass)

def build(input: InputValue): Either[ExecutionError, A] = input match {
case InputValue.ObjectValue(fields) if fields.size == 1 =>
builders.view
.map(_.build(input))
.find(_.isRight)
.getOrElse(Left(ExecutionError(s"Invalid oneOf input $fields for trait ${ctx.typeName.short}}")))
ghostdogpr marked this conversation as resolved.
Show resolved Hide resolved
case InputValue.ObjectValue(_) =>
Left(ExecutionError(s"Exactly one key must be specified for oneOf inputs"))
kyri-petrou marked this conversation as resolved.
Show resolved Hide resolved
case _ =>
Left(ExecutionError(s"Can't build a trait from input $input"))
}
}

}

trait ArgBuilderDerivation extends CommonArgBuilderDerivation {
Expand Down
30 changes: 24 additions & 6 deletions core/src/main/scala-2/caliban/schema/SchemaDerivation.scala
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,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 @@ -61,7 +61,7 @@ trait CommonSchemaDerivation[R] {
Some(ctx.typeName.full),
Some(getDirectives(ctx))
)
else
} else
makeObject(
Some(getName(ctx)),
getDescription(ctx),
Expand Down Expand Up @@ -130,11 +130,19 @@ trait CommonSchemaDerivation[R] {
case _ => false
}

if (isEnum && subtypes.nonEmpty && !isInterface && !isUnion)
lazy val subtypeInputFields =
ctx.subtypes.map(_.typeclass.toType_(isInput = true).inputFields.getOrElse(Nil)).toList

lazy val isOneOfInput =
ctx.annotations.contains(GQLOneOfInput()) &&
subtypeInputFields.nonEmpty &&
subtypeInputFields.forall(_.size == 1)

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 +154,25 @@ trait CommonSchemaDerivation[R] {
Some(ctx.typeName.full),
Some(getDirectives(ctx.annotations))
)
else if (!isInterface)
} else if (isOneOfInput) {
makeInputObject(
Some(ctx.annotations.collectFirst { case GQLInputName(suffix) => suffix }
.getOrElse(customizeInputTypeName(getName(ctx)))),
getDescription(ctx),
subtypeInputFields.flatten.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
48 changes: 39 additions & 9 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, GQLName }

import scala.deriving.Mirror
import scala.compiletime.*
Expand Down Expand Up @@ -34,10 +33,21 @@ 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]) {
inline if (Macros.isValidOneOffInput[A])
makeOneOffBuilder[A](
recurse[A, m.MirroredElemLabels, m.MirroredElemTypes](),
constValue[m.MirroredLabel]
)
else
error(
"Invalid oneOf input. OneOff inputs must be sealed traits with 2 or more case classes extending them that:\n\t1. Have a single non-nullable field\n\t2. Do not have duplicated field names\n\t"
)
} else
makeSumArgBuilder[A](
recurse[A, m.MirroredElemLabels, m.MirroredElemTypes](),
constValue[m.MirroredLabel]
)

case m: Mirror.ProductOf[A] =>
makeProductArgBuilder(
Expand All @@ -49,7 +59,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,10 +83,30 @@ trait CommonArgBuilderDerivation {
}
}

private def makeOneOffBuilder[A](
_subTypes: => List[(String, List[Any], ArgBuilder[Any])],
_traitLabel: => String
): ArgBuilder[A] = new ArgBuilder[A] {
private lazy val builders = _subTypes.map(_._3).asInstanceOf[List[ArgBuilder[A]]]
private lazy val traitLabel = _traitLabel

def build(input: InputValue): Either[ExecutionError, A] = input match {
case InputValue.ObjectValue(fields) if fields.size == 1 =>
builders.view
.map(_.build(input))
.find(_.isRight)
.getOrElse(Left(ExecutionError(s"Invalid oneOf input $fields for trait $traitLabel")))
case InputValue.ObjectValue(_) =>
Left(ExecutionError("Exactly one key must be specified for oneOf inputs"))
case _ =>
Left(ExecutionError(s"Can't build a trait from input $input"))
}
}

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

Expand Down
26 changes: 19 additions & 7 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,12 @@ trait CommonSchemaDerivation {
annotations: List[Any]
)(ordinal: A => Int): Schema[R, A] = new Schema[R, A] {

private lazy val members = _members
private lazy val members = _members.toVector // Vector has ~O(1) performance for `.apply` as opposed to List's O(n)
private lazy val membersOrdered = members.sortBy(_._1).toList

private lazy val subTypes = members.map { case (label, subTypeAnnotations, schema, _) =>
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 +104,20 @@ 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 && subTypes.nonEmpty && isEnum && !isOneOfInput) mkEnum(annotations, info, subTypes)
else if (isOneOfInput) {
makeInputObject(
Some(getInputName(annotations).getOrElse(customizeInputTypeName(getName(annotations, info)))),
getDescription(annotations),
membersOrdered.map(_._3.toType_(true)).flatMap(_.inputFields.getOrElse(Nil)).map(_.nullable),
Some(info.full),
Some(List(Directive("oneOf"))),
isOneOf = true
)
} else if (!isInterface)
makeUnion(
Some(getName(annotations, info)),
getDescription(annotations),
Expand Down Expand Up @@ -227,13 +239,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
27 changes: 25 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,8 @@ 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] }
inline def isValidOneOffInput[P]: Boolean = ${ isValidOneOffInputImpl[P] }

def annotationsImpl[T: Type](using qctx: Quotes): Expr[List[Any]] = {
import qctx.reflect.*
Expand Down Expand Up @@ -88,4 +89,26 @@ 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"))
}

def isValidOneOffInputImpl[T: Type](using q: Quotes): Expr[Boolean] = {
kyri-petrou marked this conversation as resolved.
Show resolved Hide resolved
import q.reflect.*
val tpe = TypeRepr.of[T].typeSymbol
val flags = tpe.flags
if (flags.is(Flags.Sealed) && flags.is(Flags.Trait)) {
val constructors = tpe.children.map(_.primaryConstructor)
val children = constructors.map(_.paramSymss.flatten.map(_.name))
val size = children.size
Expr(
size >= 2
&& children.forall(_.size == 1)
&& size == children.flatten.distinct.size
&& !constructors.exists(_.signature.paramSigs.contains("scala.Option"))
)
} else Expr(false)
}

}
16 changes: 15 additions & 1 deletion core/src/main/scala/caliban/introspection/Introspector.scala
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ object Introspector extends IntrospectionDerivation {
)
)

private val oneOfDirective =
__Directive(
"oneOf",
Some(
"The `@oneOf` directive is used within the type system definition language to indicate an Input Object is a OneOf Input Object."
),
Set(__DirectiveLocation.INPUT_OBJECT),
Nil,
isRepeatable = false
)

/**
* Generates a schema for introspecting the given type.
*/
Expand All @@ -67,14 +78,17 @@ object Introspector extends IntrospectionDerivation {
.values
.toList
.sortBy(_.name.getOrElse(""))

val hasOneOf = types.exists(_._isOneOfInput)

val resolver = __Introspection(
__Schema(
rootType.description,
rootType.queryType,
rootType.mutationType,
rootType.subscriptionType,
types,
directives ++ rootType.additionalDirectives
directives ++ (if (hasOneOf) List(oneOfDirective) else Nil) ++ rootType.additionalDirectives
kyri-petrou marked this conversation as resolved.
Show resolved Hide resolved
),
args => types.find(_.name.contains(args.name))
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ case class __InputValue(
}

private[caliban] lazy val _type: __Type = `type`()

private[caliban] def nullable: __InputValue = copy(`type` = () => _type.nullable)
}
14 changes: 11 additions & 3 deletions core/src/main/scala/caliban/introspection/adt/__Type.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ case class __Type(
ofType: Option[__Type] = None,
specifiedBy: Option[String] = None,
directives: Option[List[Directive]] = None,
origin: Option[String] = None
origin: Option[String] = None,
isOneOf: Option[Boolean] = None
ghostdogpr marked this conversation as resolved.
Show resolved Hide resolved
) { self =>
def |+|(that: __Type): __Type = __Type(
kind,
Expand Down Expand Up @@ -117,11 +118,18 @@ case class __Type(
case _ => true
}

def list: __Type = __Type(__TypeKind.LIST, ofType = Some(self))
def nonNull: __Type = __Type(__TypeKind.NON_NULL, ofType = Some(self))
def list: __Type = __Type(__TypeKind.LIST, ofType = Some(self))
def nonNull: __Type = __Type(__TypeKind.NON_NULL, ofType = Some(self))
def nullable: __Type =
(kind, ofType) match {
case (__TypeKind.NON_NULL, Some(inner)) => inner
case _ => self
}

lazy val allFields: List[__Field] =
fields(__DeprecatedArgs(Some(true))).getOrElse(Nil)

lazy val innerType: __Type = Types.innerType(this)

def _isOneOfInput: Boolean = isOneOf.getOrElse(false)
}
3 changes: 2 additions & 1 deletion core/src/main/scala/caliban/rendering/DocumentRenderer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -437,16 +437,17 @@ object DocumentRenderer extends Renderer[Document] {
value match {
case InputObjectTypeDefinition(description, name, directives, fields) =>
newlineOrSpace(indent, write)
newline(indent, write)
descriptionRenderer.unsafeRender(description, indent, write)
write append "input "
write append name
directivesRenderer.unsafeRender(directives, indent, write)
space(indent, write)
write append '{'
newline(indent, write)
fieldsRenderer.unsafeRender(fields, increment(indent), write)
newline(indent, write)
write append '}'
newline(indent, write)
}
}

Expand Down
Loading