Skip to content

Commit

Permalink
Support for repeatable directives
Browse files Browse the repository at this point in the history
  • Loading branch information
OlegIlyenko committed Oct 7, 2018
1 parent 65ffe26 commit 764154b
Show file tree
Hide file tree
Showing 26 changed files with 369 additions and 150 deletions.
3 changes: 2 additions & 1 deletion src/main/scala/sangria/ast/QueryAst.scala
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ case class DirectiveDefinition(
arguments: Vector[InputValueDefinition],
locations: Vector[DirectiveLocation],
description: Option[StringValue] = None,
repeatable: Boolean = false,
comments: Vector[Comment] = Vector.empty,
location: Option[AstLocation] = None) extends TypeSystemDefinition with WithDescription

Expand Down Expand Up @@ -911,7 +912,7 @@ object AstVisitor {
tc.foreach(c loop(c))
breakOrSkip(onLeave(n))
}
case n @ DirectiveDefinition(_, args, locations, description, comment, _)
case n @ DirectiveDefinition(_, args, locations, description, _, comment, _)
if (breakOrSkip(onEnter(n))) {
args.foreach(d loop(d))
locations.foreach(d loop(d))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ object IntrospectionParser {
name = mapStringField(directive, "name", path),
description = mapStringFieldOpt(directive, "description"),
locations = um.getListValue(mapField(directive, "locations")).map(v DirectiveLocation.fromString(stringValue(v, path :+ "locations"))).toSet,
args = mapFieldOpt(directive, "args") map um.getListValue getOrElse Vector.empty map (arg parseInputValue(arg, path :+ "args")))
args = mapFieldOpt(directive, "args") map um.getListValue getOrElse Vector.empty map (arg parseInputValue(arg, path :+ "args")),
repeatable = mapBooleanFieldOpt(directive, "isRepeatable") getOrElse false)

private def parseType[In : InputUnmarshaller](tpe: In, path: Vector[String]) =
mapStringField(tpe, "kind", path) match {
Expand Down Expand Up @@ -148,11 +149,14 @@ object IntrospectionParser {
private def mapBooleanField[In : InputUnmarshaller](map: In, name: String, path: Vector[String] = Vector.empty): Boolean =
booleanValue(mapField(map, name, path), path :+ name)

private def mapBooleanFieldOpt[In : InputUnmarshaller](map: In, name: String, path: Vector[String] = Vector.empty): Option[Boolean] =
mapFieldOpt(map, name) filter um.isDefined map (booleanValue(_, path :+ name))

private def mapFieldOpt[In : InputUnmarshaller](map: In, name: String): Option[In] =
um.getMapValue(map, name) filter um.isDefined

private def mapStringFieldOpt[In : InputUnmarshaller](map: In, name: String, path: Vector[String] = Vector.empty): Option[String] =
mapFieldOpt(map, name) filter um.isDefined map (s stringValue(s, path :+ name) )
mapFieldOpt(map, name) filter um.isDefined map (stringValue(_, path :+ name))

private def um[T: InputUnmarshaller] = implicitly[InputUnmarshaller[T]]

Expand Down
3 changes: 2 additions & 1 deletion src/main/scala/sangria/introspection/model.scala
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,5 @@ case class IntrospectionDirective(
name: String,
description: Option[String],
locations: Set[DirectiveLocation.Value],
args: Seq[IntrospectionInputValue])
args: Seq[IntrospectionInputValue],
repeatable: Boolean)
11 changes: 7 additions & 4 deletions src/main/scala/sangria/introspection/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,9 @@ package object introspection {
Field("name", StringType, resolve = _.value.name),
Field("description", OptionType(StringType), resolve = _.value.description),
Field("locations", ListType(__DirectiveLocation), resolve = _.value.locations.toVector.sorted),
Field("args", ListType(__InputValue), resolve = _.value.arguments)))
Field("args", ListType(__InputValue), resolve = _.value.arguments),
Field("isRepeatable", BooleanType, Some("Permits using the directive multiple times at the same location."),
resolve = _.value.repeatable)))

