Skip to content

Commit 7078afb

Browse files
authored
Add transformer that excludes fields / inputs based on directives (#2293)
* Add transformer that excludes fields / inputs based on tags * Fix scaladoc * Fix Scala 3 derivation * Define tags as non-introspectable directives * Change transformer to work on directives instead * Fix for Scala 3 * PR comment * Add overload of `ExcludeDirectives.apply` that takes a predicate
1 parent 1180ee6 commit 7078afb

File tree

13 files changed

+291
-30
lines changed

13 files changed

+291
-30
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
11
package caliban.schema
22

3-
trait AnnotationsVersionSpecific
3+
import caliban.parsing.adt.Directive
4+
5+
import scala.annotation.StaticAnnotation
6+
7+
trait AnnotationsVersionSpecific {
8+
9+
/**
10+
* Annotation used to provide directives to a schema type
11+
*/
12+
class GQLDirective(val directive: Directive) extends StaticAnnotation
13+
14+
object GQLDirective {
15+
def unapply(annotation: GQLDirective): Option[Directive] =
16+
Some(annotation.directive)
17+
}
18+
19+
}

core/src/main/scala-2/caliban/schema/SchemaDerivation.scala

+1
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ trait CommonSchemaDerivation[R] {
286286

287287
private def getDescription[Typeclass[_], Type](ctx: ReadOnlyParam[Typeclass, Type]): Option[String] =
288288
getDescription(ctx.annotations)
289+
289290
}
290291

291292
trait SchemaDerivation[R] extends CommonSchemaDerivation[R] {

core/src/main/scala-3/caliban/schema/AnnotationsVersionSpecific.scala

+12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package caliban.schema
22

3+
import caliban.parsing.adt.Directive
4+
35
import scala.annotation.StaticAnnotation
46

57
trait AnnotationsVersionSpecific {
@@ -21,4 +23,14 @@ trait AnnotationsVersionSpecific {
2123
*/
2224
case class GQLFieldsFromMethods() extends StaticAnnotation
2325

26+
/**
27+
* Annotation used to provide directives to a schema type
28+
*/
29+
open class GQLDirective(val directive: Directive) extends StaticAnnotation
30+
31+
object GQLDirective {
32+
def unapply(annotation: GQLDirective): Option[Directive] =
33+
Some(annotation.directive)
34+
}
35+
2436
}

core/src/main/scala/caliban/execution/Fragment.scala

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
package caliban.execution
22

33
import caliban.Value.{ BooleanValue, IntValue, StringValue }
4-
import caliban.parsing.adt.Directive
4+
import caliban.parsing.adt.{ Directive, Directives }
55

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

88
object Fragment {
99
object IsDeferred {
1010
def unapply(fragment: Fragment): Option[Option[String]] =
1111
fragment.directives.collectFirst {
12-
case Directive("defer", args, _) if args.get("if").forall {
12+
case Directive(Directives.Defer, args, _, _) if args.get("if").forall {
1313
case BooleanValue(v) => v
1414
case _ => true
1515
} =>
@@ -20,7 +20,7 @@ object Fragment {
2020

2121
object IsStream {
2222
def unapply(field: Field): Option[(Option[String], Option[Int])] =
23-
field.directives.collectFirst { case Directive("stream", args, _) =>
23+
field.directives.collectFirst { case Directive(Directives.Stream, args, _, _) =>
2424
(
2525
args.get("label").collect { case StringValue(v) => v },
2626
args.get("initialCount").collect { case v: IntValue => v.toInt }

core/src/main/scala/caliban/introspection/adt/__InputValue.scala

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
package caliban.introspection.adt
22

33
import caliban.Value.StringValue
4+
import caliban.parsing.Parser
45
import caliban.parsing.adt.Definition.TypeSystemDefinition.TypeDefinition.InputValueDefinition
56
import caliban.parsing.adt.Directive
6-
import caliban.parsing.Parser
77
import caliban.schema.Annotations.GQLExcluded
88

99
import scala.annotation.tailrec

core/src/main/scala/caliban/introspection/adt/__Type.scala

+7-6
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,13 @@ case class __Type(
6060
Some(
6161
ScalarTypeDefinition(
6262
description,
63-
name.getOrElse(""),
64-
directives
65-
.getOrElse(Nil) ++
66-
specifiedBy
67-
.map(url => Directive("specifiedBy", Map("url" -> StringValue(url)), directives.size))
68-
.toList
63+
name.getOrElse(""), {
64+
val dirs = directives.getOrElse(Nil)
65+
dirs ++
66+
specifiedBy
67+
.map(url => Directive("specifiedBy", Map("url" -> StringValue(url)), dirs.size))
68+
.toList
69+
}
6970
)
7071
)
7172
case __TypeKind.OBJECT =>

core/src/main/scala/caliban/parsing/adt/Directive.scala

+9-2
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,21 @@ package caliban.parsing.adt
22

33
import caliban.{ InputValue, Value }
44

5-
case class Directive(name: String, arguments: Map[String, InputValue] = Map.empty, index: Int = 0)
5+
case class Directive(
6+
name: String,
7+
arguments: Map[String, InputValue] = Map.empty,
8+
index: Int = 0,
9+
isIntrospectable: Boolean = true
10+
)
611

712
object Directives {
813

14+
final val Defer = "defer"
15+
final val DeprecatedDirective = "deprecated"
916
final val LazyDirective = "lazy"
1017
final val NewtypeDirective = "newtype"
11-
final val DeprecatedDirective = "deprecated"
1218
final val OneOf = "oneOf"
19+
final val Stream = "stream"
1320

1421
def isDeprecated(directives: List[Directive]): Boolean =
1522
directives.exists(_.name == DeprecatedDirective)

core/src/main/scala/caliban/rendering/DocumentRenderer.scala

+3-1
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ object DocumentRenderer extends Renderer[Document] {
7676
typeDefinitionsRenderer.contramap(_.flatMap(_.toTypeDefinition))
7777

7878
private[caliban] lazy val directivesRenderer: Renderer[List[Directive]] =
79-
directiveRenderer.list(Renderer.spaceOrEmpty, omitFirst = false).contramap(_.sortBy(_.name))
79+
directiveRenderer
80+
.list(Renderer.spaceOrEmpty, omitFirst = false)
81+
.contramap(_.filter(_.isIntrospectable).sortBy(_.name))
8082

8183
private[caliban] lazy val descriptionRenderer: Renderer[Option[String]] =
8284
new Renderer[Option[String]] {

core/src/main/scala/caliban/schema/Annotations.scala

+2-12
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package caliban.schema
22

3-
import caliban.parsing.adt.Directive
3+
import caliban.{ InputValue, Value }
4+
import caliban.parsing.adt.{ Directive, Directives }
45

56
import scala.annotation.StaticAnnotation
67

@@ -32,16 +33,6 @@ object Annotations extends AnnotationsVersionSpecific {
3233
*/
3334
case class GQLName(value: String) extends StaticAnnotation
3435

35-
/**
36-
* Annotation used to provide directives to a schema type
37-
*/
38-
class GQLDirective(val directive: Directive) extends StaticAnnotation
39-
40-
object GQLDirective {
41-
def unapply(annotation: GQLDirective): Option[Directive] =
42-
Some(annotation.directive)
43-
}
44-
4536
/**
4637
* Annotation to make a sealed trait an interface instead of a union type or an enum
4738
*
@@ -80,5 +71,4 @@ object Annotations extends AnnotationsVersionSpecific {
8071
* Annotation to make a sealed trait as a GraphQL @oneOf input
8172
*/
8273
case class GQLOneOfInput() extends StaticAnnotation
83-
8474
}

core/src/main/scala/caliban/transformers/Transformer.scala

+72-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package caliban.transformers
33
import caliban.InputValue
44
import caliban.execution.Field
55
import caliban.introspection.adt._
6+
import caliban.parsing.adt.Directive
7+
import caliban.schema.Annotations.GQLDirective
68
import caliban.schema.Step
79
import caliban.schema.Step.{ FunctionStep, MetadataFunctionStep, NullStep, ObjectStep }
810

@@ -19,7 +21,7 @@ abstract class Transformer[-R] { self =>
1921
* Set of type names that this transformer applies to.
2022
* Needed for applying optimizations when combining transformers.
2123
*/
22-
protected val typeNames: collection.Set[String]
24+
protected def typeNames: collection.Set[String]
2325

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

@@ -326,20 +328,87 @@ object Transformer {
326328
}
327329
}
328330

331+
object ExcludeDirectives {
332+
333+
/**
334+
* A transformer that allows excluding fields and inputs with specific directives.
335+
*
336+
* {{{
337+
* case object Experimental extends GQLDirective(Directive("experimental"))
338+
* case object Internal extends GQLDirective(Directive("internal"))
339+
*
340+
* ExcludeDirectives(Experimental, Internal)
341+
* }}}
342+
*/
343+
def apply(directives: GQLDirective*): Transformer[Any] =
344+
if (directives.isEmpty) Empty else new ExcludeDirectives(directives.map(_.directive).toSet.contains)
345+
346+
/**
347+
* A transformer that allows excluding fields and inputs with specific directives based on a predicate.
348+
*/
349+
def apply(predicate: Directive => Boolean): Transformer[Any] =
350+
new ExcludeDirectives(predicate)
351+
352+
}
353+
354+
final private class ExcludeDirectives(predicate: Directive => Boolean) extends Transformer[Any] {
355+
private val map: mutable.HashMap[String, Set[String]] = mutable.HashMap.empty
356+
357+
private def hasMatchingDirectives(directives: Option[List[Directive]]): Boolean =
358+
directives match {
359+
case None | Some(Nil) => false
360+
case Some(dirs) => dirs.exists(predicate)
361+
}
362+
363+
private def shouldKeepType(tpe: __Type, field: __Field): Boolean = {
364+
val matched = hasMatchingDirectives(field.directives)
365+
if (matched) map.updateWith(tpe.name.getOrElse("")) {
366+
case Some(set) => Some(set + field.name)
367+
case None => Some(Set(field.name))
368+
}
369+
!matched
370+
}
371+
372+
val typeVisitor: TypeVisitor =
373+
TypeVisitor.fields.filterWith((t, field) => shouldKeepType(t, field)) |+|
374+
TypeVisitor.fields.modify { field =>
375+
def loop(arg: __InputValue): Option[__InputValue] =
376+
if (arg._type.isNullable && hasMatchingDirectives(arg.directives)) None
377+
else {
378+
lazy val newType = arg._type.mapInnerType { t =>
379+
t.copy(inputFields = t.inputFields(_).map(_.flatMap(loop)))
380+
}
381+
Some(arg.copy(`type` = () => newType))
382+
}
383+
384+
field.copy(args = field.args(_).flatMap(loop))
385+
}
386+
387+
protected def typeNames: collection.Set[String] = map.keySet
388+
389+
protected def transformStep[R](step: ObjectStep[R], field: Field): ObjectStep[R] =
390+
map.getOrElse(step.name, null) match {
391+
case null => step
392+
case excl => step.copy(fields = name => if (!excl(name)) step.fields(name) else NullStep)
393+
}
394+
}
395+
329396
final private class Combined[-R](left: Transformer[R], right: Transformer[R]) extends Transformer[R] {
330397
val typeVisitor: TypeVisitor = left.typeVisitor |+| right.typeVisitor
331398

332-
protected val typeNames: mutable.HashSet[String] = {
399+
protected def typeNames: mutable.HashSet[String] = {
333400
val set = mutable.HashSet.from(left.typeNames)
334401
set ++= right.typeNames
335402
set
336403
}
337404

405+
private lazy val materializedTypeNames = typeNames
406+
338407
protected def transformStep[R1 <: R](step: ObjectStep[R1], field: Field): ObjectStep[R1] =
339408
right.transformStep(left.transformStep(step, field), field)
340409

341410
override def apply[R1 <: R](step: ObjectStep[R1], field: Field): ObjectStep[R1] =
342-
if (typeNames(step.name)) transformStep(step, field) else step
411+
if (materializedTypeNames(step.name)) transformStep(step, field) else step
343412
}
344413

345414
private def mapFunctionStep[R](step: Step[R])(f: Map[String, InputValue] => Map[String, InputValue]): Step[R] =

core/src/main/scala/caliban/wrappers/DeferSupport.scala

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ package caliban.wrappers
22

33
import caliban.execution.Feature
44
import caliban.introspection.adt.{ __Directive, __DirectiveLocation, __InputValue }
5+
import caliban.parsing.adt.Directives
56
import caliban.schema.Types
67
import caliban.{ GraphQL, GraphQLAspect }
78

89
object DeferSupport {
910
private[caliban] val deferDirective = __Directive(
10-
"defer",
11+
Directives.Defer,
1112
Some(""),
1213
Set(__DirectiveLocation.FRAGMENT_SPREAD, __DirectiveLocation.INLINE_FRAGMENT),
1314
_ =>

core/src/test/scala/caliban/RenderingSpec.scala

+15
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,21 @@ object RenderingSpec extends ZIOSpecDefault {
132132
val renderedType = DocumentRenderer.typesRenderer.render(List(testType)).trim
133133
assertTrue(renderedType == "type TestType @testdirective(object: {key1: \"value1\", key2: \"value2\"})")
134134
},
135+
test("only introspectable directives are rendered") {
136+
val all = List(
137+
Directive("d0", isIntrospectable = false),
138+
Directive("d1"),
139+
Directive("d2", isIntrospectable = false),
140+
Directive("d3"),
141+
Directive("d4"),
142+
Directive("d5", isIntrospectable = false),
143+
Directive("d6", isIntrospectable = false)
144+
)
145+
val filtered = all.filter(_.isIntrospectable)
146+
val renderedAll = DocumentRenderer.directivesRenderer.render(all)
147+
val renderedFiltered = DocumentRenderer.directivesRenderer.render(filtered)
148+
assertTrue(renderedAll == renderedFiltered, renderedAll == " @d1 @d3 @d4")
149+
},
135150
test(
136151
"it should escape \", \\, backspace, linefeed, carriage-return and tab inside a normally quoted description string"
137152
) {

0 commit comments

Comments
 (0)