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 transformer that excludes fields / inputs based on directives #2293

Merged
merged 11 commits into from
Jun 27, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ trait CommonSchemaDerivation[R] {

private def getDescription[Typeclass[_], Type](ctx: ReadOnlyParam[Typeclass, Type]): Option[String] =
getDescription(ctx.annotations)

}

trait SchemaDerivation[R] extends CommonSchemaDerivation[R] {
Expand Down
6 changes: 3 additions & 3 deletions core/src/main/scala/caliban/execution/Fragment.scala
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
package caliban.execution

import caliban.Value.{ BooleanValue, IntValue, StringValue }
import caliban.parsing.adt.Directive
import caliban.parsing.adt.{ Directive, Directives }

case class Fragment(name: Option[String], directives: List[Directive]) {}

object Fragment {
object IsDeferred {
def unapply(fragment: Fragment): Option[Option[String]] =
fragment.directives.collectFirst {
case Directive("defer", args, _) if args.get("if").forall {
case Directive(Directives.Defer, args, _, _) if args.get("if").forall {
case BooleanValue(v) => v
case _ => true
} =>
Expand All @@ -20,7 +20,7 @@ object Fragment {

object IsStream {
def unapply(field: Field): Option[(Option[String], Option[Int])] =
field.directives.collectFirst { case Directive("stream", args, _) =>
field.directives.collectFirst { case Directive(Directives.Stream, args, _, _) =>
(
args.get("label").collect { case StringValue(v) => v },
args.get("initialCount").collect { case v: IntValue => v.toInt }
Expand Down
4 changes: 3 additions & 1 deletion core/src/main/scala/caliban/introspection/adt/__Field.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package caliban.introspection.adt

import caliban.Value.StringValue
import caliban.parsing.adt.Definition.TypeSystemDefinition.TypeDefinition.{ FieldDefinition, InputValueDefinition }
import caliban.parsing.adt.Directive
import caliban.parsing.adt.{ Directive, Directives }
import caliban.schema.Annotations.GQLExcluded

case class __Field(
Expand Down Expand Up @@ -34,6 +34,8 @@ case class __Field(
lazy val allArgs: List[__InputValue] =
args(__DeprecatedArgs.include)

private[caliban] lazy val _tags: Set[String] = directives.fold(Set.empty[String])(Directives.internal.tags)

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

private[caliban] lazy val allArgNames: Set[String] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package caliban.introspection.adt

import caliban.Value.StringValue
import caliban.parsing.adt.Definition.TypeSystemDefinition.TypeDefinition.InputValueDefinition
import caliban.parsing.adt.Directive
import caliban.parsing.adt.{ Directive, Directives }
import caliban.parsing.Parser
import caliban.schema.Annotations.GQLExcluded

Expand All @@ -18,6 +18,7 @@ case class __InputValue(
@GQLExcluded directives: Option[List[Directive]] = None,
@GQLExcluded parentType: () => Option[__Type] = () => None
) {

def toInputValueDefinition: InputValueDefinition = {
val default = defaultValue.flatMap(v => Parser.parseInputValue(v).toOption)
val allDirectives = (if (isDeprecated)
Expand All @@ -31,6 +32,8 @@ case class __InputValue(
InputValueDefinition(description, name, _type.toType(), default, allDirectives)
}

private[caliban] lazy val _tags: Set[String] = directives.fold(Set.empty[String])(Directives.internal.tags)

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

Expand Down
13 changes: 7 additions & 6 deletions core/src/main/scala/caliban/introspection/adt/__Type.scala
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,13 @@ case class __Type(
Some(
ScalarTypeDefinition(
description,
name.getOrElse(""),
directives
.getOrElse(Nil) ++
specifiedBy
.map(url => Directive("specifiedBy", Map("url" -> StringValue(url)), directives.size))
.toList
name.getOrElse(""), {
val dirs = directives.getOrElse(Nil)
dirs ++
specifiedBy
.map(url => Directive("specifiedBy", Map("url" -> StringValue(url)), dirs.size))
.toList
}
)
)
case __TypeKind.OBJECT =>
Expand Down
21 changes: 19 additions & 2 deletions core/src/main/scala/caliban/parsing/adt/Directive.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,31 @@ package caliban.parsing.adt

import caliban.{ InputValue, Value }

case class Directive(name: String, arguments: Map[String, InputValue] = Map.empty, index: Int = 0)
case class Directive(
name: String,
arguments: Map[String, InputValue] = Map.empty,
index: Int = 0,
isIntrospectable: Boolean = true
)

object Directives {

final val Defer = "defer"
final val DeprecatedDirective = "deprecated"
final val LazyDirective = "lazy"
final val NewtypeDirective = "newtype"
final val DeprecatedDirective = "deprecated"
final val OneOf = "oneOf"
final val Stream = "stream"

// We prefix these with `_caliban_tag_` to avoid conflicts with user-defined directives
private[caliban] object internal {
final val Tag = "_caliban_tag"

def tags(directives: List[Directive]): Set[String] =
directives.collect { case Directive(Tag, args, _, _) if args.nonEmpty => args.keySet }
.foldLeft(Set.empty[String])(_ ++ _)

}

def isDeprecated(directives: List[Directive]): Boolean =
directives.exists(_.name == DeprecatedDirective)
Expand Down
13 changes: 8 additions & 5 deletions core/src/main/scala/caliban/rendering/DocumentRenderer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -572,11 +572,14 @@ object DocumentRenderer extends Renderer[Document] {
}

private lazy val directiveRenderer: Renderer[Directive] = new Renderer[Directive] {
override def unsafeRender(d: Directive, indent: Option[Int], writer: StringBuilder): Unit = {
writer append '@'
writer append d.name
inputArgumentsRenderer.unsafeRender(d.arguments, indent, writer)
}
override def unsafeRender(d: Directive, indent: Option[Int], writer: StringBuilder): Unit =
if (d.isIntrospectable) {
writer append '@'
writer append d.name
inputArgumentsRenderer.unsafeRender(d.arguments, indent, writer)
} else {
writer.deleteCharAt(writer.size - 1)
kyri-petrou marked this conversation as resolved.
Show resolved Hide resolved
}
}

private lazy val fieldDefinitionsRenderer: Renderer[List[FieldDefinition]] =
Expand Down
15 changes: 14 additions & 1 deletion core/src/main/scala/caliban/schema/Annotations.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package caliban.schema

import caliban.parsing.adt.Directive
import caliban.{ InputValue, Value }
import caliban.parsing.adt.{ Directive, Directives }

import scala.annotation.StaticAnnotation

Expand Down Expand Up @@ -81,4 +82,16 @@ object Annotations extends AnnotationsVersionSpecific {
*/
case class GQLOneOfInput() extends StaticAnnotation

/**
* Compile-time annotation that can be used in conjunction with [[caliban.transformers.Transformer]] to
* customize schema generation.
*/
case class GQLTag(tags: String*)
kyri-petrou marked this conversation as resolved.
Show resolved Hide resolved
extends GQLDirective(
Directive(
Directives.internal.Tag,
tags.map(_ -> Value.NullValue).toMap,
isIntrospectable = false
)
)
}
59 changes: 56 additions & 3 deletions core/src/main/scala/caliban/transformers/Transformer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ abstract class Transformer[-R] { self =>
* Set of type names that this transformer applies to.
* Needed for applying optimizations when combining transformers.
*/
protected val typeNames: collection.Set[String]
protected def typeNames: collection.Set[String]

protected def transformStep[R1 <: R](step: ObjectStep[R1], field: Field): ObjectStep[R1]

Expand Down Expand Up @@ -326,20 +326,73 @@ object Transformer {
}
}

object ExcludeTags {

/**
* A transformer that allows excluding tagged fields and input arguments.
*
* {{{
* ExcludeTags("TagA", "TagB")
* }}}
*
* @param f tuples in the format of `(TypeName -> fieldToBeExcluded)`
*/
def apply(f: String*): Transformer[Any] =
if (f.isEmpty) Empty else new ExcludeTags(f.toSet)
}

final private class ExcludeTags(tags: Set[String]) extends Transformer[Any] {
private val map: mutable.HashMap[String, Set[String]] = mutable.HashMap.empty

private def shouldKeep(tpe: __Type, field: __Field): Boolean = {
val keep = field._tags.intersect(tags).isEmpty
if (!keep) map.updateWith(tpe.name.getOrElse("")) {
case Some(set) => Some(set + field.name)
case None => Some(Set(field.name))
}
keep
}

val typeVisitor: TypeVisitor =
TypeVisitor.fields.filterWith((t, field) => shouldKeep(t, field)) |+|
TypeVisitor.fields.modify { field =>
def loop(arg: __InputValue): Option[__InputValue] =
if (arg._type.isNullable && arg._tags.intersect(tags).nonEmpty) None
else {
lazy val newType = arg._type.mapInnerType { t =>
t.copy(inputFields = t.inputFields(_).map(_.flatMap(loop)))
}
Some(arg.copy(`type` = () => newType))
}

field.copy(args = field.args(_).flatMap(loop))
}

protected def typeNames: collection.Set[String] = map.keySet

protected def transformStep[R](step: ObjectStep[R], field: Field): ObjectStep[R] =
map.getOrElse(step.name, null) match {
case null => step
case excl => step.copy(fields = name => if (!excl(name)) step.fields(name) else NullStep)
}
}

final private class Combined[-R](left: Transformer[R], right: Transformer[R]) extends Transformer[R] {
val typeVisitor: TypeVisitor = left.typeVisitor |+| right.typeVisitor

protected val typeNames: mutable.HashSet[String] = {
protected def typeNames: mutable.HashSet[String] = {
val set = mutable.HashSet.from(left.typeNames)
set ++= right.typeNames
set
}

private lazy val materializedTypeNames = typeNames

protected def transformStep[R1 <: R](step: ObjectStep[R1], field: Field): ObjectStep[R1] =
right.transformStep(left.transformStep(step, field), field)

override def apply[R1 <: R](step: ObjectStep[R1], field: Field): ObjectStep[R1] =
if (typeNames(step.name)) transformStep(step, field) else step
if (materializedTypeNames(step.name)) transformStep(step, field) else step
}

private def mapFunctionStep[R](step: Step[R])(f: Map[String, InputValue] => Map[String, InputValue]): Step[R] =
Expand Down
3 changes: 2 additions & 1 deletion core/src/main/scala/caliban/wrappers/DeferSupport.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ package caliban.wrappers

import caliban.execution.Feature
import caliban.introspection.adt.{ __Directive, __DirectiveLocation, __InputValue }
import caliban.parsing.adt.Directives
import caliban.schema.Types
import caliban.{ GraphQL, GraphQLAspect }

object DeferSupport {
private[caliban] val deferDirective = __Directive(
"defer",
Directives.Defer,
Some(""),
Set(__DirectiveLocation.FRAGMENT_SPREAD, __DirectiveLocation.INLINE_FRAGMENT),
_ =>
Expand Down
15 changes: 15 additions & 0 deletions core/src/test/scala/caliban/RenderingSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,21 @@ object RenderingSpec extends ZIOSpecDefault {
val renderedType = DocumentRenderer.typesRenderer.render(List(testType)).trim
assertTrue(renderedType == "type TestType @testdirective(object: {key1: \"value1\", key2: \"value2\"})")
},
test("only introspectable directives are rendered") {
val all = List(
Directive("d0", isIntrospectable = false),
Directive("d1"),
Directive("d2", isIntrospectable = false),
Directive("d3"),
Directive("d4"),
Directive("d5", isIntrospectable = false),
Directive("d6", isIntrospectable = false)
)
val filtered = all.filter(_.isIntrospectable)
val renderedAll = DocumentRenderer.directivesRenderer.render(all)
val renderedFiltered = DocumentRenderer.directivesRenderer.render(filtered)
assertTrue(renderedAll == renderedFiltered, renderedAll == " @d1 @d3 @d4")
},
test(
"it should escape \", \\, backspace, linefeed, carriage-return and tab inside a normally quoted description string"
) {
Expand Down
Loading