Skip to content

Commit

Permalink
Add source location mapping (#168)
Browse files Browse the repository at this point in the history
* feat(sourcelocation): Add SourceMapper

* feat(sourcemapping): Add source mapping. Fix tests

* Fix formatting

* Fix formatting

* Address PR reviews

* Fix format and rebase
  • Loading branch information
paulpdaniels authored and ghostdogpr committed Jan 28, 2020
1 parent ac52b2b commit 9cd1643
Show file tree
Hide file tree
Showing 17 changed files with 326 additions and 122 deletions.
62 changes: 60 additions & 2 deletions core/src/main/scala/caliban/CalibanError.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package caliban

import caliban.interop.circe.IsCirceEncoder
import caliban.parsing.adt.LocationInfo

/**
* The base type for all Caliban errors.
*/
Expand All @@ -12,14 +15,19 @@ object CalibanError {
/**
* Describes an error that happened while parsing a query.
*/
case class ParsingError(msg: String, innerThrowable: Option[Throwable] = None) extends CalibanError {
case class ParsingError(
msg: String,
locationInfo: Option[LocationInfo] = None,
innerThrowable: Option[Throwable] = None
) extends CalibanError {
override def toString: String = s"""Parsing error: $msg ${innerThrowable.fold("")(_.toString)}"""
}

/**
* Describes an error that happened while validating a query.
*/
case class ValidationError(msg: String, explanatoryText: String) extends CalibanError {
case class ValidationError(msg: String, explanatoryText: String, locationInfo: Option[LocationInfo] = None)
extends CalibanError {
override def toString: String = s"""Validation error: $msg $explanatoryText"""
}

Expand All @@ -29,6 +37,7 @@ object CalibanError {
case class ExecutionError(
msg: String,
path: List[Either[String, Int]] = Nil,
locationInfo: Option[LocationInfo] = None,
innerThrowable: Option[Throwable] = None
) extends CalibanError {
override def toString: String = {
Expand All @@ -41,4 +50,53 @@ object CalibanError {
s"Execution error$field: $msg$inner"
}
}

implicit def circeEncoder[F[_]](implicit ev: IsCirceEncoder[F]): F[CalibanError] =
ErrorCirce.errorValueEncoder.asInstanceOf[F[CalibanError]]
}

private object ErrorCirce {
import io.circe._
import io.circe.syntax._

private def locationToJson(li: LocationInfo): Json =
Json.obj("line" -> li.line.asJson, "column" -> li.column.asJson)

val errorValueEncoder: Encoder[CalibanError] = Encoder.instance[CalibanError] {
case CalibanError.ParsingError(msg, locationInfo, _) =>
Json
.obj(
"message" -> msg.asJson,
"locations" -> Some(locationInfo).collect {
case Some(li) => Json.arr(locationToJson(li))
}.asJson
)
.dropNullValues
case CalibanError.ValidationError(msg, _, locationInfo) =>
Json
.obj(
"message" -> msg.asJson,
"locations" -> Some(locationInfo).collect {
case Some(li) => Json.arr(locationToJson(li))
}.asJson
)
.dropNullValues
case CalibanError.ExecutionError(msg, path, locationInfo, _) =>
Json
.obj(
"message" -> msg.asJson,
"locations" -> Some(locationInfo).collect {
case Some(li) => Json.arr(locationToJson(li))
}.asJson,
"path" -> Some(path).collect {
case p if p.nonEmpty =>
Json.fromValues(p.map {
case Left(value) => value.asJson
case Right(value) => value.asJson
})
}.asJson
)
.dropNullValues
}

}
12 changes: 2 additions & 10 deletions core/src/main/scala/caliban/GraphQLResponse.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package caliban

import caliban.CalibanError.ExecutionError
import caliban.ResponseValue.ObjectValue
import caliban.interop.circe._

Expand Down Expand Up @@ -34,15 +33,8 @@ private object GraphQLResponseCirce {

private def handleError(err: Any): Json =
err match {
case ExecutionError(_, path, _) if path.nonEmpty =>
Json.obj(
"message" -> Json.fromString(err.toString),
"path" -> Json.fromValues(path.map {
case Left(value) => Json.fromString(value)
case Right(value) => Json.fromInt(value)
})
)
case _ => Json.obj("message" -> Json.fromString(err.toString))
case ce: CalibanError => ce.asJson
case _ => Json.obj("message" -> Json.fromString(err.toString))
}

}
40 changes: 28 additions & 12 deletions core/src/main/scala/caliban/execution/Executor.scala
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
package caliban.execution

import scala.annotation.tailrec
import scala.collection.immutable.ListMap
import caliban.CalibanError.ExecutionError
import caliban.ResponseValue._
import caliban.Value._
import caliban.parsing.adt._
import caliban.schema.Step._
import caliban.schema.{ GenericSchema, ReducedStep, Step }
import caliban.schema.{ ReducedStep, Step }
import caliban.wrappers.Wrapper.FieldWrapper
import caliban.{ CalibanError, GraphQLResponse, InputValue, ResponseValue }
import zio._
import zquery.ZQuery

import scala.annotation.tailrec
import scala.collection.immutable.ListMap

object Executor {

/**
Expand Down Expand Up @@ -43,13 +45,11 @@ object Executor {
step match {
case s @ PureStep(value) =>
value match {
case EnumValue(v) if mergeFields(currentField, v).collectFirst {
case Field("__typename", _, _, _, _, _, _) => true
}.nonEmpty =>
case EnumValue(v) =>
// special case of an hybrid union containing case objects, those should return an object instead of a string
val obj = mergeFields(currentField, v).collectFirst {
case Field(name @ "__typename", _, _, alias, _, _, _) =>
ObjectValue(List(alias.getOrElse(name) -> StringValue(v)))
case f: Field if f.name == "__typename" =>
ObjectValue(List(f.alias.getOrElse(f.name) -> StringValue(v)))
}
obj.fold(s)(PureStep(_))
case _ => s
Expand All @@ -62,9 +62,9 @@ object Executor {
case ObjectStep(objectName, fields) =>
val mergedFields = mergeFields(currentField, objectName)
val items = mergedFields.map {
case f @ Field(name @ "__typename", _, _, alias, _, _, _) =>
case f @ Field(name @ "__typename", _, _, alias, _, _, _, _) =>
(alias.getOrElse(name), PureStep(StringValue(objectName)), fieldInfo(f, path))
case f @ Field(name, _, _, alias, _, _, args) =>
case f @ Field(name, _, _, alias, _, _, args, _) =>
val arguments = resolveVariables(args, request.variableDefinitions, variables)
(
alias.getOrElse(name),
Expand All @@ -77,12 +77,18 @@ object Executor {
reduceObject(items, fieldWrappers)
case QueryStep(inner) =>
ReducedStep.QueryStep(
inner.bimap(GenericSchema.effectfulExecutionError(path, _), reduceStep(_, currentField, arguments, path))
inner.bimap(
effectfulExecutionError(path, Some(currentField.locationInfo), _),
reduceStep(_, currentField, arguments, path)
)
)
case StreamStep(stream) =>
if (request.operationType == OperationType.Subscription) {
ReducedStep.StreamStep(
stream.bimap(GenericSchema.effectfulExecutionError(path, _), reduceStep(_, currentField, arguments, path))
stream.bimap(
effectfulExecutionError(path, Some(currentField.locationInfo), _),
reduceStep(_, currentField, arguments, path)
)
)
} else {
reduceStep(QueryStep(ZQuery.fromEffect(stream.runCollect.map(ListStep(_)))), currentField, arguments, path)
Expand Down Expand Up @@ -194,4 +200,14 @@ object Executor {
}))
else ReducedStep.ObjectStep(items)

private def effectfulExecutionError(
path: List[Either[String, Int]],
locationInfo: Option[LocationInfo],
e: Throwable
): ExecutionError =
e match {
case e: ExecutionError => e
case other => ExecutionError("Effect failure", path.reverse, locationInfo, Some(other))
}

}
24 changes: 19 additions & 5 deletions core/src/main/scala/caliban/execution/Field.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ package caliban.execution
import caliban.InputValue
import caliban.Value.BooleanValue
import caliban.introspection.adt.{ __DeprecatedArgs, __Type }
import caliban.parsing.SourceMapper
import caliban.parsing.adt.ExecutableDefinition.FragmentDefinition
import caliban.parsing.adt.Selection.{ FragmentSpread, InlineFragment, Field => F }
import caliban.parsing.adt.{ Directive, Selection }
import caliban.parsing.adt.{ Directive, LocationInfo, Selection }
import caliban.schema.Types

case class Field(
Expand All @@ -15,28 +16,41 @@ case class Field(
alias: Option[String] = None,
fields: List[Field] = Nil,
conditionalFields: Map[String, List[Field]] = Map(),
arguments: Map[String, InputValue] = Map()
arguments: Map[String, InputValue] = Map(),
locationInfo: LocationInfo = LocationInfo.origin
)

object Field {
def apply(
selectionSet: List[Selection],
fragments: Map[String, FragmentDefinition],
variableValues: Map[String, InputValue],
fieldType: __Type
fieldType: __Type,
sourceMapper: SourceMapper
): Field = {

def loop(selectionSet: List[Selection], fieldType: __Type): Field = {
val innerType = Types.innerType(fieldType)
val (fields, cFields) = selectionSet.map {
case f @ F(alias, name, arguments, _, selectionSet) if checkDirectives(f.directives, variableValues) =>
case f @ F(alias, name, arguments, _, selectionSet, index) if checkDirectives(f.directives, variableValues) =>
val t = innerType
.fields(__DeprecatedArgs(Some(true)))
.flatMap(_.find(_.name == name))
.fold(Types.string)(_.`type`()) // default only case where it's not found is __typename
val field = loop(selectionSet, t)
(
List(Field(name, t, Some(innerType), alias, field.fields, field.conditionalFields, arguments)),
List(
Field(
name,
t,
Some(innerType),
alias,
field.fields,
field.conditionalFields,
arguments,
sourceMapper.getLocation(index)
)
),
Map.empty[String, List[Field]]
)
case FragmentSpread(name, directives) if checkDirectives(directives, variableValues) =>
Expand Down
4 changes: 2 additions & 2 deletions core/src/main/scala/caliban/introspection/Introspector.scala
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ object Introspector {
document.definitions.forall {
case OperationDefinition(_, _, _, _, selectionSet) =>
selectionSet.forall {
case Field(_, name, _, _, _) => name == "__schema" || name == "__type"
case _ => true
case Field(_, name, _, _, _, _) => name == "__schema" || name == "__type"
case _ => true
}
case _ => true
}
Expand Down
35 changes: 21 additions & 14 deletions core/src/main/scala/caliban/parsing/Parser.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ package caliban.parsing

import caliban.CalibanError.ParsingError
import caliban.InputValue
import caliban.InputValue._
import caliban.Value._
import caliban.parsing.adt.ExecutableDefinition._
import caliban.parsing.adt.Selection._
import caliban.parsing.adt.Type._
import caliban.InputValue._
import caliban.Value._
import caliban.parsing.adt._
import fastparse._
import zio.{ IO, Task }
Expand Down Expand Up @@ -114,8 +114,8 @@ object Parser {
private def argument[_: P]: P[(String, InputValue)] = P(name ~ ":" ~ value)
private def arguments[_: P]: P[Map[String, InputValue]] = P("(" ~/ argument.rep ~ ")").map(_.toMap)

private def directive[_: P]: P[Directive] = P("@" ~/ name ~ arguments).map {
case (name, arguments) => Directive(name, arguments)
private def directive[_: P]: P[Directive] = P(Index ~ "@" ~/ name ~ arguments).map {
case (index, name, arguments) => Directive(name, arguments, index)
}
private def directives[_: P]: P[List[Directive]] = P(directive.rep).map(_.toList)

Expand All @@ -141,14 +141,15 @@ object Parser {
}
private def defaultValue[_: P]: P[InputValue] = P("=" ~/ value)

private def field[_: P]: P[Field] = P(alias.? ~ name ~ arguments.? ~ directives.? ~ selectionSet.?).map {
case (alias, name, args, dirs, sels) =>
private def field[_: P]: P[Field] = P(Index ~ alias.? ~ name ~ arguments.? ~ directives.? ~ selectionSet.?).map {
case (index, alias, name, args, dirs, sels) =>
Field(
alias,
name,
args.getOrElse(Map()),
dirs.getOrElse(Nil),
sels.getOrElse(Nil)
sels.getOrElse(Nil),
index
)
}

Expand Down Expand Up @@ -184,17 +185,21 @@ object Parser {

private def definition[_: P]: P[ExecutableDefinition] = executableDefinition

private def document[_: P]: P[Document] =
P(Start ~ ignored ~ definition.rep ~ ignored ~ End).map(seq => Document(seq.toList))
private def document[_: P]: P[ParsedDocument] =
P(Start ~ ignored ~ definition.rep ~ ignored ~ End).map(seq => ParsedDocument(seq.toList))

/**
* Parses the given string into a [[caliban.parsing.adt.Document]] object or fails with a [[caliban.CalibanError.ParsingError]].
*/
def parseQuery(query: String): IO[ParsingError, Document] =
Task(parse(query, document(_))).mapError(ex => ParsingError(s"Internal parsing error", Some(ex))).flatMap {
case Parsed.Success(value, _) => IO.succeed(value)
case f: Parsed.Failure => IO.fail(ParsingError(f.msg))
}
def parseQuery(query: String): IO[ParsingError, Document] = {
val sm = SourceMapper(query)
Task(parse(query, document(_)))
.mapError(ex => ParsingError(s"Internal parsing error", innerThrowable = Some(ex)))
.flatMap {
case Parsed.Success(value, _) => IO.succeed(Document(value.definitions, sm))
case f: Parsed.Failure => IO.fail(ParsingError(f.msg, Some(sm.getLocation(f.index))))
}
}

/**
* Checks if the query is valid, if not returns an error string.
Expand All @@ -204,3 +209,5 @@ object Parser {
case f: Parsed.Failure => Some(f.msg)
}
}

case class ParsedDocument(definitions: List[ExecutableDefinition], index: Int = 0)
44 changes: 44 additions & 0 deletions core/src/main/scala/caliban/parsing/SourceMapper.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package caliban.parsing

import caliban.parsing.adt.LocationInfo
import fastparse.internal.Util

/**
* Maps an index to the "friendly" version of an index based on the underlying source.
*/
trait SourceMapper {

def getLocation(index: Int): LocationInfo

}

object SourceMapper {

/**
* Implementation taken from https://github.com/lihaoyi/fastparse/blob/e334ca88b747fb3b6637ef6d76715ad66e048a6c/fastparse/src/fastparse/ParserInput.scala#L123-L131
*
* It is used to look up a line/column number pair given a raw index into a source string. The numbers are determined by
* computing the number of newlines occurring between 0 and the current index.
*/
private[parsing] case class DefaultSourceMapper(source: String) extends SourceMapper {
private[this] lazy val lineNumberLookup = Util.lineNumberLookup(source)

def getLocation(index: Int): LocationInfo = {
val line = lineNumberLookup.indexWhere(_ > index) match {
case -1 => lineNumberLookup.length - 1
case n => 0 max (n - 1)
}

val col = index - lineNumberLookup(line)
LocationInfo(column = col + 1, line = line + 1)
}
}

def apply(source: String): SourceMapper = DefaultSourceMapper(source)

private case object EmptySourceMapper extends SourceMapper {
def getLocation(index: Int): LocationInfo = LocationInfo.origin
}

val empty: SourceMapper = EmptySourceMapper
}
Loading

0 comments on commit 9cd1643

Please sign in to comment.