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 31 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
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package caliban.json

import caliban.Value._
import caliban.interop.circe.json.GraphQLResponseCirce.{graphQLResponseEncoder => circeEncoder}
import caliban.interop.zio.GraphQLResponseZioJson.{graphQLResponseEncoder => zioEncoder}
import caliban.{GraphQLResponse, ResponseValue}
import caliban.interop.circe.json.GraphQLResponseCirce.{ graphQLResponseEncoder => circeEncoder }
import caliban.interop.zio.GraphQLResponseZioJson.{ graphQLResponseEncoder => zioEncoder }
import caliban.{ GraphQLResponse, ResponseValue }
import com.github.plokhotnyuk.jsoniter_scala.core._
import org.openjdk.jmh.annotations._

Expand Down
17 changes: 15 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import com.typesafe.tools.mima.core.{ DirectMissingMethodProblem, MissingClassProblem, ProblemFilters }
import com.typesafe.tools.mima.core.{
DirectMissingMethodProblem,
FinalMethodProblem,
MissingClassProblem,
MissingTypesProblem,
ProblemFilters
}
import org.scalajs.linker.interface.ModuleSplitStyle
import sbtcrossproject.CrossPlugin.autoImport.{ crossProject, CrossType }

Expand Down Expand Up @@ -774,7 +780,14 @@ lazy val enableMimaSettingsJVM =
Def.settings(
mimaFailOnProblem := enforceMimaCompatibility,
mimaPreviousArtifacts := previousStableVersion.value.map(organization.value %% moduleName.value % _).toSet,
mimaBinaryIssueFilters ++= Seq()
mimaBinaryIssueFilters ++= Seq(
ProblemFilters.exclude[DirectMissingMethodProblem]("caliban.parsing.adt.Type.$init$"),
ProblemFilters.exclude[DirectMissingMethodProblem]("caliban.introspection.adt.__Type.*"),
ProblemFilters.exclude[DirectMissingMethodProblem]("caliban.introspection.adt.__InputValue.*"),
ProblemFilters.exclude[FinalMethodProblem]("caliban.parsing.adt.Type*"),
ProblemFilters.exclude[MissingTypesProblem]("caliban.introspection.adt.__Type$"),
ProblemFilters.exclude[MissingTypesProblem]("caliban.introspection.adt.__InputValue$")
)
)

lazy val enableMimaSettingsJS =
Expand Down
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 magnolia1._

import scala.language.experimental.macros
Expand Down Expand Up @@ -37,7 +36,11 @@ trait CommonArgBuilderDerivation {
}
}

