From 78965918361e217037219add33173696e86ecfe1 Mon Sep 17 00:00:00 2001 From: Paul Daniels Date: Wed, 23 Aug 2023 17:05:47 +0800 Subject: [PATCH] Add fast rendering (#1835) * checkpoint * Support fast document rendering * checkpoint 2 * checkpoint 3 - core tests passing * checkpoint 4 - add compact rendering * checkpoint 5 - Add value renderer, cleanup * checkpoint 6 - delete previous implementation * checkpoint 7 - cleanup * checkpoint 9 - more polish * fix scala 3 * fix compact rendering of queries * fix rendering order * address comments * fix mima * use type renderer * minor polish, make methods private --- .../scala/caliban/GraphQLBenchmarks.scala | 14 +- core/src/main/scala/caliban/GraphQL.scala | 33 +- core/src/main/scala/caliban/Rendering.scala | 255 +------ core/src/main/scala/caliban/Value.scala | 16 +- .../main/scala/caliban/execution/Field.scala | 45 ++ core/src/main/scala/caliban/package.scala | 4 +- .../scala/caliban/parsing/SourceMapper.scala | 4 + .../caliban/rendering/DocumentRenderer.scala | 690 ++++++++++++++++++ .../scala/caliban/rendering/Renderer.scala | 187 +++++ .../caliban/rendering/ValueRenderer.scala | 133 ++++ .../scala/caliban/validation/Validator.scala | 5 +- .../caliban/wrappers/ApolloTracing.scala | 7 +- .../test/scala/caliban/RenderingSpec.scala | 39 +- .../federation/v2x/FederationV2Spec.scala | 71 +- .../caliban/tools/stitching/RemoteQuery.scala | 61 +- .../tools/stitching/RemoteQuerySpec.scala | 6 +- 16 files changed, 1204 insertions(+), 366 deletions(-) create mode 100644 core/src/main/scala/caliban/rendering/DocumentRenderer.scala create mode 100644 core/src/main/scala/caliban/rendering/Renderer.scala create mode 100644 core/src/main/scala/caliban/rendering/ValueRenderer.scala diff --git a/benchmarks/src/main/scala/caliban/GraphQLBenchmarks.scala b/benchmarks/src/main/scala/caliban/GraphQLBenchmarks.scala index f66d167892..d1c4039b14 100644 --- a/benchmarks/src/main/scala/caliban/GraphQLBenchmarks.scala +++ b/benchmarks/src/main/scala/caliban/GraphQLBenchmarks.scala @@ -2,8 +2,11 @@ package caliban import caliban.Data._ import caliban.parsing.Parser +import caliban.parsing.adt.Document +import caliban.rendering.{ DocumentRenderer, Renderer } import caliban.schema.Schema.auto._ import caliban.schema.ArgBuilder.auto._ + import cats.effect.IO import edu.gemini.grackle.generic.GenericMapping import io.circe.{ Encoder, Json } @@ -249,7 +252,10 @@ class GraphQLBenchmarks { ) ) - val interpreter: GraphQLInterpreter[Any, CalibanError] = run(graphQL(resolver).interpreter) + val gql: GraphQL[Any] = graphQL(resolver) + val document: Document = gql.toDocument + + val interpreter: GraphQLInterpreter[Any, CalibanError] = run(gql.interpreter) def run[A](zio: Task[A]): A = Unsafe.unsafe(implicit u => runtime.unsafe.run(zio).getOrThrow()) } @@ -500,6 +506,12 @@ class GraphQLBenchmarks { () } + val renderer: Renderer[Document] = DocumentRenderer + + @Benchmark + def renderCalibanFast(): Unit = + renderer.render(Caliban.document) + @Benchmark def simpleGrackle(): Unit = { val io = Grackle.compileAndRun(simpleQuery) diff --git a/core/src/main/scala/caliban/GraphQL.scala b/core/src/main/scala/caliban/GraphQL.scala index aee9613441..2743e0cb7e 100644 --- a/core/src/main/scala/caliban/GraphQL.scala +++ b/core/src/main/scala/caliban/GraphQL.scala @@ -1,13 +1,13 @@ package caliban import caliban.CalibanError.ValidationError -import caliban.Rendering.{ renderDescription, renderDirectives, renderSchemaDirectives, renderTypes } import caliban.execution.{ ExecutionRequest, Executor, Feature } import caliban.introspection.Introspector import caliban.introspection.adt._ import caliban.parsing.adt.Definition.TypeSystemDefinition.SchemaDefinition import caliban.parsing.adt.{ Directive, Document, OperationType } import caliban.parsing.{ Parser, SourceMapper, VariablesCoercer } +import caliban.rendering.DocumentRenderer import caliban.schema._ import caliban.validation.Validator import caliban.wrappers.Wrapper @@ -33,37 +33,14 @@ trait GraphQL[-R] { self => /** * Returns a string that renders the API types into the GraphQL SDL. */ - final def render: String = { - val parts = Seq( - schemaBuilder.query.flatMap(_.opType.name).map(n => s" query: $n"), - schemaBuilder.mutation.flatMap(_.opType.name).map(n => s" mutation: $n"), - schemaBuilder.subscription.flatMap(_.opType.name).map(n => s" subscription: $n") - ) - val schemaDirectives = renderSchemaDirectives(schemaBuilder.schemaDirectives) - val schemaDescription = renderDescription(schemaBuilder.schemaDescription, newline = true) - val flattenedParts = parts.flatten.mkString("\n") - val schema = (flattenedParts, schemaDirectives) match { - case ("", "") => "" - case ("", schemaDirectives) => s"extend schema $schemaDirectives" - case (something, schemaDirectives) => s"""${schemaDescription}schema $schemaDirectives{ - |$something - |}""".stripMargin - } - - val directivesPrefix = renderDirectives(additionalDirectives) match { - case "" => "" - case directiveStr => directiveStr + "\n\n" - } - - s"""$directivesPrefix$schema - | - |${renderTypes(schemaBuilder.types)}""".stripMargin - } + final def render: String = DocumentRenderer.render(toDocument) /** * Converts the schema to a Document. */ - final def toDocument: Document = + final def toDocument: Document = cachedDocument + + private lazy val cachedDocument: Document = Document( SchemaDefinition( schemaBuilder.schemaDirectives, diff --git a/core/src/main/scala/caliban/Rendering.scala b/core/src/main/scala/caliban/Rendering.scala index 63d61561f5..741d7e9aab 100644 --- a/core/src/main/scala/caliban/Rendering.scala +++ b/core/src/main/scala/caliban/Rendering.scala @@ -1,259 +1,38 @@ package caliban -import caliban.Value._ import caliban.introspection.adt._ import caliban.introspection.adt.__TypeKind._ import caliban.parsing.adt.Directive +import caliban.rendering.DocumentRenderer +@deprecated("Prefer the methods in caliban.rendering.DocumentRenderer instead.", "2.3.1") object Rendering { /** * Returns a string that renders the provided types into the GraphQL format. */ + @deprecated("Prefer DocumentRenderer.render() to render a Document.", "2.3.1") def renderTypes(types: List[__Type]): String = - types - .sorted(typeOrdering) - .flatMap { t => - t.kind match { - case __TypeKind.SCALAR => - t.name.flatMap(name => - if (isBuiltinScalar(name)) None - else - Some( - s"""${renderDescription(t.description)}scalar $name${renderDirectives( - t.directives - )}${renderSpecifiedBy(t.specifiedBy)}""".stripMargin - ) - ) - case __TypeKind.NON_NULL => None - case __TypeKind.LIST => None - case __TypeKind.UNION => - val renderedTypes: String = - t.possibleTypes - .fold(List.empty[String])(_.flatMap(_.name)) - .mkString(" | ") - Some( - s"""${renderDescription(t.description)}${renderKind(t.kind)} ${renderTypeName(t)}${renderDirectives( - t.directives - )} = $renderedTypes""" - ) - case _ => - val renderedDirectives: String = renderDirectives(t.directives) - val renderedFields: String = - t.allFields - .map(field => - List( - field.description.map(_ => renderDescription(field.description)), - Some(renderField(field)) - ).flatten.mkString(" ") - ) - .mkString("\n ") - val renderedInputFields: String = t.inputFields - .fold(List.empty[String])( - _.map(field => - List( - field.description.map(_ => renderDescription(field.description)), - Some(renderInputValue(field)) - ).flatten - .mkString(" ") - ) - ) - .mkString("\n ") - val renderedEnumValues = t - .enumValues(__DeprecatedArgs(Some(true))) - .fold(List.empty[String])(_.map(renderEnumValue)) - .mkString("\n ") - - val typedef = - s"${renderDescription(t.description)}${renderKind(t.kind)} ${renderTypeName(t)}${renderInterfaces(t)}$renderedDirectives" - - s"$renderedFields$renderedInputFields$renderedEnumValues" match { - case "" => Some(typedef) - case interior => - Some( - s"""$typedef { - | $interior - |}""".stripMargin - ) - } - } - } - .mkString("\n\n") + DocumentRenderer.typesRenderer.render(types.sorted(typeOrdering)) + @deprecated("Prefer DocumentRenderer.directivesRenderer.render instead", "2.3.1") def renderSchemaDirectives(directives: List[Directive]): String = - directives - .map(renderSchemaDirective) - .mkString(" ") - - private def renderSchemaDirective(directive: Directive): String = { - val args = - if (directive.arguments.isEmpty) "" - else directive.arguments.map { case (k, v) => s"$k: ${v.toInputString}" }.mkString("(", ", ", ") ") - s"@${directive.name}$args" - } + DocumentRenderer.directivesRenderer.render(directives) + @deprecated("Prefer DocumentRenderer.directiveDefinitionsRenderer.render instead", "2.3.1") def renderDirectives(directives: List[__Directive]): String = - directives.map(renderDirective).mkString("\n") - - private def renderDirective(directive: __Directive): String = { - val inputs = directive.args match { - case i if i.nonEmpty => s"""(${i.map(renderInputValue).mkString(", ")})""" - case _ => "" - } - val locationStrings = directive.locations.map { - case __DirectiveLocation.QUERY => "QUERY" - case __DirectiveLocation.MUTATION => "MUTATION" - case __DirectiveLocation.SUBSCRIPTION => "SUBSCRIPTION" - case __DirectiveLocation.FIELD => "FIELD" - case __DirectiveLocation.FRAGMENT_DEFINITION => "FRAGMENT_DEFINITION" - case __DirectiveLocation.FRAGMENT_SPREAD => "FRAGMENT_SPREAD" - case __DirectiveLocation.INLINE_FRAGMENT => "INLINE_FRAGMENT" - case __DirectiveLocation.SCHEMA => "SCHEMA" - case __DirectiveLocation.SCALAR => "SCALAR" - case __DirectiveLocation.OBJECT => "OBJECT" - case __DirectiveLocation.FIELD_DEFINITION => "FIELD_DEFINITION" - case __DirectiveLocation.ARGUMENT_DEFINITION => "ARGUMENT_DEFINITION" - case __DirectiveLocation.INTERFACE => "INTERFACE" - case __DirectiveLocation.UNION => "UNION" - case __DirectiveLocation.ENUM => "ENUM" - case __DirectiveLocation.ENUM_VALUE => "ENUM_VALUE" - case __DirectiveLocation.INPUT_OBJECT => "INPUT_OBJECT" - case __DirectiveLocation.INPUT_FIELD_DEFINITION => "INPUT_FIELD_DEFINITION" - case __DirectiveLocation.VARIABLE_DEFINITION => "VARIABLE_DEFINITION" - } - val directiveLocations = locationStrings.mkString(" | ") - - val on = if (directive.isRepeatable) { "repeatable on" } - else { "on" } - - val body = s"""directive @${directive.name}${inputs} ${on} ${directiveLocations}""".stripMargin - - renderDescription(directive.description) match { - case "" => body - case something => something + body - } - } - - private def renderInterfaces(t: __Type): String = - t.interfaces() - .fold("")(_.flatMap(_.name) match { - case Nil => "" - case list => s" implements ${list.mkString(" & ")}" - }) - - private def renderKind(kind: __TypeKind): String = - kind match { - case __TypeKind.OBJECT => "type" - case __TypeKind.UNION => "union" - case __TypeKind.ENUM => "enum" - case __TypeKind.INPUT_OBJECT => "input" - case __TypeKind.INTERFACE => "interface" - case _ => "" - } - - private[caliban] def renderDescription(description: Option[String], newline: Boolean = true): String = { - // Most of the graphql tools are greedy on triple quotes. To be compatible we - // need to break 4 or more '"' at the end of the description either with a newline or a space - val tripleQuote = "\"\"\"" - - def nlOrSp = if (newline) "\n" else " " - - def nlOrNot = if (newline) "\n" else "" - - def renderTripleQuotedString(value: String) = { - val valueEscaped = value.replace(tripleQuote, s"\\$tripleQuote") - // check if it ends in quote but it is already escaped - if (value.endsWith("\\\"")) - s"$tripleQuote$nlOrNot$valueEscaped$nlOrNot$tripleQuote" - // check if it ends in quote. We need to break the sequence of 4 or more '"' - else if (value.last == '"') { - s"$tripleQuote$nlOrNot$valueEscaped$nlOrSp$tripleQuote" - } else { - // ok. No quotes at the end of value - s"$tripleQuote$nlOrNot$valueEscaped$nlOrNot$tripleQuote" - } - } - description match { - case None => "" - case Some(value) if value.exists(_ == '\n') => - s"${renderTripleQuotedString(s"$value")}$nlOrSp" - case Some(value) => s"${renderString(value)}$nlOrSp" - } - } - - private def renderSpecifiedBy(specifiedBy: Option[String]): String = - specifiedBy.fold("")(url => s""" @specifiedBy(url: "$url")""") - - private def renderDirectiveArgument(value: InputValue): Option[String] = value match { - case InputValue.ListValue(values) => - Some(values.flatMap(renderDirectiveArgument).mkString("[", ",", "]")) - case InputValue.ObjectValue(fields) => - Some( - fields.flatMap { case (key, value) => renderDirectiveArgument(value).map(v => s"$key: $v") } - .mkString("{", ",", "}") - ) - case NullValue => Some("null") - case StringValue(value) => Some(renderString(value)) - case i: IntValue => Some(i.toInt.toString) - case f: FloatValue => Some(f.toFloat.toString) - case BooleanValue(value) => Some(value.toString) - case EnumValue(value) => Some(value) - case InputValue.VariableValue(_) => None - } - - private def renderDirective(directive: Directive) = - s"@${directive.name}${if (directive.arguments.nonEmpty) s"""(${directive.arguments.flatMap { case (key, value) => - renderDirectiveArgument(value).map(v => s"$key: $v") - }.mkString(",")})""" - else ""}" - - private def renderDirectives(directives: Option[List[Directive]]) = - directives.fold("") { - case Nil => "" - case d => d.sortBy(_.name).map(renderDirective).mkString(" ", " ", "") - } - - private def renderField(field: __Field): String = - s"${field.name}${renderArguments(field.args)}: ${renderTypeName(field._type)}${if (field.isDeprecated) - s" @deprecated${field.deprecationReason.fold("")(reason => s"""(reason: "$reason")""")}" - else ""}${renderDirectives(field.directives)}" - - private def renderInputValue(inputValue: __InputValue): String = - s"${inputValue.name}: ${renderTypeName(inputValue._type)}${renderDefaultValue(inputValue)}${renderDirectives(inputValue.directives)}" - - private def renderEnumValue(v: __EnumValue): String = - s"${renderDescription(v.description)}${v.name}${if (v.isDeprecated) - s" @deprecated${v.deprecationReason.fold("")(reason => s"""(reason: "$reason")""")}" - else ""}${renderDirectives(v.directives)}" - - private def renderDefaultValue(a: __InputValue): String = a.defaultValue.fold("")(d => s" = $d") - - private def renderArguments(arguments: List[__InputValue]): String = - arguments match { - case Nil => "" - case list => - s"(${list.map(a => s"${renderDescription(a.description, newline = false)}${a.name}: ${renderTypeName(a._type)}${renderDefaultValue(a)}${renderDirectives(a.directives)}").mkString(", ")})" - } + DocumentRenderer.directiveDefinitionsRenderer.render(directives.map(_.toDirectiveDefinition)) + @deprecated("Prefer DocumentRenderer.isBuiltinScalar instead", "2.3.1") private[caliban] def isBuiltinScalar(name: String): Boolean = - name == "Int" || name == "Float" || name == "String" || name == "Boolean" || name == "ID" + DocumentRenderer.isBuiltinScalar(name) - private[caliban] def renderTypeName(fieldType: __Type): String = { - lazy val renderedTypeName = fieldType.ofType.fold("null")(renderTypeName) - fieldType.kind match { - case __TypeKind.NON_NULL => renderedTypeName + "!" - case __TypeKind.LIST => s"[$renderedTypeName]" - case _ => s"${fieldType.name.getOrElse("null")}" - } - } + @deprecated("Prefer DocumentRenderer.descriptionRenderer instead", "2.3.1") + private[caliban] def renderDescription(description: Option[String], newline: Boolean = true): String = + if (newline) DocumentRenderer.descriptionRenderer.render(description) + else DocumentRenderer.descriptionRenderer.renderCompact(description) - private def renderString(value: String) = - "\"" + value - .replace("\\", "\\\\") - .replace("\b", "\\b") - .replace("\f", "\\f") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") - .replace("\"", "\\\"") + "\"" + @deprecated("Prefer DocumentRenderer.renderTypeName instead", "2.3.1") + private[caliban] def renderTypeName(fieldType: __Type): String = + DocumentRenderer.renderTypeName(fieldType) } diff --git a/core/src/main/scala/caliban/Value.scala b/core/src/main/scala/caliban/Value.scala index 006c95f70c..5a6bc72585 100644 --- a/core/src/main/scala/caliban/Value.scala +++ b/core/src/main/scala/caliban/Value.scala @@ -5,22 +5,22 @@ import caliban.interop.circe._ import caliban.interop.tapir.IsTapirSchema import caliban.interop.jsoniter.IsJsoniterCodec import caliban.interop.zio.{ IsZIOJsonDecoder, IsZIOJsonEncoder } +import caliban.rendering.ValueRenderer import zio.stream.Stream -sealed trait InputValue { - def toInputString: String = toString +sealed trait InputValue { self => + def toInputString: String = ValueRenderer.inputValueRenderer.renderCompact(self) } object InputValue extends ValueJsonCompat { case class ListValue(values: List[InputValue]) extends InputValue { override def toString: String = values.mkString("[", ",", "]") - override def toInputString: String = values.map(_.toInputString).mkString("[", ", ", "]") + override def toInputString: String = ValueRenderer.inputListValueRenderer.render(this) } case class ObjectValue(fields: Map[String, InputValue]) extends InputValue { override def toString: String = fields.map { case (name, value) => s""""$name:${value.toString}"""" }.mkString("{", ",", "}") - override def toInputString: String = - fields.map { case (name, value) => s"""$name: ${value.toInputString}""" }.mkString("{", ", ", "}") + override def toInputString: String = ValueRenderer.inputObjectValueRenderer.render(this) } case class VariableValue(name: String) extends InputValue { override def toString: String = s"$$$name" @@ -60,11 +60,11 @@ sealed trait ResponseValue { self => } object ResponseValue extends ValueJsonCompat { case class ListValue(values: List[ResponseValue]) extends ResponseValue { - override def toString: String = values.mkString("[", ",", "]") + override def toString: String = ValueRenderer.responseListValueRenderer.renderCompact(this) } case class ObjectValue(fields: List[(String, ResponseValue)]) extends ResponseValue { override def toString: String = - fields.map { case (name, value) => s""""$name":${value.toString}""" }.mkString("{", ",", "}") + ValueRenderer.responseObjectValueRenderer.renderCompact(this) override def hashCode: Int = fields.toSet.hashCode() override def equals(other: Any): Boolean = @@ -114,7 +114,7 @@ object Value { } case class EnumValue(value: String) extends Value { override def toString: String = s""""${value.replace("\"", "\\\"")}"""" - override def toInputString: String = s"""${value.replace("\"", "\\\"")}""" + override def toInputString: String = ValueRenderer.enumInputValueRenderer.render(this) } object IntValue { diff --git a/core/src/main/scala/caliban/execution/Field.scala b/core/src/main/scala/caliban/execution/Field.scala index 7271679947..b2c4b0b40e 100644 --- a/core/src/main/scala/caliban/execution/Field.scala +++ b/core/src/main/scala/caliban/execution/Field.scala @@ -5,6 +5,7 @@ import caliban.introspection.adt.__Type import caliban.parsing.SourceMapper import caliban.parsing.adt.Definition.ExecutableDefinition.FragmentDefinition import caliban.parsing.adt.Selection.{ Field => F, FragmentSpread, InlineFragment } +import caliban.parsing.adt.Type.NamedType import caliban.parsing.adt.{ Directive, LocationInfo, Selection, VariableDefinition } import caliban.schema.{ RootType, Types } import caliban.{ InputValue, Value } @@ -58,6 +59,50 @@ case class Field( case (None, None) => None } ) + + lazy val toSelection: Selection = { + def loop(f: Field): Selection = { + // Not pretty, but it avoids computing the hashmap if it isn't needed + var map: mutable.Map[String, List[Selection]] = + null.asInstanceOf[mutable.Map[String, List[Selection]]] + + val children = f.fields.flatMap { child => + val childSelection = loop(child) + child.targets match { + case Some(targets) => + targets.foreach { target => + if (map eq null) map = mutable.LinkedHashMap.empty + map.update(target, childSelection :: map.getOrElse(target, Nil)) + } + None + case None => + Some(childSelection) + } + } + + val inlineFragments = + if (map eq null) Nil + else + map.map { case (name, selections) => + Selection.InlineFragment( + Some(NamedType(name, nonNull = false)), + Nil, + selections + ) + } + + Selection.Field( + f.alias, + f.name, + f.arguments, + f.directives, + children ++ inlineFragments, + 0 + ) + } + + loop(this) + } } object Field { diff --git a/core/src/main/scala/caliban/package.scala b/core/src/main/scala/caliban/package.scala index a4f11c8ba4..4faa8cdbe0 100644 --- a/core/src/main/scala/caliban/package.scala +++ b/core/src/main/scala/caliban/package.scala @@ -1,6 +1,6 @@ -import caliban.Rendering.renderTypes import caliban.introspection.adt.__Directive import caliban.parsing.adt.Directive +import caliban.rendering.DocumentRenderer import caliban.schema.Types.collectTypes import caliban.schema._ import caliban.wrappers.Wrapper @@ -48,5 +48,5 @@ package object caliban { * This variant of the method allows specifying the environment type when it's not `Any`. */ def renderWith[R, T](implicit schema: Schema[R, T]): String = - renderTypes(collectTypes(schema.toType_(), Nil)) + DocumentRenderer.typesRenderer.render(collectTypes(schema.toType_(), Nil)) } diff --git a/core/src/main/scala/caliban/parsing/SourceMapper.scala b/core/src/main/scala/caliban/parsing/SourceMapper.scala index df3eae3873..14abd6855d 100644 --- a/core/src/main/scala/caliban/parsing/SourceMapper.scala +++ b/core/src/main/scala/caliban/parsing/SourceMapper.scala @@ -10,6 +10,8 @@ trait SourceMapper { def getLocation(index: Int): LocationInfo + def size: Option[Int] = None + } object SourceMapper { @@ -32,6 +34,8 @@ object SourceMapper { val col = index - lineNumberLookup(line) LocationInfo(column = col + 1, line = line + 1) } + + override def size: Option[Int] = Some(source.length) } def apply(source: String): SourceMapper = DefaultSourceMapper(source) diff --git a/core/src/main/scala/caliban/rendering/DocumentRenderer.scala b/core/src/main/scala/caliban/rendering/DocumentRenderer.scala new file mode 100644 index 0000000000..8ffc97e6d5 --- /dev/null +++ b/core/src/main/scala/caliban/rendering/DocumentRenderer.scala @@ -0,0 +1,690 @@ +package caliban.rendering + +import caliban.InputValue +import caliban.introspection.adt.{ __Type, __TypeKind } +import caliban.parsing.adt.Definition.ExecutableDefinition.{ FragmentDefinition, OperationDefinition } +import caliban.parsing.adt.Definition.TypeSystemDefinition.DirectiveLocation.{ + ExecutableDirectiveLocation, + TypeSystemDirectiveLocation +} +import caliban.parsing.adt.Definition.TypeSystemDefinition.TypeDefinition._ +import caliban.parsing.adt.Definition.TypeSystemDefinition.{ + DirectiveDefinition, + DirectiveLocation, + SchemaDefinition, + TypeDefinition +} +import caliban.parsing.adt.Type.{ innerType, NamedType } +import caliban.parsing.adt._ + +import scala.annotation.switch + +object DocumentRenderer extends Renderer[Document] { + + def renderTypeName(t: __Type): String = { + val builder = new StringBuilder + __typeNameRenderer.unsafeRender(t, None, builder) + builder.toString() + } + + private implicit val typeOrdering: Ordering[TypeDefinition] = Ordering.by { + case TypeDefinition.ScalarTypeDefinition(_, name, _) => (0, name) + case TypeDefinition.UnionTypeDefinition(_, name, _, _) => (1, name) + case TypeDefinition.EnumTypeDefinition(_, name, _, _) => (2, name) + case TypeDefinition.InputObjectTypeDefinition(_, name, _, _) => (3, name) + case TypeDefinition.InterfaceTypeDefinition(_, name, _, _, _) => (4, name) + case TypeDefinition.ObjectTypeDefinition(_, name, _, _, _) => (5, name) + } + + override protected[caliban] def unsafeRender(value: Document, indent: Option[Int], write: StringBuilder): Unit = { + // Estimate the size of the underlying definitions to prevent re-allocations + val sizeEstimate = value.sourceMapper.size.getOrElse { + val numDefs = value.definitions.length + numDefs * 16 // A naive estimate but a fast one, we just want to get into the ballpark of the actual size + } + write.ensureCapacity(sizeEstimate) + documentRenderer.unsafeRender(value, indent, write) + } + + private[caliban] lazy val directiveDefinitionsRenderer: Renderer[List[DirectiveDefinition]] = + directiveDefinitionRenderer.list(Renderer.newline) + + private[caliban] lazy val typesRenderer: Renderer[List[__Type]] = + typeDefinitionsRenderer.contramap(_.flatMap(_.toTypeDefinition)) + + private[caliban] lazy val directivesRenderer: Renderer[List[Directive]] = + directiveRenderer.list(Renderer.char(' '), omitFirst = false).contramap(_.sortBy(_.name)) + + private[caliban] lazy val descriptionRenderer: Renderer[Option[String]] = + new Renderer[Option[String]] { + private val tripleQuote = "\"\"\"" + + override def unsafeRender(description: Option[String], indent: Option[Int], writer: StringBuilder): Unit = + description.foreach { + case value if value.contains('\n') => + def valueEscaped(): Unit = unsafeFastEscapeQuote(value, writer) + + writer ++= tripleQuote + // check if it ends in quote but it is already escaped + if (value.endsWith("\\\"")) { + newline(indent, writer) + valueEscaped() + newline(indent, writer) + } else if (value.last == '"') { + newline(indent, writer) + valueEscaped() + newlineOrSpace(indent, writer) + // check if it ends in quote. We need to break the sequence of 4 or more '"' + } else { + // ok. No quotes at the end of value + newline(indent, writer) + valueEscaped() + newline(indent, writer) + } + writer ++= tripleQuote + newlineOrSpace(indent, writer) + case value => + pad(indent, writer) + unsafeFastEscape(value, writer) + newlineOrSpace(indent, writer) + } + } + + private lazy val documentRenderer: Renderer[Document] = Renderer.combine( + directiveDefinitionsRenderer.contramap(_.directiveDefinitions), + schemaRenderer.optional.contramap(_.schemaDefinition), + operationDefinitionRenderer.list(Renderer.newlineOrSpace).contramap(_.operationDefinitions), + typeDefinitionsRenderer.contramap(_.typeDefinitions), + fragmentRenderer.list.contramap(_.fragmentDefinitions) + ) + + private lazy val __typeNameRenderer: Renderer[__Type] = new Renderer[__Type] { + override def unsafeRender(value: __Type, indent: Option[Int], write: StringBuilder): Unit = { + def loop(typ: Option[__Type]): Unit = typ match { + case Some(t) => + t.kind match { + case __TypeKind.NON_NULL => + loop(t.ofType) + write append '!' + case __TypeKind.LIST => + write append '[' + loop(t.ofType) + write append ']' + case _ => + write append t.name.getOrElse("null") + } + case None => + write append "null" + } + + loop(Some(value)) + } + } + + private lazy val directiveDefinitionRenderer: Renderer[DirectiveDefinition] = + new Renderer[DirectiveDefinition] { + private val inputRenderer = inputValueDefinitionRenderer.list(Renderer.comma ++ Renderer.spaceOrEmpty) + private val locationsRenderer: Renderer[Set[DirectiveLocation]] = + locationRenderer.set(Renderer.spaceOrEmpty ++ Renderer.char('|') ++ Renderer.spaceOrEmpty) + + override def unsafeRender(value: DirectiveDefinition, indent: Option[Int], write: StringBuilder): Unit = + value match { + case DirectiveDefinition(description, name, args, isRepeatable, locations) => + descriptionRenderer.unsafeRender(description, indent, write) + write append "directive @" + write append name + if (args.nonEmpty) { + write append '(' + inputRenderer.unsafeRender(args, indent, write) + write append ')' + } + if (isRepeatable) write append " repeatable" + write append " on" + locationsRenderer.unsafeRender(locations, indent, write) + } + + private[caliban] lazy val locationRenderer: Renderer[DirectiveLocation] = + new Renderer[DirectiveLocation] { + override def unsafeRender(location: DirectiveLocation, indent: Option[Int], writer: StringBuilder): Unit = + location match { + case ExecutableDirectiveLocation.QUERY => writer ++= "QUERY" + case ExecutableDirectiveLocation.MUTATION => writer ++= "MUTATION" + case ExecutableDirectiveLocation.SUBSCRIPTION => writer ++= "SUBSCRIPTION" + case ExecutableDirectiveLocation.FIELD => writer ++= "FIELD" + case ExecutableDirectiveLocation.FRAGMENT_DEFINITION => writer ++= "FRAGMENT_DEFINITION" + case ExecutableDirectiveLocation.FRAGMENT_SPREAD => writer ++= "FRAGMENT_SPREAD" + case ExecutableDirectiveLocation.INLINE_FRAGMENT => writer ++= "INLINE_FRAGMENT" + case TypeSystemDirectiveLocation.SCHEMA => writer ++= "SCHEMA" + case TypeSystemDirectiveLocation.SCALAR => writer ++= "SCALAR" + case TypeSystemDirectiveLocation.OBJECT => writer ++= "OBJECT" + case TypeSystemDirectiveLocation.FIELD_DEFINITION => writer ++= "FIELD_DEFINITION" + case TypeSystemDirectiveLocation.ARGUMENT_DEFINITION => writer ++= "ARGUMENT_DEFINITION" + case TypeSystemDirectiveLocation.INTERFACE => writer ++= "INTERFACE" + case TypeSystemDirectiveLocation.UNION => writer ++= "UNION" + case TypeSystemDirectiveLocation.ENUM => writer ++= "ENUM" + case TypeSystemDirectiveLocation.ENUM_VALUE => writer ++= "ENUM_VALUE" + case TypeSystemDirectiveLocation.INPUT_OBJECT => writer ++= "INPUT_OBJECT" + case TypeSystemDirectiveLocation.INPUT_FIELD_DEFINITION => writer ++= "INPUT_FIELD_DEFINITION" + case TypeSystemDirectiveLocation.VARIABLE_DEFINITION => writer ++= "VARIABLE_DEFINITION" + } + } + + } + + private lazy val operationDefinitionRenderer: Renderer[OperationDefinition] = + new Renderer[OperationDefinition] { + override def unsafeRender(definition: OperationDefinition, indent: Option[Int], writer: StringBuilder): Unit = + definition match { + case OperationDefinition(operationType, name, variableDefinitions, directives, selectionSet) => + operationTypeRenderer.unsafeRender(operationType, indent, writer) + name.foreach { n => + writer append ' ' + writer append n + } + variableDefinitionsRenderer.unsafeRender(variableDefinitions, indent, writer) + directivesRenderer.unsafeRender(directives, indent, writer) + selectionsRenderer.unsafeRender(selectionSet, indent, writer) + } + } + + private lazy val operationTypeRenderer: Renderer[OperationType] = new Renderer[OperationType] { + override def unsafeRender(operationType: OperationType, indent: Option[Int], write: StringBuilder): Unit = + operationType match { + case OperationType.Query => write append "query" + case OperationType.Mutation => write append "mutation" + case OperationType.Subscription => write append "subscription" + } + } + + private lazy val variableDefinitionsRenderer: Renderer[List[VariableDefinition]] = + new Renderer[List[VariableDefinition]] { + private val inner = variableDefinition.list(Renderer.comma ++ Renderer.spaceOrEmpty) + + override def unsafeRender(value: List[VariableDefinition], indent: Option[Int], write: StringBuilder): Unit = + if (value.nonEmpty) { + write append '(' + inner.unsafeRender(value, indent, write) + write append ')' + } + } + + private lazy val variableDefinition: Renderer[VariableDefinition] = new Renderer[VariableDefinition] { + override def unsafeRender(definition: VariableDefinition, indent: Option[Int], builder: StringBuilder): Unit = { + builder append '$' + builder append definition.name + builder append ':' + space(indent, builder) + typeRenderer.unsafeRender(definition.variableType, indent, builder) + defaultValueRenderer.unsafeRender(definition.defaultValue, indent, builder) + } + } + + private lazy val selectionsRenderer: Renderer[List[Selection]] = new Renderer[List[Selection]] { + private val inner = selectionRenderer.list(Renderer.newlineOrSpace) + + override def unsafeRender(selections: List[Selection], indent: Option[Int], builder: StringBuilder): Unit = { + space(indent, builder) + builder append '{' + inner.unsafeRender(selections, increment(indent), builder) + newline(indent, builder) + pad(indent, builder) + builder append '}' + } + } + + private lazy val selectionRenderer: Renderer[Selection] = new Renderer[Selection] { + override protected[caliban] def unsafeRender( + selection: Selection, + indent: Option[Int], + builder: StringBuilder + ): Unit = { + pad(indent, builder) + selection match { + case Selection.Field(alias, name, arguments, directives, selectionSet, _) => + alias.foreach { a => + builder append a + builder append ':' + space(indent, builder) + } + builder append name + inputArgumentsRenderer.unsafeRender(arguments, indent, builder) + directivesRenderer.unsafeRender(directives, indent, builder) + if (selectionSet.nonEmpty) { + selectionsRenderer.unsafeRender(selectionSet, increment(indent), builder) + } + case Selection.FragmentSpread(name, directives) => + builder append "..." + builder append name + directivesRenderer.unsafeRender(directives, indent, builder) + case Selection.InlineFragment(typeCondition, dirs, selectionSet) => + builder append "..." + typeCondition.foreach { t => + space(indent, builder) + builder append "on " + builder append t.name + } + directivesRenderer.unsafeRender(dirs, indent, builder) + if (selectionSet.nonEmpty) { + selectionsRenderer.unsafeRender(selectionSet, increment(indent), builder) + } + } + } + } + + private lazy val inputArgumentsRenderer: Renderer[Map[String, InputValue]] = + new Renderer[Map[String, InputValue]] { + private val inner = + Renderer.map( + Renderer.string, + ValueRenderer.inputValueRenderer, + Renderer.comma ++ Renderer.spaceOrEmpty, + Renderer.char(':') ++ Renderer.spaceOrEmpty + ) + + override def unsafeRender(arguments: Map[String, InputValue], indent: Option[Int], builder: StringBuilder): Unit = + if (arguments.nonEmpty) { + builder append '(' + inner.unsafeRender(arguments, indent, builder) + builder append ')' + } + } + + private lazy val schemaRenderer: Renderer[SchemaDefinition] = new Renderer[SchemaDefinition] { + override def unsafeRender(definition: SchemaDefinition, indent: Option[Int], write: StringBuilder): Unit = + definition match { + case SchemaDefinition(directives, query, mutation, subscription, description) => + val hasTypes = query.nonEmpty || mutation.nonEmpty || subscription.nonEmpty + val isExtension = directives.nonEmpty && !hasTypes + var first = true + + def renderOp(name: String, op: Option[String]): Unit = + op.foreach { o => + if (first) { + first = false + newline(indent, write) + } else newlineOrComma(indent, write) + pad(increment(indent), write) + write append name + write append ':' + space(indent, write) + write append o + } + + descriptionRenderer.unsafeRender(description, indent, write) + if (isExtension) write append "extend " + if (isExtension || hasTypes) { + write append "schema" + directivesRenderer.unsafeRender(directives, indent, write) + if (hasTypes) { + space(indent, write) + write append '{' + renderOp("query", query) + renderOp("mutation", mutation) + renderOp("subscription", subscription) + newline(indent, write) + write append '}' + } + } + } + } + + private lazy val typeDefinitionsRenderer: Renderer[List[TypeDefinition]] = + typeDefinitionRenderer.list.contramap(_.sorted) + + private lazy val typeDefinitionRenderer: Renderer[TypeDefinition] = new Renderer[TypeDefinition] { + override def unsafeRender(definition: TypeDefinition, indent: Option[Int], writer: StringBuilder): Unit = + definition match { + case typ: TypeDefinition.ObjectTypeDefinition => + objectTypeDefinitionRenderer.unsafeRender(typ, indent, writer) + case typ: TypeDefinition.InterfaceTypeDefinition => + interfaceTypeDefinitionRenderer.unsafeRender(typ, indent, writer) + case typ: TypeDefinition.InputObjectTypeDefinition => + inputObjectTypeDefinition.unsafeRender(typ, indent, writer) + case typ: TypeDefinition.EnumTypeDefinition => + enumRenderer.unsafeRender(typ, indent, writer) + case typ: TypeDefinition.UnionTypeDefinition => + unionRenderer.unsafeRender(typ, indent, writer) + case typ: TypeDefinition.ScalarTypeDefinition => + scalarRenderer.unsafeRender(typ, indent, writer) + } + } + + private lazy val fragmentRenderer: Renderer[FragmentDefinition] = new Renderer[FragmentDefinition] { + override def unsafeRender(value: FragmentDefinition, indent: Option[Int], write: StringBuilder): Unit = + value match { + case FragmentDefinition(name, typeCondition, directives, selectionSet) => + newlineOrSpace(indent, write) + write append "fragment " + write append name + write append " on " + write append typeCondition.name + directivesRenderer.unsafeRender(directives, indent, write) + selectionsRenderer.unsafeRender(selectionSet, indent, write) + } + } + + private lazy val unionRenderer: Renderer[UnionTypeDefinition] = new Renderer[UnionTypeDefinition] { + private val memberRenderer = + Renderer.string.list(Renderer.spaceOrEmpty ++ Renderer.char('|') ++ Renderer.spaceOrEmpty) + + override def unsafeRender(value: UnionTypeDefinition, indent: Option[Int], write: StringBuilder): Unit = + value match { + case UnionTypeDefinition(description, name, directives, members) => + newlineOrSpace(indent, write) + descriptionRenderer.unsafeRender(description, indent, write) + write append "union " + write append name + directivesRenderer.unsafeRender(directives, indent, write) + space(indent, write) + write append '=' + space(indent, write) + memberRenderer.unsafeRender(members, indent, write) + } + } + + private lazy val scalarRenderer: Renderer[ScalarTypeDefinition] = new Renderer[ScalarTypeDefinition] { + override def unsafeRender(value: ScalarTypeDefinition, indent: Option[Int], write: StringBuilder): Unit = + value match { + case ScalarTypeDefinition(description, name, directives) => + if (!isBuiltinScalar(name)) { + newlineOrSpace(indent, write) + descriptionRenderer.unsafeRender(description, indent, write) + write append "scalar " + write append name + directivesRenderer.unsafeRender(directives, indent, write) + } + } + } + + private lazy val enumRenderer: Renderer[EnumTypeDefinition] = new Renderer[EnumTypeDefinition] { + private val memberRenderer = enumValueDefinitionRenderer.list(Renderer.newlineOrComma) + + override def unsafeRender(value: EnumTypeDefinition, indent: Option[Int], write: StringBuilder): Unit = + value match { + case EnumTypeDefinition(description, name, directives, values) => + newlineOrSpace(indent, write) + newline(indent, write) + descriptionRenderer.unsafeRender(description, indent, write) + write append "enum " + write append name + directivesRenderer.unsafeRender(directives, indent, write) + space(indent, write) + write append '{' + newline(indent, write) + memberRenderer.unsafeRender(values, increment(indent), write) + newline(indent, write) + write append '}' + } + } + + private lazy val enumValueDefinitionRenderer: Renderer[EnumValueDefinition] = + new Renderer[EnumValueDefinition] { + override def unsafeRender(value: EnumValueDefinition, indent: Option[Int], write: StringBuilder): Unit = + value match { + case EnumValueDefinition(description, name, directives) => + descriptionRenderer.unsafeRender(description, indent, write) + pad(indent, write) + write append name + directivesRenderer.unsafeRender(directives, indent, write) + } + } + + private lazy val inputObjectTypeDefinition: Renderer[InputObjectTypeDefinition] = + new Renderer[InputObjectTypeDefinition] { + private val fieldsRenderer = inputValueDefinitionRenderer.list(Renderer.newlineOrSpace) + + override def unsafeRender(value: InputObjectTypeDefinition, indent: Option[Int], write: StringBuilder): Unit = + value match { + case InputObjectTypeDefinition(description, name, directives, fields) => + newlineOrSpace(indent, write) + descriptionRenderer.unsafeRender(description, indent, write) + write append "input " + write append name + directivesRenderer.unsafeRender(directives, indent, write) + space(indent, write) + write append '{' + fieldsRenderer.unsafeRender(fields, increment(indent), write) + newline(indent, write) + write append '}' + newline(indent, write) + } + } + + private lazy val inputValueDefinitionRenderer: Renderer[InputValueDefinition] = + new Renderer[InputValueDefinition] { + override def unsafeRender( + definition: InputValueDefinition, + indent: Option[Int], + builder: StringBuilder + ): Unit = + definition match { + case InputValueDefinition(description, name, valueType, defaultValue, directives) => + descriptionRenderer.unsafeRender(description, indent, builder) + pad(indent, builder) + builder append name + builder append ':' + space(indent, builder) + typeRenderer.unsafeRender(valueType, indent, builder) + defaultValueRenderer.unsafeRender(defaultValue, indent, builder) + directivesRenderer.unsafeRender(directives, indent, builder) + } + } + + private lazy val objectTypeDefinitionRenderer: Renderer[ObjectTypeDefinition] = + new Renderer[ObjectTypeDefinition] { + override def unsafeRender(value: ObjectTypeDefinition, indent: Option[Int], write: StringBuilder): Unit = + unsafeRenderObjectLike( + "type", + value.description, + value.name, + value.implements, + value.directives, + value.fields, + indent, + write + ) + } + + private lazy val interfaceTypeDefinitionRenderer: Renderer[InterfaceTypeDefinition] = + new Renderer[InterfaceTypeDefinition] { + override def unsafeRender(value: InterfaceTypeDefinition, indent: Option[Int], write: StringBuilder): Unit = + unsafeRenderObjectLike( + "interface", + value.description, + value.name, + value.implements, + value.directives, + value.fields, + indent, + write + ) + } + + private def unsafeRenderObjectLike( + variant: String, + description: Option[String], + name: String, + implements: List[NamedType], + directives: List[Directive], + fields: List[FieldDefinition], + indent: Option[Int], + writer: StringBuilder + ): Unit = { + newline(indent, writer) + newline(indent, writer) + descriptionRenderer.unsafeRender(description, indent, writer) + writer append variant + writer append ' ' + writer append name + implements match { + case Nil => + case head :: tail => + writer append " implements " + writer append innerType(head) + tail.foreach { impl => + writer append " & " + writer append innerType(impl) + } + } + directivesRenderer.unsafeRender(directives, indent, writer) + if (fields.nonEmpty) { + space(indent, writer) + writer append '{' + fieldDefinitionsRenderer.unsafeRender(fields, increment(indent), writer) + newline(indent, writer) + writer append '}' + } + } + + 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) + } + } + + private lazy val fieldDefinitionsRenderer: Renderer[List[FieldDefinition]] = + fieldDefinitionRenderer.list(Renderer.newlineOrSpace, omitFirst = false) + + private lazy val fieldDefinitionRenderer: Renderer[FieldDefinition] = new Renderer[FieldDefinition] { + override def unsafeRender(definition: FieldDefinition, indent: Option[Int], builder: StringBuilder): Unit = + definition match { + case FieldDefinition(description, name, arguments, tpe, directives) => + descriptionRenderer.unsafeRender(description, indent, builder) + pad(indent, builder) + builder ++= name + inlineInputValueDefinitionsRenderer.unsafeRender(arguments, indent, builder) + builder += ':' + space(indent, builder) + typeRenderer.unsafeRender(tpe, indent, builder) + directivesRenderer.unsafeRender(directives, None, builder) + } + } + + private lazy val typeRenderer: Renderer[Type] = new Renderer[Type] { + override def unsafeRender(value: Type, indent: Option[Int], write: StringBuilder): Unit = { + def loop(t: Type): Unit = t match { + case Type.NamedType(name, nonNull) => + write append name + if (nonNull) write append '!' + case Type.ListType(ofType, nonNull) => + write append '[' + loop(ofType) + write append ']' + if (nonNull) write append '!' + } + loop(value) + } + } + + private lazy val inlineInputValueDefinitionsRenderer: Renderer[List[InputValueDefinition]] = + (Renderer.char('(') ++ + inlineInputValueDefinitionRenderer.list(Renderer.comma ++ Renderer.spaceOrEmpty) ++ + Renderer.char(')')).when(_.nonEmpty) + + private lazy val inlineInputValueDefinitionRenderer: Renderer[InputValueDefinition] = + new Renderer[InputValueDefinition] { + override def unsafeRender(definition: InputValueDefinition, indent: Option[Int], builder: StringBuilder): Unit = + definition match { + case InputValueDefinition(description, name, tpe, defaultValue, directives) => + descriptionRenderer.unsafeRender(description, None, builder) + builder append name + builder append ':' + space(indent, builder) + typeRenderer.unsafeRender(tpe, indent, builder) + defaultValueRenderer.unsafeRender(defaultValue, indent, builder) + directivesRenderer.unsafeRender(directives, None, builder) + } + } + + private lazy val defaultValueRenderer: Renderer[Option[InputValue]] = new Renderer[Option[InputValue]] { + override def unsafeRender(value: Option[InputValue], indent: Option[Int], writer: StringBuilder): Unit = + value.foreach { value => + space(indent, writer) + writer append '=' + space(indent, writer) + ValueRenderer.inputValueRenderer.unsafeRender(value, indent, writer) + } + } + + private def pad(indentation: Option[Int], writer: StringBuilder): Unit = { + var i = indentation.getOrElse(0) + while (i > 0) { + writer append " " + i -= 1 + } + } + + private[caliban] def isBuiltinScalar(name: String): Boolean = + name == "Int" || name == "Float" || name == "String" || name == "Boolean" || name == "ID" + + private def space(indentation: Option[Int], writer: StringBuilder): Unit = + if (indentation.isDefined) writer append ' ' + + private def newline(indent: Option[Int], writer: StringBuilder): Unit = + if (indent.isDefined) writer append '\n' + + private def newlineOrSpace(indent: Option[Int], writer: StringBuilder): Unit = + if (indent.isDefined) writer append '\n' else writer append ' ' + + private def newlineOrComma(indentation: Option[Int], writer: StringBuilder): Unit = + if (indentation.isDefined) writer append '\n' else writer append ',' + + private def increment(indentation: Option[Int]): Option[Int] = indentation.map(_ + 1) + + private def unsafeFastEscape(value: String, builder: StringBuilder) = { + builder.append('"') + var i = 0 + while (i < value.length) { + (value.charAt(i): @switch) match { + case '\\' => builder.append("\\\\") + case '\b' => builder.append("\\b") + case '\f' => builder.append("\\f") + case '\n' => builder.append("\\n") + case '\r' => builder.append("\\r") + case '\t' => builder.append("\\t") + case '"' => builder.append("\\\"") + case c => builder.append(c) + } + i += 1 + } + builder.append('"') + } + + /** + * A zero allocation version of triple quote escaping. + */ + private def unsafeFastEscapeQuote(value: String, builder: StringBuilder): Unit = { + var i = 0 + var quotes = 0 + def padQuotes(): Unit = + while (quotes > 0) { + builder.append('"') + quotes -= 1 + } + while (i < value.length) { + (value.charAt(i): @switch) match { + case '"' => + quotes += 1 + // We have encountered a triple quote sequence + if (quotes == 3) { + builder.append("\\") + padQuotes() + } + case c => + // We have encountered a non-quote character before reaching the end of the triple sequence + if (quotes > 0) { + padQuotes() + } + builder.append(c) + } + i += 1 + } + // If we reached the end without fully closing the triple quote sequence, we need to append the buffer to the builder + if (quotes > 0) { + padQuotes() + } + } + +} diff --git a/core/src/main/scala/caliban/rendering/Renderer.scala b/core/src/main/scala/caliban/rendering/Renderer.scala new file mode 100644 index 0000000000..5b31deda8a --- /dev/null +++ b/core/src/main/scala/caliban/rendering/Renderer.scala @@ -0,0 +1,187 @@ +package caliban.rendering + +/** + * The inverse of a `Parser` over some type A. + * A renderer can be used to render a value of type A to a string in either a regular or compact format. + * + * For specializations actually relevant to graphql see [[caliban.rendering.ValueRenderer]] and [[caliban.rendering.DocumentRenderer]] + */ +trait Renderer[-A] { self => + def render(a: A): String = { + val sb = new StringBuilder + unsafeRender(a, Some(0), sb) + sb.toString() + } + + def renderCompact(a: A): String = { + val sb = new StringBuilder + unsafeRender(a, None, sb) + sb.toString() + } + + /** + * Combines this renderer with another renderer sequentially. Semantically equivalent to `this andThen that` + */ + def ++[A1 <: A](that: Renderer[A1]): Renderer[A1] = self match { + case Renderer.Combined(renderers) => Renderer.Combined(renderers :+ that) + case _ => Renderer.Combined(List(self, that)) + } + + /** + * Contramaps the input of this renderer with the given function producing a renderer that now operates on type B + */ + def contramap[B](f: B => A): Renderer[B] = new Renderer[B] { + override def unsafeRender(value: B, indent: Option[Int], write: StringBuilder): Unit = + self.unsafeRender(f(value), indent, write) + } + + /** + * Returns an optional renderer that will only render the value if it is defined + */ + def optional: Renderer[Option[A]] = new Renderer[Option[A]] { + override def unsafeRender(value: Option[A], indent: Option[Int], write: StringBuilder): Unit = + value.foreach(self.unsafeRender(_, indent, write)) + } + + /** + * Returns a renderer that renders a list of A where the underlying renderer is responsible for rendering the + * separator between each element. + */ + def list: Renderer[List[A]] = + list(Renderer.empty) + + /** + * Returns a renderer that renders a list of A but where the separator is rendered by provided argument renderer. + * The second parameter determines whether to print the separator before the first element or not. + */ + def list[A1 <: A](separator: Renderer[A1], omitFirst: Boolean = true): Renderer[List[A1]] = new Renderer[List[A1]] { + override protected[caliban] def unsafeRender(value: List[A1], indent: Option[Int], write: StringBuilder): Unit = { + var first = omitFirst + value.foreach { v => + if (first) first = false + else separator.unsafeRender(v, indent, write) + self.unsafeRender(v, indent, write) + } + } + } + + /** + * Returns a renderer that renders a set of A but where the separator is rendered by provided argument renderer. + */ + def set[A1 <: A](separator: Renderer[A1]): Renderer[Set[A1]] = new Renderer[Set[A1]] { + override protected[caliban] def unsafeRender(value: Set[A1], indent: Option[Int], write: StringBuilder): Unit = { + var first = true + value.foreach { v => + if (first) first = false + else separator.unsafeRender(v, indent, write) + self.unsafeRender(v, indent, write) + } + } + } + + /** + * Returns a renderer that will only render when the provided predicate is true. + */ + def when[A1 <: A](pred: A1 => Boolean): Renderer[A1] = new Renderer[A1] { + override protected[caliban] def unsafeRender(value: A1, indent: Option[Int], write: StringBuilder): Unit = + if (pred(value)) self.unsafeRender(value, indent, write) + } + + /** + * Protected method for implementers to override. This method provides the actual unsafe rendering logic. + * @param value the value to render + * @param indent the current indentation level. This will be None if the renderer is rendering in compact mode or Some(n) + * if the renderer is rendering in regular mode where n is the current indentation level. + * @param write the string builder to write to + */ + protected[caliban] def unsafeRender(value: A, indent: Option[Int], write: StringBuilder): Unit +} + +object Renderer { + + def combine[A](renderers: Renderer[A]*): Renderer[A] = + Combined(renderers.toList) + + /** + * A Renderer which always renders a single character. + */ + def char(char: Char): Renderer[Any] = new Renderer[Any] { + override def unsafeRender(value: Any, indent: Option[Int], write: StringBuilder): Unit = + write.append(char) + } + + def comma: Renderer[Any] = char(',') + + /** + * A Renderer which always renders a string. + */ + def string(str: String): Renderer[Any] = new Renderer[Any] { + override def unsafeRender(value: Any, indent: Option[Int], write: StringBuilder): Unit = + write.append(str) + } + + /** + * A Renderer which simply renders the input string + */ + lazy val string: Renderer[String] = new Renderer[String] { + override def unsafeRender(value: String, indent: Option[Int], write: StringBuilder): Unit = + write.append(value) + } + + /** + * A Renderer which doesn't render anything. + */ + lazy val empty: Renderer[Any] = new Renderer[Any] { + override def unsafeRender(value: Any, indent: Option[Int], write: StringBuilder): Unit = () + } + + /** + * A Renderer which renders a space character when in non-compact mode. + */ + lazy val spaceOrEmpty: Renderer[Any] = new Renderer[Any] { + + override protected[caliban] def unsafeRender(value: Any, indent: Option[Int], write: StringBuilder): Unit = + if (indent.isDefined) write.append(' ') + } + + /** + * A Renderer which renders a newline character when in non-compact mode otherwise it renders a comma + */ + lazy val newlineOrComma: Renderer[Any] = new Renderer[Any] { + override protected[caliban] def unsafeRender(value: Any, indent: Option[Int], write: StringBuilder): Unit = + if (indent.isDefined) write.append('\n') else write.append(',') + } + + /** + * A Renderer which renders a newline character when in non-compact mode otherwise it renders a space + */ + lazy val newlineOrSpace: Renderer[Any] = new Renderer[Any] { + override protected[caliban] def unsafeRender(value: Any, indent: Option[Int], write: StringBuilder): Unit = + if (indent.isDefined) write.append('\n') else write.append(' ') + } + + lazy val newline: Renderer[Any] = char('\n') + + def map[K, V]( + keyRender: Renderer[K], + valueRender: Renderer[V], + separator: Renderer[Any], + delimiter: Renderer[Any] + ): Renderer[Map[K, V]] = new Renderer[Map[K, V]] { + override def unsafeRender(value: Map[K, V], indent: Option[Int], write: StringBuilder): Unit = { + var first = true + value.foreach { case (k, v) => + if (first) first = false + else separator.unsafeRender((), indent, write) + keyRender.unsafeRender(k, indent, write) + delimiter.unsafeRender((), indent, write) + valueRender.unsafeRender(v, indent, write) + } + } + } + + private final case class Combined[-A](renderers: List[Renderer[A]]) extends Renderer[A] { + override def unsafeRender(value: A, indent: Option[Int], write: StringBuilder): Unit = + renderers.foreach(_.unsafeRender(value, indent, write)) + } +} diff --git a/core/src/main/scala/caliban/rendering/ValueRenderer.scala b/core/src/main/scala/caliban/rendering/ValueRenderer.scala new file mode 100644 index 0000000000..e1185237d0 --- /dev/null +++ b/core/src/main/scala/caliban/rendering/ValueRenderer.scala @@ -0,0 +1,133 @@ +package caliban.rendering + +import caliban.Value.FloatValue.{ BigDecimalNumber, DoubleNumber, FloatNumber } +import caliban.Value.IntValue.{ BigIntNumber, IntNumber, LongNumber } +import caliban.Value.StringValue +import caliban.{ InputValue, ResponseValue, Value } + +import scala.annotation.switch + +object ValueRenderer { + + lazy val inputValueRenderer: Renderer[InputValue] = new Renderer[InputValue] { + override protected[caliban] def unsafeRender(value: InputValue, indent: Option[Int], write: StringBuilder): Unit = + value match { + case in: InputValue.ListValue => inputListValueRenderer.unsafeRender(in, indent, write) + case in: InputValue.ObjectValue => inputObjectValueRenderer.unsafeRender(in, indent, write) + case InputValue.VariableValue(name) => + write += '$' + write ++= name + case StringValue(str) => + write += '"' + unsafeFastEscape(str, write) + write += '"' + case Value.EnumValue(value) => unsafeFastEscape(value, write) + case Value.BooleanValue(value) => write append value + case Value.NullValue => write ++= "null" + case IntNumber(value) => write append value + case LongNumber(value) => write append value + case BigIntNumber(value) => write append value + case FloatNumber(value) => write append value + case DoubleNumber(value) => write append value + case BigDecimalNumber(value) => write append value + } + } + + lazy val inputObjectValueRenderer: Renderer[InputValue.ObjectValue] = + Renderer.char('{') ++ Renderer + .map( + Renderer.string, + inputValueRenderer, + Renderer.char(',') ++ Renderer.spaceOrEmpty, + Renderer.char(':') ++ Renderer.spaceOrEmpty + ) + .contramap[InputValue.ObjectValue](_.fields) ++ Renderer.char('}') + + lazy val inputListValueRenderer: Renderer[InputValue.ListValue] = + Renderer.char('[') ++ inputValueRenderer + .list(Renderer.char(',') ++ Renderer.spaceOrEmpty) + .contramap[InputValue.ListValue](_.values) ++ Renderer.char(']') + + lazy val enumInputValueRenderer: Renderer[Value.EnumValue] = new Renderer[Value.EnumValue] { + override protected[caliban] def unsafeRender( + value: Value.EnumValue, + indent: Option[Int], + write: StringBuilder + ): Unit = + unsafeFastEscape(value.value, write) + } + + lazy val responseValueRenderer: Renderer[ResponseValue] = new Renderer[ResponseValue] { + override protected[caliban] def unsafeRender( + value: ResponseValue, + indent: Option[Int], + write: StringBuilder + ): Unit = + value match { + case ResponseValue.ListValue(values) => + responseListValueRenderer.unsafeRender(ResponseValue.ListValue(values), indent, write) + case in: ResponseValue.ObjectValue => + responseObjectValueRenderer.unsafeRender(in, indent, write) + case StringValue(str) => + write += '"' + unsafeFastEscape(str, write) + write += '"' + case Value.EnumValue(value) => + write += '"' + unsafeFastEscape(value, write) + write += '"' + case Value.BooleanValue(value) => write append value + case Value.NullValue => write append "null" + case IntNumber(value) => write append value + case LongNumber(value) => write append value + case FloatNumber(value) => write append value + case DoubleNumber(value) => write append value + case BigDecimalNumber(value) => write append value + case BigIntNumber(value) => write append value + case ResponseValue.StreamValue(_) => write append "" + } + } + + lazy val responseListValueRenderer: Renderer[ResponseValue.ListValue] = + Renderer.char('[') ++ responseValueRenderer + .list(Renderer.char(',') ++ Renderer.spaceOrEmpty) + .contramap[ResponseValue.ListValue](_.values) ++ Renderer.char(']') + + lazy val responseObjectValueRenderer: Renderer[ResponseValue.ObjectValue] = new Renderer[ResponseValue.ObjectValue] { + override protected[caliban] def unsafeRender( + value: ResponseValue.ObjectValue, + indent: Option[Int], + write: StringBuilder + ): Unit = { + write += '{' + var first = true + value.fields.foreach { field => + if (first) first = false + else { + write += ',' + if (indent.nonEmpty) write += ' ' + } + write += '"' + write ++= field._1 + write += '"' + write += ':' + if (indent.nonEmpty) write += ' ' + responseValueRenderer.unsafeRender(field._2, indent, write) + } + write += '}' + } + } + + private def unsafeFastEscape(str: String, write: StringBuilder): Unit = { + var i = 0 + while (i < str.length) { + (str.charAt(i): @switch) match { + case '"' => write ++= "\\\"" + case '\n' => write ++= "\\n" + case c => write += c + } + i += 1 + } + } + +} diff --git a/core/src/main/scala/caliban/validation/Validator.scala b/core/src/main/scala/caliban/validation/Validator.scala index 9e2432c8c9..fe2f3c8410 100644 --- a/core/src/main/scala/caliban/validation/Validator.scala +++ b/core/src/main/scala/caliban/validation/Validator.scala @@ -16,9 +16,10 @@ import caliban.parsing.adt.Selection.{ Field, FragmentSpread, InlineFragment } import caliban.parsing.adt.Type.NamedType import caliban.parsing.adt._ import caliban.parsing.Parser +import caliban.rendering.DocumentRenderer import caliban.schema._ import caliban.validation.Utils.isObjectType -import caliban.{ Configurator, InputValue, Rendering, Value } +import caliban.{ Configurator, InputValue, Value } import zio.{ IO, ZIO } import zio.prelude._ import zio.prelude.fx.ZPure @@ -507,7 +508,7 @@ object Validator { .fromOption(currentType.allFields.find(_.name == field.name)) .orElseFail( ValidationError( - s"Field '${field.name}' does not exist on type '${Rendering.renderTypeName(currentType)}'.", + s"Field '${field.name}' does not exist on type '${DocumentRenderer.renderTypeName(currentType)}'.", "The target field of a field selection must be defined on the scoped type of the selection set. There are no limitations on alias names." ) ) diff --git a/core/src/main/scala/caliban/wrappers/ApolloTracing.scala b/core/src/main/scala/caliban/wrappers/ApolloTracing.scala index bfd39e0aad..de9a2b03be 100644 --- a/core/src/main/scala/caliban/wrappers/ApolloTracing.scala +++ b/core/src/main/scala/caliban/wrappers/ApolloTracing.scala @@ -7,8 +7,9 @@ import caliban.ResponseValue.{ ListValue, ObjectValue } import caliban.Value.{ IntValue, StringValue } import caliban.execution.{ ExecutionRequest, FieldInfo } import caliban.parsing.adt.Document +import caliban.rendering.DocumentRenderer import caliban.wrappers.Wrapper.{ EffectfulWrapper, FieldWrapper, OverallWrapper, ParsingWrapper, ValidationWrapper } -import caliban.{ CalibanError, GraphQLRequest, GraphQLResponse, Rendering, ResponseValue } +import caliban.{ CalibanError, GraphQLRequest, GraphQLResponse, ResponseValue } import zio._ import zio.query.ZQuery @@ -182,9 +183,9 @@ object ApolloTracing { execution = state.execution.copy( resolvers = Resolver( path = fieldInfo.path, - parentType = fieldInfo.details.parentType.fold("")(Rendering.renderTypeName), + parentType = fieldInfo.details.parentType.fold("")(DocumentRenderer.renderTypeName), fieldName = fieldInfo.name, - returnType = Rendering.renderTypeName(fieldInfo.details.fieldType), + returnType = DocumentRenderer.renderTypeName(fieldInfo.details.fieldType), startOffset = start - state.startTimeMonotonic, duration = duration ) :: state.execution.resolvers diff --git a/core/src/test/scala/caliban/RenderingSpec.scala b/core/src/test/scala/caliban/RenderingSpec.scala index a9b8407921..5a62aec355 100644 --- a/core/src/test/scala/caliban/RenderingSpec.scala +++ b/core/src/test/scala/caliban/RenderingSpec.scala @@ -12,6 +12,7 @@ import caliban.parsing.adt.Definition.TypeSystemDefinition.TypeDefinition.{ InputValueDefinition } import caliban.parsing.adt.{ Definition, Directive } +import caliban.rendering.DocumentRenderer import caliban.schema.Schema.auto._ import caliban.schema.ArgBuilder.auto._ import zio.IO @@ -20,7 +21,7 @@ import zio.test._ object RenderingSpec extends ZIOSpecDefault { def fixDirectives(directives: List[Directive]): List[Directive] = - directives.map(_.copy(index = 0)) + directives.map(_.copy(index = 0)).sortBy(_.name) def fixFields(fields: List[FieldDefinition]): List[FieldDefinition] = fields.map(field => field.copy(args = fixInputValues(field.args), directives = fixDirectives(field.directives))) @@ -55,7 +56,7 @@ object RenderingSpec extends ZIOSpecDefault { def checkApi[R](api: GraphQL[R]): IO[ParsingError, TestResult] = { val definitions = fixDefinitions(api.toDocument.definitions.filter { case d: Definition.TypeSystemDefinition.TypeDefinition.ScalarTypeDefinition => - !Rendering.isBuiltinScalar(d.name) + !DocumentRenderer.isBuiltinScalar(d.name) case _ => true }) @@ -69,7 +70,7 @@ object RenderingSpec extends ZIOSpecDefault { test("it should render directives") { val api = graphQL( resolver, - directives = List(Directives.Test, Directives.Repeatable), + directives = List(Directives.Repeatable, Directives.Test), schemaDirectives = List(SchemaDirectives.Link) ) checkApi(api) @@ -93,19 +94,15 @@ object RenderingSpec extends ZIOSpecDefault { test( "it should not render a schema definition without schema directives if no queries, mutations, or subscription" ) { - assert(graphQL(InvalidSchemas.resolverEmpty).render.trim)( - equalTo("") - ) + assertTrue(graphQL(InvalidSchemas.resolverEmpty).render.trim == "") }, test( "it should render a schema extension with schema directives even if no queries, mutations, or subscription" ) { val renderedType = graphQL(InvalidSchemas.resolverEmpty, schemaDirectives = List(SchemaDirectives.Link)).render.trim - assert(renderedType)( - equalTo( - """extend schema @link(url: "https://example.com", import: ["@key", {name: "@provides", as: "@self"}])""" - ) + assertTrue( + renderedType == """extend schema @link(url: "https://example.com", import: ["@key", {name: "@provides", as: "@self"}])""" ) }, test("it should render object arguments in type directives") { @@ -128,8 +125,8 @@ object RenderingSpec extends ZIOSpecDefault { ) ) ) - val renderedType = Rendering.renderTypes(List(testType)) - assert(renderedType)(equalTo("type TestType @testdirective(object: {key1: \"value1\",key2: \"value2\"})")) + val renderedType = DocumentRenderer.typesRenderer.render(List(testType)).trim + assertTrue(renderedType == "type TestType @testdirective(object: {key1: \"value1\", key2: \"value2\"})") }, test( "it should escape \", \\, backspace, linefeed, carriage-return and tab inside a normally quoted description string" @@ -139,10 +136,8 @@ object RenderingSpec extends ZIOSpecDefault { name = Some("TestType"), description = Some("A \"TestType\" description with \\, \b, \f, \r and \t") ) - val renderedType = Rendering.renderTypes(List(testType)) - assert(renderedType)( - equalTo("\"A \\\"TestType\\\" description with \\\\, \\b, \\f, \\r and \\t\"\ntype TestType") - ) + val renderedType = DocumentRenderer.typesRenderer.render(List(testType)).trim + assertTrue(renderedType == "\"A \\\"TestType\\\" description with \\\\, \\b, \\f, \\r and \\t\"\ntype TestType") }, test("it should escape \"\"\" inside a triple-quoted description string") { val testType = __Type( @@ -150,9 +145,9 @@ object RenderingSpec extends ZIOSpecDefault { name = Some("TestType"), description = Some("A multiline \"TestType\" description\ngiven inside \"\"\"-quotes\n") ) - val renderedType = Rendering.renderTypes(List(testType)) - assert(renderedType)( - equalTo("\"\"\"\nA multiline \"TestType\" description\ngiven inside \\\"\"\"-quotes\n\n\"\"\"\ntype TestType") + val renderedType = DocumentRenderer.typesRenderer.render(List(testType)).trim + assertTrue( + renderedType == "\"\"\"\nA multiline \"TestType\" description\ngiven inside \\\"\"\"-quotes\n\n\"\"\"\ntype TestType" ) }, test("it should render single line descriptions") { @@ -170,6 +165,12 @@ object RenderingSpec extends ZIOSpecDefault { test("it should render multi line descriptions ending in quote") { val api = graphQL(resolver) checkApi(api) + }, + test("it should render compact") { + val rendered = DocumentRenderer.renderCompact(graphQL(resolver).toDocument) + assertTrue( + rendered == """schema{query:Query} "Description of custom scalar emphasizing proper captain ship names" scalar CaptainShipName @specifiedBy(url:"http://someUrl") @tag union Role @uniondirective=Captain|Engineer|Mechanic|Pilot enum Origin @enumdirective{BELT,EARTH,MARS,MOON @deprecated(reason:"Use: EARTH | MARS | BELT")} input CharacterInput @inputobjdirective{name:String! @external nicknames:[String!]! @required origin:Origin!}interface Human{ name:String! @external}type Captain{ shipName:CaptainShipName!}type Character implements Human @key(name:"name"){ name:String! @external nicknames:[String!]! @required origin:Origin! role:Role}type Engineer{ shipName:String!}type Mechanic{ shipName:String!}type Narrator implements Human{ name:String!}type Pilot{ shipName:String!}"Queries" type Query{ "Return all characters from a given origin" characters(origin:Origin):[Character!]! character(name:String!):Character @deprecated(reason:"Use `characters`") charactersIn(names:[String!]! @lowercase):[Character!]! exists(character:CharacterInput!):Boolean! human:Human!}""" + ) } ) } diff --git a/federation/src/test/scala/caliban/federation/v2x/FederationV2Spec.scala b/federation/src/test/scala/caliban/federation/v2x/FederationV2Spec.scala index fb1ed5cc8f..f202b1346f 100644 --- a/federation/src/test/scala/caliban/federation/v2x/FederationV2Spec.scala +++ b/federation/src/test/scala/caliban/federation/v2x/FederationV2Spec.scala @@ -10,7 +10,9 @@ import caliban._ import io.circe.Json import io.circe.parser.decode import zio.ZIO +import zio.test.Assertion.{ hasSameElements, isSome } import zio.test.{ assertTrue, ZIOSpecDefault } +import zio.test._ object FederationV2Spec extends ZIOSpecDefault { override def spec = @@ -18,29 +20,30 @@ object FederationV2Spec extends ZIOSpecDefault { test("includes schema directives - v2.0") { import caliban.federation.v2_0._ makeSchemaDirectives(federated(_)).map { schemaDirectives => + val linkDirective = schemaDirectives.find(_.name == "link") + val url = linkDirective.flatMap(_.arguments.get("url")) + val imports = linkDirective.toList + .flatMap(_.arguments.get("import")) + .collect { case ListValue(values) => + values.collect { case StringValue(value) => value } + } + .flatten + assertTrue( - schemaDirectives - .contains( - Directive( - name = "link", - Map( - "url" -> StringValue("https://specs.apollo.dev/federation/v2.0"), - "import" -> ListValue( - List( - StringValue("@key"), - StringValue("@requires"), - StringValue("@provides"), - StringValue("@external"), - StringValue("@shareable"), - StringValue("@tag"), - StringValue("@inaccessible"), - StringValue("@override"), - StringValue("@extends") - ) - ) - ) - ) - ) + linkDirective.isDefined, + url.get == StringValue("https://specs.apollo.dev/federation/v2.0") + ) && assert(imports)( + hasSameElements( + "@key" :: + "@requires" :: + "@provides" :: + "@external" :: + "@shareable" :: + "@tag" :: + "@inaccessible" :: + "@override" :: + "@extends" :: Nil + ) ) } }, @@ -126,6 +129,18 @@ object FederationV2Spec extends ZIOSpecDefault { makeSchemaDirectives(federated(_)).map { schemaDirectives => assertTrue( schemaDirectives == List( + Directive( + "composeDirective", + Map( + "name" -> StringValue("@myDirective") + ) + ), + Directive( + "composeDirective", + Map( + "name" -> StringValue("@hello") + ) + ), Directive( "link", Map( @@ -163,18 +178,6 @@ object FederationV2Spec extends ZIOSpecDefault { ) ) ) - ), - Directive( - "composeDirective", - Map( - "name" -> StringValue("@myDirective") - ) - ), - Directive( - "composeDirective", - Map( - "name" -> StringValue("@hello") - ) ) ) ) diff --git a/tools/src/main/scala/caliban/tools/stitching/RemoteQuery.scala b/tools/src/main/scala/caliban/tools/stitching/RemoteQuery.scala index 1151d82ab5..97a09c973d 100644 --- a/tools/src/main/scala/caliban/tools/stitching/RemoteQuery.scala +++ b/tools/src/main/scala/caliban/tools/stitching/RemoteQuery.scala @@ -6,50 +6,53 @@ import caliban.Value._ import caliban.InputValue._ import caliban.Value.FloatValue._ import caliban.Value.IntValue._ -import caliban.parsing.adt.Selection -import caliban.parsing.adt.Selection.FragmentSpread -import caliban.parsing.adt.Selection.InlineFragment +import caliban.parsing.SourceMapper +import caliban.parsing.adt.Definition.ExecutableDefinition.OperationDefinition +import caliban.parsing.adt.Type.NamedType +import caliban.parsing.adt.{ Document, OperationType, Selection } +import caliban.rendering.DocumentRenderer + +import scala.collection.mutable case class RemoteQuery(field: Field) { self => def toGraphQLRequest: GraphQLRequest = - GraphQLRequest(query = - Some( - RemoteQuery.QueryRenderer.render(self) - ) + GraphQLRequest( + query = Some(DocumentRenderer.renderCompact(toDocument)) ) + + def toDocument: Document = RemoteQuery.toDocument(OperationType.Query, field) } case class RemoteMutation(field: Field) { self => def toGraphQLRequest: GraphQLRequest = GraphQLRequest(query = Some( - RemoteQuery.QueryRenderer.render(self) + DocumentRenderer.renderCompact(toDocument) ) ) + + def toDocument: Document = RemoteQuery.toDocument(OperationType.Mutation, field) } object RemoteQuery { object QueryRenderer { - def render(r: RemoteMutation): String = s"mutation { ${renderField(r.field)} }" - def render(r: RemoteQuery): String = s"query { ${renderField(r.field)} }" - - private def renderField(field: Field): String = { - val children = renderFields(field) - val args = renderArguments(field.arguments) - val alias = field.alias.map(a => s"$a: ").getOrElse("") - val str = s"$alias${field.name}$args$children" - - field.targets - .map(typeConditions => typeConditions.map(condition => s"...on $condition { $str }").mkString("\n")) - .getOrElse(str) - } - - private def renderFields(f: Field): String = - if (f.fields.isEmpty) "" - else - f.fields.map(renderField).mkString(" { ", " ", " }") - - private def renderArguments(args: Map[String, InputValue]): String = - if (args.isEmpty) "" else args.map { case (k, v) => s"$k: ${v.toInputString}" }.mkString("(", ", ", ")") + def render(r: RemoteMutation): String = + DocumentRenderer.renderCompact(r.toDocument) + def render(r: RemoteQuery): String = + DocumentRenderer.renderCompact(r.toDocument) } + + def toDocument(operationType: OperationType, field: Field): Document = + Document( + List( + OperationDefinition( + operationType, + None, + Nil, + Nil, + List(field.toSelection) + ) + ), + SourceMapper.empty + ) } diff --git a/tools/src/test/scala/caliban/tools/stitching/RemoteQuerySpec.scala b/tools/src/test/scala/caliban/tools/stitching/RemoteQuerySpec.scala index 2e2f6cd26c..e85533a139 100644 --- a/tools/src/test/scala/caliban/tools/stitching/RemoteQuerySpec.scala +++ b/tools/src/test/scala/caliban/tools/stitching/RemoteQuerySpec.scala @@ -53,7 +53,9 @@ object RemoteQuerySpec extends ZIOSpecDefault { i <- api(ref) _ <- i.execute(query) actual <- ref.get - } yield assertTrue(actual == """query { union(value: "foo\"") { ...on Interface { id } } }""") + } yield assertTrue( + actual == """query{union(value:"foo\""){...on Interface{id}}}""" + ) }, test("correctly renders a query for a field") { val query = gqldoc("""{ @@ -66,7 +68,7 @@ object RemoteQuerySpec extends ZIOSpecDefault { _ <- i.execute(query) actual <- ref.get } yield assertTrue( - actual == """query { union(value: "bar") { ...on Interface { id } ...on A { id } ...on B { id } } }""" + actual == """query{union(value:"bar"){...on Interface{id} ...on A{id} ...on B{id}}}""" ) } )