diff --git a/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala b/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala index bac5dbf92..61c8d8758 100644 --- a/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala +++ b/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala @@ -94,7 +94,12 @@ trait CommonSchemaDerivation[R] { else p.typeclass.toType_(isInput, isSubscription).nonNull, p.annotations.collectFirst { case GQLDeprecated(_) => () }.isDefined, p.annotations.collectFirst { case GQLDeprecated(reason) => reason }, - Option(p.annotations.collect { case GQLDirective(dir) => dir }.toList).filter(_.nonEmpty) + Option( + p.annotations.collect { case GQLDirective(dir) => dir }.toList ++ { + if (isOptional && p.typeclass.semanticallyNonNull) Some(Directive("semanticNonNull")) + else None + } + ).filter(_.nonEmpty) ) } .toList, diff --git a/core/src/main/scala-3/caliban/schema/DerivationUtils.scala b/core/src/main/scala-3/caliban/schema/DerivationUtils.scala index a1fd4fe88..d8e430ece 100644 --- a/core/src/main/scala-3/caliban/schema/DerivationUtils.scala +++ b/core/src/main/scala-3/caliban/schema/DerivationUtils.scala @@ -140,7 +140,12 @@ private object DerivationUtils { else schema.toType_(isInput, isSubscription).nonNull, deprecatedReason.isDefined, deprecatedReason, - Option(getDirectives(fieldAnnotations)).filter(_.nonEmpty) + Option( + getDirectives(fieldAnnotations) ++ { + if (isOptional && schema.semanticallyNonNull) Some(Directive("semanticNonNull")) + else None + } + ).filter(_.nonEmpty) ) }, getDirectives(annotations), diff --git a/core/src/main/scala/caliban/schema/Schema.scala b/core/src/main/scala/caliban/schema/Schema.scala index b7c00fc86..f5989faee 100644 --- a/core/src/main/scala/caliban/schema/Schema.scala +++ b/core/src/main/scala/caliban/schema/Schema.scala @@ -77,6 +77,11 @@ trait Schema[-R, T] { self => */ def optional: Boolean = false + /** + * Defines if the type is considered semantically nullable or not. + */ + def semanticallyNonNull: Boolean = false + /** * Defined the arguments of the given type. Should be empty except for `Function`. */ @@ -504,6 +509,7 @@ trait GenericSchema[R] extends SchemaDerivation[R] with TemporalSchema { ): Schema[R0, ZIO[R1, E, A]] = new Schema[R0, ZIO[R1, E, A]] { override def optional: Boolean = true + override def semanticallyNonNull: Boolean = !ev.optional override def toType(isInput: Boolean, isSubscription: Boolean): __Type = ev.toType_(isInput, isSubscription) override def resolve(value: ZIO[R1, E, A]): Step[R0] = QueryStep(ZQuery.fromZIONow(value.map(ev.resolve))) } @@ -512,6 +518,7 @@ trait GenericSchema[R] extends SchemaDerivation[R] with TemporalSchema { ): Schema[R0, ZIO[R1, E, A]] = new Schema[R0, ZIO[R1, E, A]] { override def optional: Boolean = true + override def semanticallyNonNull: Boolean = !ev.optional override def toType(isInput: Boolean, isSubscription: Boolean): __Type = ev.toType_(isInput, isSubscription) override def resolve(value: ZIO[R1, E, A]): Step[R0] = QueryStep( ZQuery.fromZIONow(value.mapBoth(convertError, ev.resolve)) @@ -530,6 +537,7 @@ trait GenericSchema[R] extends SchemaDerivation[R] with TemporalSchema { ): Schema[R0, ZQuery[R1, E, A]] = new Schema[R0, ZQuery[R1, E, A]] { override def optional: Boolean = true + override def semanticallyNonNull: Boolean = !ev.optional override def toType(isInput: Boolean, isSubscription: Boolean): __Type = ev.toType_(isInput, isSubscription) override def resolve(value: ZQuery[R1, E, A]): Step[R0] = QueryStep(value.map(ev.resolve)) } @@ -538,6 +546,7 @@ trait GenericSchema[R] extends SchemaDerivation[R] with TemporalSchema { ): Schema[R0, ZQuery[R1, E, A]] = new Schema[R0, ZQuery[R1, E, A]] { override def optional: Boolean = true + override def semanticallyNonNull: Boolean = !ev.optional override def toType(isInput: Boolean, isSubscription: Boolean): __Type = ev.toType_(isInput, isSubscription) override def resolve(value: ZQuery[R1, E, A]): Step[R0] = QueryStep(value.mapBoth(convertError, ev.resolve)) } @@ -557,6 +566,7 @@ trait GenericSchema[R] extends SchemaDerivation[R] with TemporalSchema { ): Schema[R0, ZStream[R1, E, A]] = new Schema[R0, ZStream[R1, E, A]] { override def optional: Boolean = true + override def semanticallyNonNull: Boolean = !ev.optional override def toType(isInput: Boolean, isSubscription: Boolean): __Type = { val t = ev.toType_(isInput, isSubscription) if (isSubscription) t else (if (ev.optional) t else t.nonNull).list @@ -568,6 +578,7 @@ trait GenericSchema[R] extends SchemaDerivation[R] with TemporalSchema { ): Schema[R0, ZStream[R1, E, A]] = new Schema[R0, ZStream[R1, E, A]] { override def optional: Boolean = true + override def semanticallyNonNull: Boolean = !ev.optional override def toType(isInput: Boolean, isSubscription: Boolean): __Type = { val t = ev.toType_(isInput, isSubscription) if (isSubscription) t else (if (ev.optional) t else t.nonNull).list @@ -703,7 +714,12 @@ abstract class PartiallyAppliedFieldBase[V](name: String, description: Option[St else ev.toType_(ft.isInput, ft.isSubscription).nonNull, isDeprecated = Directives.isDeprecated(directives), deprecationReason = Directives.deprecationReason(directives), - directives = Some(directives.filter(_.name != "deprecated")).filter(_.nonEmpty) + directives = Some( + directives.filter(_.name != "deprecated") ++ { + if (ev.optional && ev.semanticallyNonNull) Some(Directive("semanticNonNull")) + else None + } + ).filter(_.nonEmpty) ) } @@ -735,7 +751,12 @@ case class PartiallyAppliedFieldWithArgs[V, A](name: String, description: Option else ev1.toType_(fa.isInput, fa.isSubscription).nonNull, isDeprecated = Directives.isDeprecated(directives), deprecationReason = Directives.deprecationReason(directives), - directives = Some(directives.filter(_.name != "deprecated")).filter(_.nonEmpty) + directives = Some( + directives.filter(_.name != "deprecated") ++ { + if (ev1.optional && ev1.semanticallyNonNull) Some(Directive("semanticNonNull")) + else None + } + ).filter(_.nonEmpty) ), (v: V) => ev1.resolve(fn(v)) ) diff --git a/core/src/test/scala/caliban/schema/SchemaSpec.scala b/core/src/test/scala/caliban/schema/SchemaSpec.scala index 8fb204034..6dacca93a 100644 --- a/core/src/test/scala/caliban/schema/SchemaSpec.scala +++ b/core/src/test/scala/caliban/schema/SchemaSpec.scala @@ -2,7 +2,7 @@ package caliban.schema import caliban.Value.StringValue import caliban._ -import caliban.introspection.adt.{ __DeprecatedArgs, __Type, __TypeKind } +import caliban.introspection.adt.{ __DeprecatedArgs, __Field, __Type, __TypeKind } import caliban.parsing.adt.Directive import caliban.schema.Annotations._ import caliban.schema.ArgBuilder.auto._ @@ -26,11 +26,33 @@ object SchemaSpec extends ZIOSpecDefault { isSome(hasField[__Type, __TypeKind]("kind", _.kind, equalTo(__TypeKind.SCALAR))) ) }, + test("effectful field as semanticNonNull") { + assert(introspect[EffectfulFieldSchema].fields(__DeprecatedArgs()).toList.flatten.headOption)( + isSome( + hasField[__Field, Option[List[Directive]]]( + "directives", + _.directives, + isSome(contains((Directive("semanticNonNull")))) + ) + ) + ) + }, test("effectful field as non-nullable") { assert(introspect[EffectfulFieldSchema].fields(__DeprecatedArgs()).toList.flatten.apply(1)._type)( hasField[__Type, __TypeKind]("kind", _.kind, equalTo(__TypeKind.NON_NULL)) ) }, + test("optional effectful field") { + assert(introspect[OptionalEffectfulFieldSchema].fields(__DeprecatedArgs()).toList.flatten.headOption)( + isSome( + hasField[__Field, Option[List[Directive]]]( + "directives", + _.directives.map(_.filter(_.name == "semanticNonNull")).filter(_.nonEmpty), + isNone + ) + ) + ) + }, test("infallible effectful field") { assert(introspect[InfallibleFieldSchema].fields(__DeprecatedArgs()).toList.flatten.headOption.map(_._type))( isSome(hasField[__Type, __TypeKind]("kind", _.kind, equalTo(__TypeKind.NON_NULL))) @@ -300,7 +322,7 @@ object SchemaSpec extends ZIOSpecDefault { |} | |type EnvironmentSchema { - | test: Int + | test: Int @semanticNonNull | box: Box! |} | @@ -386,6 +408,7 @@ object SchemaSpec extends ZIOSpecDefault { ) case class EffectfulFieldSchema(q: Task[Int], @GQLNonNullable qAnnotated: Task[Int]) + case class OptionalEffectfulFieldSchema(q: Task[Option[String]], @GQLNonNullable qAnnotated: Task[Option[String]]) case class InfallibleFieldSchema(q: UIO[Int], @GQLNullable qAnnotated: UIO[Int]) case class FutureFieldSchema(q: Future[Int]) case class IDSchema(id: UUID)