Skip to content

Commit

Permalink
Add fast rendering (#1835)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
paulpdaniels authored Aug 23, 2023
1 parent 52540e3 commit 7896591
Show file tree
Hide file tree
Showing 16 changed files with 1,204 additions and 366 deletions.
14 changes: 13 additions & 1 deletion benchmarks/src/main/scala/caliban/GraphQLBenchmarks.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 5 additions & 28 deletions core/src/main/scala/caliban/GraphQL.scala
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down
255 changes: 17 additions & 238 deletions core/src/main/scala/caliban/Rendering.scala
Original file line number Diff line number Diff line change
@@ -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)
}
Loading

0 comments on commit 7896591

Please sign in to comment.