val __Schema = ObjectType(
name = "__Schema",
Expand Down Expand Up @@ -309,10 +311,10 @@ package object introspection {

def introspectionQuery: ast.Document = introspectionQuery()

def introspectionQuery(schemaDescription: Boolean = true): ast.Document =
QueryParser.parse(introspectionQueryString(schemaDescription))
def introspectionQuery(schemaDescription: Boolean = true, directiveRepeatableFlag: Boolean = true): ast.Document =
QueryParser.parse(introspectionQueryString(schemaDescription, directiveRepeatableFlag))

def introspectionQueryString(schemaDescription: Boolean = true): String =
def introspectionQueryString(schemaDescription: Boolean = true, directiveRepeatableFlag: Boolean = true): String =
s"""query IntrospectionQuery {
| __schema {
| queryType { name }
Expand All @@ -328,6 +330,7 @@ package object introspection {
| args {
| ...InputValue
| }
| ${if (directiveRepeatableFlag) "isRepeatable" else ""}
| }
| ${if (schemaDescription) "description" else ""}
| }
Expand Down
4 changes: 2 additions & 2 deletions src/main/scala/sangria/macros/AstLiftable.scala
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ trait AstLiftable {
case FragmentDefinition(n, t, d, s, v, c, tc, p)
q"_root_.sangria.ast.FragmentDefinition($n, $t, $d, $s, $v, $c, $tc, $p)"

case DirectiveDefinition(n, a, l, desc, c, p)
q"_root_.sangria.ast.DirectiveDefinition($n, $a, $l, $desc, $c, $p)"
case DirectiveDefinition(n, a, l, desc, r, c, p)
q"_root_.sangria.ast.DirectiveDefinition($n, $a, $l, $desc, $r, $c, $p)"
case SchemaDefinition(o, d, desc, c, tc, p)
q"_root_.sangria.ast.SchemaDefinition($o, $d, $desc, $c, $tc, $p)"

Expand Down
6 changes: 4 additions & 2 deletions src/main/scala/sangria/parser/QueryParser.scala
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,11 @@ trait TypeSystemDefinitions { this: Parser with Tokens with Ignored with Directi
wsNoComment('{') ~ (test(legacyEmptyFields) ~ InputValueDefinition.* | InputValueDefinition.+) ~ Comments ~ wsNoComment('}') ~> (_ _)
}

def repeatable = rule { capture(Keyword("repeatable")).? ~> (_.isDefined)}

def DirectiveDefinition = rule {
Description ~ Comments ~ trackPos ~ directive ~ '@' ~ NameStrict ~ (ArgumentsDefinition.? ~> (_ getOrElse Vector.empty)) ~ on ~ DirectiveLocations ~> (
(descr, comment, location, name, args, locations) ast.DirectiveDefinition(name, args, locations, descr, comment, location))
Description ~ Comments ~ trackPos ~ directive ~ '@' ~ NameStrict ~ (ArgumentsDefinition.? ~> (_ getOrElse Vector.empty)) ~ repeatable ~ on ~ DirectiveLocations ~> (
(descr, comment, location, name, args, rep, locations) ast.DirectiveDefinition(name, args, locations, descr, rep, comment, location))
}

def DirectiveLocations = rule { ws('|').? ~ DirectiveLocation.+(wsNoComment('|')) ~> (_.toVector) }
Expand Down
6 changes: 4 additions & 2 deletions src/main/scala/sangria/renderer/QueryRenderer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ object QueryRenderer {
renderDirs(dirs, config, indent, frontSep = true) +
renderOperationTypeDefinitions(ops, ext, indent, config, frontSep = true)

case dd @ DirectiveDefinition(name, args, locations, description, _, _)
case dd @ DirectiveDefinition(name, args, locations, description, rep, _, _)
val locsRendered = locations.zipWithIndex map { case (l, idx)
(if (idx != 0 && shouldRenderComment(l, None, config)) config.lineBreak else "") +
(if (shouldRenderComment(l, None, config)) config.lineBreak else if (idx != 0) config.separator else "") +
Expand All @@ -568,7 +568,9 @@ object QueryRenderer {
renderComment(dd, description orElse prev, indent, config) +
indent.str + "directive" + config.separator + "@" + name +
renderInputValueDefs(args, indent, config) + (if (args.isEmpty) config.mandatorySeparator else "") +
"on" + (if (shouldRenderComment(locations.head, None, config)) "" else config.mandatorySeparator) +
(if (rep) "repeatable" + config.mandatorySeparator else "") +
"on" +
(if (shouldRenderComment(locations.head, None, config)) "" else config.mandatorySeparator) +
locsRendered.mkString(config.separator + "|")

case dl @ DirectiveLocation(name, _, _)
Expand Down
4 changes: 2 additions & 2 deletions src/main/scala/sangria/renderer/SchemaRenderer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -214,10 +214,10 @@ object SchemaRenderer {
ast.DirectiveLocation(__DirectiveLocation.byValue(loc).name)

def renderDirective(dir: Directive) =
ast.DirectiveDefinition(dir.name, renderArgs(dir.arguments), dir.locations.toVector.map(renderDirectiveLocation).sortBy(_.name), renderDescription(dir.description))
ast.DirectiveDefinition(dir.name, renderArgs(dir.arguments), dir.locations.toVector.map(renderDirectiveLocation).sortBy(_.name), renderDescription(dir.description), dir.repeatable)

def renderDirective(dir: IntrospectionDirective) =
ast.DirectiveDefinition(dir.name, renderArgsI(dir.args), dir.locations.toVector.map(renderDirectiveLocation).sortBy(_.name), renderDescription(dir.description))
ast.DirectiveDefinition(dir.name, renderArgsI(dir.args), dir.locations.toVector.map(renderDirectiveLocation).sortBy(_.name), renderDescription(dir.description), dir.repeatable)

def schemaAstFromIntrospection(introspectionSchema: IntrospectionSchema, filter: SchemaFilter = SchemaFilter.default): ast.Document = {
val schemaDef = if (filter.renderSchema) renderSchemaDefinition(introspectionSchema) else None
Expand Down
1 change: 1 addition & 0 deletions src/main/scala/sangria/schema/AstSchemaBuilder.scala
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,7 @@ class DefaultAstSchemaBuilder[Ctx] extends AstSchemaBuilder[Ctx] {
description = directiveDescription(definition),
locations = locations,
arguments = arguments,
repeatable = definition.repeatable,
shouldInclude = directiveShouldInclude(definition)))

def transformInputObjectType[T](
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ class DefaultIntrospectionSchemaBuilder[Ctx] extends IntrospectionSchemaBuilder[
description = directiveDescription(definition),
locations = definition.locations,
arguments = arguments,
repeatable = definition.repeatable,
shouldInclude = directiveShouldInclude(definition)))

def objectTypeInstanceCheck(definition: IntrospectionObjectType): Option[(Any, Class[_]) Boolean] =
Expand Down
1 change: 1 addition & 0 deletions src/main/scala/sangria/schema/Schema.scala
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,7 @@ case class Directive(
description: Option[String] = None,
arguments: List[Argument[_]] = Nil,
locations: Set[DirectiveLocation.Value] = Set.empty,
repeatable: Boolean = false,
shouldInclude: DirectiveContext Boolean = _ true) extends HasArguments with Named {
def rename(newName: String) = copy(name = newName).asInstanceOf[this.type]
def toAst: ast.DirectiveDefinition = SchemaRenderer.renderDirective(this)
Expand Down
11 changes: 10 additions & 1 deletion src/main/scala/sangria/schema/SchemaComparator.scala
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ object SchemaComparator {
}

private def findInDirective(oldDir: Directive, newDir: Directive): Vector[SchemaChange] = {
val repeatableChanged =
if (oldDir.repeatable != newDir.repeatable)
Vector(SchemaChange.DirectiveRepeatableChanged(newDir, oldDir.repeatable, newDir.repeatable, !newDir.repeatable))
else
Vector.empty

val locationChanges = findInDirectiveLocations(oldDir, newDir)
val fieldChanges = findInArgs(oldDir.arguments, newDir.arguments,
added = SchemaChange.DirectiveArgumentAdded(newDir, _, _),
Expand All @@ -69,7 +75,7 @@ object SchemaComparator {
dirAdded = SchemaChange.DirectiveArgumentAstDirectiveAdded(newDir, _, _),
dirRemoved = SchemaChange.DirectiveArgumentAstDirectiveRemoved(newDir, _, _))

locationChanges ++ fieldChanges
repeatableChanged ++ locationChanges ++ fieldChanges
}

private def findInDirectiveLocations(oldDir: Directive, newDir: Directive): Vector[SchemaChange] = {
Expand Down Expand Up @@ -659,6 +665,9 @@ object SchemaChange {
case class DirectiveArgumentAdded(directive: Directive, argument: Argument[_], breaking: Boolean)
extends AbstractChange(s"Argument `${argument.name}` was added to `${directive.name}` directive", breaking)

case class DirectiveRepeatableChanged(directive: Directive, oldRepeatable: Boolean, newRepeatable: Boolean, breaking: Boolean)
extends AbstractChange(if (newRepeatable) s"Directive `${directive.name}` was made repeatable per location" else s"Directive `${directive.name}` was made unique per location", breaking)

case class InputFieldTypeChanged(tpe: InputObjectType[_], field: InputField[_], breaking: Boolean, oldFiledType: InputType[_], newFieldType: InputType[_])
extends AbstractChange(s"`${tpe.name}.${field.name}` input field type changed from `${SchemaRenderer.renderTypeName(oldFiledType)}` to `${SchemaRenderer.renderTypeName(newFieldType)}`", breaking) with TypeChange

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import scala.collection.mutable.{Map ⇒ MutableMap}
*/
class UniqueDirectivesPerLocation extends ValidationRule {
override def visitor(ctx: ValidationContext) = new AstValidatingVisitor {
val repeatableDirectives = ctx.schema.directivesByName.mapValues(d d.repeatable)

override val onEnter: ValidationVisit = {
// Many different AST nodes may contain directives. Rather than listing
// them all, just listen for entering any node, and check to see if it
Expand All @@ -22,11 +24,13 @@ class UniqueDirectivesPerLocation extends ValidationRule {
val knownDirectives = MutableMap[String, ast.Directive]()

val errors = node.directives.foldLeft(Vector.empty[Violation]) {
case (errors, d) if knownDirectives contains d.name
errors :+ DuplicateDirectiveViolation(d.name, ctx.sourceMapper, knownDirectives(d.name).location.toList ++ d.location.toList )
case (errors, d)
case (es, d) if repeatableDirectives.getOrElse(d.name, true)
es
case (es, d) if knownDirectives contains d.name
es :+ DuplicateDirectiveViolation(d.name, ctx.sourceMapper, knownDirectives(d.name).location.toList ++ d.location.toList )
case (es, d)
knownDirectives(d.name) = d
errors
es
}

if (errors.nonEmpty) Left(errors)
Expand Down
2 changes: 2 additions & 0 deletions src/test/resources/queries/schema-kitchen-sink-pretty.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,6 @@ extend type Foo @onType
"cool skip"
directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
directive @myRepeatableDir(name: String!) repeatable on OBJECT | INTERFACE
directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
4 changes: 4 additions & 0 deletions src/test/resources/queries/schema-kitchen-sink.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ extend type Foo @onType
"cool skip"
directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
directive @myRepeatableDir(name: String!) repeatable on
| OBJECT
| INTERFACE
directive @include(if: Boolean!)
on FIELD
| FRAGMENT_SPREAD
Expand Down
58 changes: 55 additions & 3 deletions src/test/scala/sangria/introspection/IntrospectionSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import org.scalatest.{Matchers, WordSpec}
import sangria.execution.Executor
import sangria.parser.QueryParser
import sangria.schema._
import sangria.macros._
import sangria.util.{DebugUtil, FutureResultSupport}
import sangria.validation.QueryValidator

Expand Down Expand Up @@ -110,6 +111,19 @@ class IntrospectionSpec extends WordSpec with Matchers with FutureResultSupport
"name" "__InputValue",
"ofType" null)))),
"isDeprecated" false,
"deprecationReason" null),
Map(
"name" "isRepeatable",
"description" "Permits using the directive multiple times at the same location.",
"args" Vector.empty,
"type" Map(
"kind" "NON_NULL",
"name" null,
"ofType" Map(
"kind" "SCALAR",
"name" "Boolean",
"ofType" null)),
"isDeprecated" false,
"deprecationReason" null)),
"inputFields" null,
"interfaces" Vector.empty,
Expand Down Expand Up @@ -749,7 +763,8 @@ class IntrospectionSpec extends WordSpec with Matchers with FutureResultSupport
"kind" "SCALAR",
"name" "Boolean",
"ofType" null)),
"defaultValue" null))),
"defaultValue" null)),
"isRepeatable" false),
Map(
"name" "skip",
"description" "Directs the executor to skip this field or fragment when the `if` argument is true.",
Expand All @@ -768,7 +783,8 @@ class IntrospectionSpec extends WordSpec with Matchers with FutureResultSupport
"kind" "SCALAR",
"name" "Boolean",
"ofType" null)),
"defaultValue" null))),
"defaultValue" null)),
"isRepeatable" false),
Map(
"name" "deprecated",
"description" "Marks an element of a GraphQL schema as no longer supported.",
Expand All @@ -783,10 +799,46 @@ class IntrospectionSpec extends WordSpec with Matchers with FutureResultSupport
"kind" "SCALAR",
"name" "String",
"ofType" null),
"defaultValue" "\"No longer supported\"")))),
"defaultValue" "\"No longer supported\"")),
"isRepeatable" false)),
"description" null))))
}

"includes repeatable flag on directives" in {
val testType = ObjectType("TestType", fields[Unit, Unit](Field("foo", OptionType(StringType), resolve = _ None)))
val repeatableDirective = Directive("test", repeatable = true, locations = Set(DirectiveLocation.Object, DirectiveLocation.Interface))
val schema = Schema(testType, directives = repeatableDirective :: BuiltinDirectives)

val query =
gql"""
{
__schema {
directives {
name
isRepeatable
}
}
}
"""

Executor.execute(schema, query).await should be (Map(
"data" Map(
"__schema" Map(
"directives" Vector(
Map(
"name" "test",
"isRepeatable" true),
Map(
"name" "include",
"isRepeatable" false),
Map(
"name" "skip",
"isRepeatable" false),
Map(
"name" "deprecated",
"isRepeatable" false))))))
}

"introspects on input object" in {
val inputType = InputObjectType("TestInputObject", List(
InputField("a", OptionInputType(StringType), defaultValue = "foo"),
Expand Down
2 changes: 2 additions & 0 deletions src/test/scala/sangria/macros/LiteralMacroSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,7 @@ class LiteralMacroSpec extends WordSpec with Matchers {
DirectiveLocation("FRAGMENT_SPREAD", Vector.empty, None),
DirectiveLocation("INLINE_FRAGMENT", Vector.empty, None)),
None,
false,
Vector.empty,
None
),
Expand All @@ -1038,6 +1039,7 @@ class LiteralMacroSpec extends WordSpec with Matchers {
DirectiveLocation("FRAGMENT_SPREAD", Vector.empty, None),
DirectiveLocation("INLINE_FRAGMENT", Vector.empty, None)),
None,
false,
Vector.empty,
None
)),
Expand Down
1 change: 1 addition & 0 deletions src/test/scala/sangria/parser/QueryParserSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1373,6 +1373,7 @@ class QueryParserSpec extends WordSpec with Matchers with StringMatchers {
DirectiveLocation("FRAGMENT_SPREAD", Vector.empty, Some(AstLocation(76, 4, 13))),
DirectiveLocation("INLINE_FRAGMENT", Vector.empty, Some(AstLocation(104, 5, 13)))),
None,
false,
Vector.empty,
Some(AstLocation(9, 2, 9))
)),
Expand Down
Loading

0 comments on commit 764154b

Please sign in to comment.