def split[T](ctx: SealedTrait[ArgBuilder, T]): ArgBuilder[T] = input =>
def split[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 @@ -53,6 +56,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
32 changes: 24 additions & 8 deletions core/src/main/scala-2/caliban/schema/SchemaDerivation.scala
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package caliban.schema

import caliban.CalibanError.ValidationError
import caliban.Value._
import caliban.introspection.adt._
import caliban.parsing.adt.Directive
import caliban.parsing.adt.{ Directive, Directives }
import caliban.schema.Annotations._
import caliban.schema.Types._
import magnolia1._
Expand Down Expand Up @@ -65,8 +66,8 @@ trait CommonSchemaDerivation[R] {
if (_isValueType) {
if (isScalarValueType(ctx)) makeScalar(getName(ctx), getDescription(ctx))
else ctx.parameters.head.typeclass.toType_(isInput, isSubscription)
} else if (isInput)
makeInputObject(
} else if (isInput) {
lazy val tpe: __Type = makeInputObject(
Some(ctx.annotations.collectFirst { case GQLInputName(suffix) => suffix }
.getOrElse(customizeInputTypeName(getName(ctx)))),
getDescription(ctx),
Expand All @@ -81,14 +82,16 @@ trait CommonSchemaDerivation[R] {
p.annotations.collectFirst { case GQLDefault(v) => v },
p.annotations.collectFirst { case GQLDeprecated(_) => () }.isDefined,
p.annotations.collectFirst { case GQLDeprecated(reason) => reason },
Some(p.annotations.collect { case GQLDirective(dir) => dir }.toList).filter(_.nonEmpty)
Some(p.annotations.collect { case GQLDirective(dir) => dir }.toList).filter(_.nonEmpty),
() => Some(tpe)
)
)
.toList,
Some(ctx.typeName.full),
Some(getDirectives(ctx))
)
else
tpe
} else
makeObject(
Some(getName(ctx)),
getDescription(ctx),
Expand Down Expand Up @@ -172,11 +175,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 @@ -188,7 +193,18 @@ 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).allInputFields.map(_.nullable)
},
Some(ctx.typeName.full),
Some(List(Directive(Directives.OneOf)))
)
} else if (!isInterface) {
containsEmptyUnionObjects = emptyUnionObjectIdxs.contains(true)
makeUnion(
Some(getName(ctx)),
Expand Down
60 changes: 46 additions & 14 deletions core/src/main/scala-3/caliban/schema/ArgBuilderDerivation.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package caliban.schema

import caliban.CalibanError.ExecutionError
import caliban.Value.*
import caliban.schema.Annotations.{ GQLDefault, GQLName }
import caliban.schema.Annotations.{ GQLDefault, GQLName, GQLOneOfInput }
import caliban.schema.macros.Macros
import caliban.{ CalibanError, InputValue }
import magnolia1.Macro as MagnoliaMacro
Expand Down Expand Up @@ -53,10 +53,16 @@ trait CommonArgBuilderDerivation {
inline def derived[A]: ArgBuilder[A] =
inline summonInline[Mirror.Of[A]] match {
case m: Mirror.SumOf[A] =>
makeSumArgBuilder[A](
recurseSum[A, m.MirroredElemLabels, m.MirroredElemTypes](),
constValue[m.MirroredLabel]
)
inline if (Macros.hasAnnotation[A, GQLOneOfInput]) {
makeOneOfBuilder[A](
recurseSum[A, m.MirroredElemLabels, m.MirroredElemTypes](),
constValue[m.MirroredLabel]
)
} else
makeSumArgBuilder[A](
recurseSum[A, m.MirroredElemLabels, m.MirroredElemTypes](),
constValue[m.MirroredLabel]
)

case m: Mirror.ProductOf[A] =>
makeProductArgBuilder(
Expand All @@ -68,7 +74,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 val emptyInput = InputValue.ObjectValue(Map.empty)

Expand All @@ -91,23 +97,49 @@ 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, ArgBuilder[Any])],
annotations: Map[String, List[Any]]
)(fromProduct: Product => A) = new ArgBuilder[A] {
private lazy val fields = _fields

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
62 changes: 40 additions & 22 deletions core/src/main/scala-3/caliban/schema/DerivationUtils.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package caliban.schema

import caliban.introspection.adt.*
import caliban.parsing.adt.Directive
import caliban.parsing.adt.{ Directive, Directives }
import caliban.schema.Annotations.*
import caliban.schema.Types.*
import magnolia1.TypeInfo
Expand Down Expand Up @@ -48,13 +48,13 @@ private object DerivationUtils {
makeEnum(
Some(getName(annotations, info)),
getDescription(annotations),
subTypes.collect { case (name, __Type(_, _, description, _, _, _, _, _, _, _, _, _), annotations) =>
subTypes.collect { case (name, __Type(_, _, description, _, _, _, _, _, _, _, _, _, _), annotations) =>
__EnumValue(
getName(annotations, 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 @@ -97,25 +97,43 @@ private object DerivationUtils {
annotations: List[Any],
fields: List[(String, List[Any], Schema[R, Any])],
info: TypeInfo
)(isInput: Boolean, isSubscription: Boolean): __Type = makeInputObject(
Some(getInputName(annotations).getOrElse(customizeInputTypeName(getName(annotations, info)))),
getDescription(annotations),
fields.map { (name, fieldAnnotations, schema) =>
__InputValue(
name,
getDescription(fieldAnnotations),
() =>
if (schema.optional) schema.toType_(isInput, isSubscription)
else schema.toType_(isInput, isSubscription).nonNull,
getDefaultValue(fieldAnnotations),
getDeprecatedReason(fieldAnnotations).isDefined,
getDeprecatedReason(fieldAnnotations),
Some(getDirectives(fieldAnnotations)).filter(_.nonEmpty)
)
},
Some(info.full),
Some(getDirectives(annotations))
)
)(isInput: Boolean, isSubscription: Boolean): __Type = {
lazy val tpe: __Type = makeInputObject(
Some(getInputName(annotations).getOrElse(customizeInputTypeName(getName(annotations, info)))),
getDescription(annotations),
fields.map { (name, fieldAnnotations, schema) =>
val deprecationReason = getDeprecatedReason(fieldAnnotations)
__InputValue(
name,
description = getDescription(fieldAnnotations),
`type` = () =>
if (schema.optional) schema.toType_(isInput, isSubscription)
else schema.toType_(isInput, isSubscription).nonNull,
defaultValue = getDefaultValue(fieldAnnotations),
isDeprecated = deprecationReason.isDefined,
deprecationReason = deprecationReason,
directives = Some(getDirectives(fieldAnnotations)).filter(_.nonEmpty),
parentType = () => Some(tpe)
)
},
Some(info.full),
Some(getDirectives(annotations))
)
tpe
}

def mkOneOfInput[R](
annotations: List[Any],
schemas: List[Schema[R, Any]],
info: TypeInfo
): __Type =
makeInputObject(
Some(getInputName(annotations).getOrElse(customizeInputTypeName(getName(annotations, info)))),
getDescription(annotations),
schemas.flatMap(_.toType_(isInput = true).allInputFields.map(_.nullable)),
Some(info.full),
Some(List(Directive(Directives.OneOf)))
)

def mkObject[R](
annotations: List[Any],
Expand Down
6 changes: 4 additions & 2 deletions core/src/main/scala-3/caliban/schema/SumSchema.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package caliban.schema

import caliban.introspection.adt.__Type
import caliban.schema.Annotations.{ GQLInterface, GQLUnion }
import caliban.schema.Annotations.{ GQLInterface, GQLOneOfInput, GQLUnion }
import caliban.schema.DerivationUtils.*
import caliban.schema.Types.makeUnion
import magnolia1.TypeInfo
Expand All @@ -26,14 +26,16 @@ final private class SumSchema[R, A](
}

private var containsEmptyUnionObjects = false
private val isOneOfInput = annotations.contains(GQLOneOfInput())

def toType(isInput: Boolean, isSubscription: Boolean): __Type = {
val _ = schemas
val isInterface = annotations.exists(_.isInstanceOf[GQLInterface])
val isUnion = annotations.contains(GQLUnion())
@threadUnsafe lazy val isEnum = subTypes.forall((_, t, _) => t.allFields.isEmpty && t.allInputFields.isEmpty)

if (!isInterface && !isUnion && subTypes.nonEmpty && isEnum) mkEnum(annotations, info, subTypes)
if (!isInterface && !isUnion && subTypes.nonEmpty && isEnum && !isOneOfInput) mkEnum(annotations, info, subTypes)
else if (isOneOfInput && isInput) mkOneOfInput(annotations, schemas.toList, info)
else if (!isInterface) {
containsEmptyUnionObjects = emptyUnionObjectIdxs.contains(true)
makeUnion(
Expand Down
2 changes: 1 addition & 1 deletion core/src/main/scala-3/caliban/schema/macros/Macros.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package caliban.schema.macros

import caliban.schema.Annotations.{ GQLExcluded, GQLField }
import caliban.schema.Annotations.*
import caliban.schema.Schema

import scala.quoted.*
Expand Down
Loading