diff --git a/core/src/main/scala/caliban/CalibanError.scala b/core/src/main/scala/caliban/CalibanError.scala index 31b6cad4a..e977b4521 100644 --- a/core/src/main/scala/caliban/CalibanError.scala +++ b/core/src/main/scala/caliban/CalibanError.scala @@ -1,5 +1,8 @@ package caliban +import caliban.interop.circe.IsCirceEncoder +import caliban.parsing.adt.LocationInfo + /** * The base type for all Caliban errors. */ @@ -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""" } @@ -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 = { @@ -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 + } + } diff --git a/core/src/main/scala/caliban/GraphQLResponse.scala b/core/src/main/scala/caliban/GraphQLResponse.scala index 544759636..970e56daa 100644 --- a/core/src/main/scala/caliban/GraphQLResponse.scala +++ b/core/src/main/scala/caliban/GraphQLResponse.scala @@ -1,6 +1,5 @@ package caliban -import caliban.CalibanError.ExecutionError import caliban.ResponseValue.ObjectValue import caliban.interop.circe._ @@ -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)) } } diff --git a/core/src/main/scala/caliban/execution/Executor.scala b/core/src/main/scala/caliban/execution/Executor.scala index 78159ad33..afd6632e8 100644 --- a/core/src/main/scala/caliban/execution/Executor.scala +++ b/core/src/main/scala/caliban/execution/Executor.scala @@ -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 { /** @@ -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 @@ -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), @@ -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) @@ -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)) + } + } diff --git a/core/src/main/scala/caliban/execution/Field.scala b/core/src/main/scala/caliban/execution/Field.scala index 4b853e1c7..c4cf7b382 100644 --- a/core/src/main/scala/caliban/execution/Field.scala +++ b/core/src/main/scala/caliban/execution/Field.scala @@ -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( @@ -15,7 +16,8 @@ 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 { @@ -23,20 +25,32 @@ object Field { 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) => diff --git a/core/src/main/scala/caliban/introspection/Introspector.scala b/core/src/main/scala/caliban/introspection/Introspector.scala index 16a7b6212..973d226c0 100644 --- a/core/src/main/scala/caliban/introspection/Introspector.scala +++ b/core/src/main/scala/caliban/introspection/Introspector.scala @@ -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 } diff --git a/core/src/main/scala/caliban/parsing/Parser.scala b/core/src/main/scala/caliban/parsing/Parser.scala index b58daa723..de80436ce 100644 --- a/core/src/main/scala/caliban/parsing/Parser.scala +++ b/core/src/main/scala/caliban/parsing/Parser.scala @@ -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 } @@ -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) @@ -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 ) } @@ -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. @@ -204,3 +209,5 @@ object Parser { case f: Parsed.Failure => Some(f.msg) } } + +case class ParsedDocument(definitions: List[ExecutableDefinition], index: Int = 0) diff --git a/core/src/main/scala/caliban/parsing/SourceMapper.scala b/core/src/main/scala/caliban/parsing/SourceMapper.scala new file mode 100644 index 000000000..df3eae387 --- /dev/null +++ b/core/src/main/scala/caliban/parsing/SourceMapper.scala @@ -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 +} diff --git a/core/src/main/scala/caliban/parsing/adt/Directive.scala b/core/src/main/scala/caliban/parsing/adt/Directive.scala index f1477565b..8c26c8bee 100644 --- a/core/src/main/scala/caliban/parsing/adt/Directive.scala +++ b/core/src/main/scala/caliban/parsing/adt/Directive.scala @@ -2,4 +2,4 @@ package caliban.parsing.adt import caliban.InputValue -case class Directive(name: String, arguments: Map[String, InputValue]) +case class Directive(name: String, arguments: Map[String, InputValue], index: Int) diff --git a/core/src/main/scala/caliban/parsing/adt/Document.scala b/core/src/main/scala/caliban/parsing/adt/Document.scala index 9338caf34..247d62fab 100644 --- a/core/src/main/scala/caliban/parsing/adt/Document.scala +++ b/core/src/main/scala/caliban/parsing/adt/Document.scala @@ -1,3 +1,5 @@ package caliban.parsing.adt -case class Document(definitions: List[ExecutableDefinition]) +import caliban.parsing.SourceMapper + +case class Document(definitions: List[ExecutableDefinition], sourceMapper: SourceMapper) diff --git a/core/src/main/scala/caliban/parsing/adt/LocationInfo.scala b/core/src/main/scala/caliban/parsing/adt/LocationInfo.scala new file mode 100644 index 000000000..356ad890b --- /dev/null +++ b/core/src/main/scala/caliban/parsing/adt/LocationInfo.scala @@ -0,0 +1,7 @@ +package caliban.parsing.adt + +case class LocationInfo(column: Int, line: Int) + +object LocationInfo { + val origin: LocationInfo = LocationInfo(0, 0) +} diff --git a/core/src/main/scala/caliban/parsing/adt/Selection.scala b/core/src/main/scala/caliban/parsing/adt/Selection.scala index c4e967963..1a1cf0ac0 100644 --- a/core/src/main/scala/caliban/parsing/adt/Selection.scala +++ b/core/src/main/scala/caliban/parsing/adt/Selection.scala @@ -12,7 +12,8 @@ object Selection { name: String, arguments: Map[String, InputValue], directives: List[Directive], - selectionSet: List[Selection] + selectionSet: List[Selection], + index: Int ) extends Selection case class FragmentSpread(name: String, directives: List[Directive]) extends Selection diff --git a/core/src/main/scala/caliban/schema/Schema.scala b/core/src/main/scala/caliban/schema/Schema.scala index c4008320f..5ca9d62c0 100644 --- a/core/src/main/scala/caliban/schema/Schema.scala +++ b/core/src/main/scala/caliban/schema/Schema.scala @@ -1,22 +1,23 @@ package caliban.schema -import scala.annotation.implicitNotFound -import scala.language.experimental.macros import java.util.UUID -import scala.concurrent.Future -import caliban.CalibanError.ExecutionError -import caliban.{ InputValue, ResponseValue } + import caliban.ResponseValue._ import caliban.Value._ import caliban.introspection.adt._ import caliban.schema.Annotations.{ GQLDeprecated, GQLDescription, GQLInputName, GQLName } import caliban.schema.Step._ import caliban.schema.Types._ +import caliban.{ InputValue, ResponseValue } import magnolia._ import zio.ZIO import zio.stream.ZStream import zquery.ZQuery +import scala.annotation.implicitNotFound +import scala.concurrent.Future +import scala.language.experimental.macros + /** * Typeclass that defines how to map the type `T` to the according GraphQL concepts: how to introspect it and how to resolve it. * `R` is the ZIO environment required by the effects in the schema (`Any` if nothing required). @@ -415,12 +416,3 @@ trait DerivationSchema[R] { implicit def gen[T]: Typeclass[T] = macro Magnolia.gen[T] } - -object GenericSchema { - - def effectfulExecutionError(path: List[Either[String, Int]], e: Throwable): ExecutionError = - e match { - case e: ExecutionError => e - case other => ExecutionError("Effect failure", path.reverse, Some(other)) - } -} diff --git a/core/src/main/scala/caliban/validation/Validator.scala b/core/src/main/scala/caliban/validation/Validator.scala index a78186495..6edf3f3d8 100644 --- a/core/src/main/scala/caliban/validation/Validator.scala +++ b/core/src/main/scala/caliban/validation/Validator.scala @@ -6,6 +6,7 @@ import caliban.Value.NullValue import caliban.execution.{ ExecutionRequest, Field => F } import caliban.introspection.Introspector import caliban.introspection.adt._ +import caliban.parsing.SourceMapper import caliban.parsing.adt.ExecutableDefinition.{ FragmentDefinition, OperationDefinition } import caliban.parsing.adt.OperationType._ import caliban.parsing.adt.Selection.{ Field, FragmentSpread, InlineFragment } @@ -79,7 +80,7 @@ object Validator { }).map( operation => ExecutionRequest( - F(op.selectionSet, fragments, variables, operation.opType), + F(op.selectionSet, fragments, variables, operation.opType, document.sourceMapper), op.operationType, op.variableDefinitions ) @@ -119,7 +120,7 @@ object Validator { .fold(List.empty[InputValue])( f => f.directives.flatMap(_.arguments.values) ++ collectValues(f.selectionSet) ) - case Field(_, _, arguments, directives, selectionSet) => + case Field(_, _, arguments, directives, selectionSet, _) => arguments.values ++ directives.flatMap(_.arguments.values) ++ collectValues(selectionSet) case InlineFragment(_, directives, selectionSet) => directives.flatMap(_.arguments.values) ++ collectValues(selectionSet) @@ -131,9 +132,9 @@ object Validator { private def collectSelectionSets(selectionSet: List[Selection]): List[Selection] = selectionSet ++ selectionSet.flatMap { - case _: FragmentSpread => Nil - case Field(_, _, _, _, selectionSet) => collectSelectionSets(selectionSet) - case InlineFragment(_, _, selectionSet) => collectSelectionSets(selectionSet) + case _: FragmentSpread => Nil + case f: Field => collectSelectionSets(f.selectionSet) + case f: InlineFragment => collectSelectionSets(f.selectionSet) } private def collectAllDirectives(context: Context): IO[ValidationError, List[(Directive, __DirectiveLocation)]] = @@ -161,7 +162,7 @@ object Validator { IO.foreach(selectionSet) { case FragmentSpread(_, directives) => checkDirectivesUniqueness(directives).as(directives.map((_, __DirectiveLocation.FRAGMENT_SPREAD))) - case Field(_, _, _, directives, selectionSet) => + case Field(_, _, _, directives, selectionSet, _) => checkDirectivesUniqueness(directives) *> collectDirectives(selectionSet).map(directives.map((_, __DirectiveLocation.FIELD)) ++ _) case InlineFragment(_, directives, selectionSet) => @@ -522,7 +523,10 @@ object Validator { t <- context.rootType.subscriptionType op <- context.operations .filter(_.operationType == OperationType.Subscription) - .find(op => F(op.selectionSet, context.fragments, Map.empty[String, InputValue], t).fields.length > 1) + .find( + op => + F(op.selectionSet, context.fragments, Map.empty[String, InputValue], t, SourceMapper.empty).fields.length > 1 + ) } yield op ) .map { op => diff --git a/core/src/test/scala/caliban/GraphQLResponseSpec.scala b/core/src/test/scala/caliban/GraphQLResponseSpec.scala index 5067ad000..2caae9af1 100644 --- a/core/src/test/scala/caliban/GraphQLResponseSpec.scala +++ b/core/src/test/scala/caliban/GraphQLResponseSpec.scala @@ -25,7 +25,7 @@ object GraphQLResponseSpec equalTo( Json.obj( "data" -> Json.fromString("data"), - "errors" -> Json.arr(Json.obj("message" -> Json.fromString("Execution error: Resolution failed"))) + "errors" -> Json.arr(Json.obj("message" -> Json.fromString("Resolution failed"))) ) ) ) diff --git a/core/src/test/scala/caliban/execution/ExecutionSpec.scala b/core/src/test/scala/caliban/execution/ExecutionSpec.scala index 8f7127107..aadf60586 100644 --- a/core/src/test/scala/caliban/execution/ExecutionSpec.scala +++ b/core/src/test/scala/caliban/execution/ExecutionSpec.scala @@ -1,12 +1,14 @@ package caliban.execution import java.util.UUID + import caliban.CalibanError.ExecutionError import caliban.GraphQL._ import caliban.Macros.gqldoc import caliban.RootResolver import caliban.TestUtils._ import caliban.Value.{ BooleanValue, StringValue } +import caliban.parsing.adt.LocationInfo import io.circe.Json import zio.IO import zio.stream.ZStream @@ -283,7 +285,16 @@ object ExecutionSpec }""") assertM( interpreter.execute(query).map(_.errors), - equalTo(List(ExecutionError("Effect failure", List(Left("a"), Left("b"), Left("c")), Some(e)))) + equalTo( + List( + ExecutionError( + "Effect failure", + List(Left("a"), Left("b"), Left("c")), + Some(LocationInfo(21, 5)), + Some(e) + ) + ) + ) ) }, testM("ZStream used in a query") { diff --git a/core/src/test/scala/caliban/parsing/ParserSpec.scala b/core/src/test/scala/caliban/parsing/ParserSpec.scala index 6a97c9586..4023829cc 100644 --- a/core/src/test/scala/caliban/parsing/ParserSpec.scala +++ b/core/src/test/scala/caliban/parsing/ParserSpec.scala @@ -9,7 +9,7 @@ import caliban.parsing.adt.ExecutableDefinition.{ FragmentDefinition, OperationD import caliban.parsing.adt.OperationType.{ Mutation, Query } import caliban.parsing.adt.Selection.{ Field, FragmentSpread, InlineFragment } import caliban.parsing.adt.Type.{ ListType, NamedType } -import caliban.parsing.adt.{ Directive, Document, Selection, VariableDefinition } +import caliban.parsing.adt.{ Directive, Document, LocationInfo, Selection, VariableDefinition } import zio.test.Assertion._ import zio.test._ @@ -34,11 +34,13 @@ object ParserSpec simpleField( "hero", selectionSet = List( - simpleField("name"), - simpleField("friends", selectionSet = List(simpleField("name"))) - ) + simpleField("name", index = 15), + simpleField("friends", selectionSet = List(simpleField("name", index = 73)), index = 57) + ), + index = 4 ) - ) + ), + sourceMapper = SourceMapper(query) ) ) ) @@ -58,10 +60,14 @@ object ParserSpec simpleField( "human", arguments = Map("id" -> StringValue("1000")), - selectionSet = - List(simpleField("name"), simpleField("height", arguments = Map("unit" -> EnumValue("FOOT")))) + selectionSet = List( + simpleField("name", index = 28), + simpleField("height", arguments = Map("unit" -> EnumValue("FOOT")), index = 37) + ), + index = 4 ) - ) + ), + sourceMapper = SourceMapper(query) ) ) ) @@ -84,15 +90,18 @@ object ParserSpec "hero", alias = Some("empireHero"), arguments = Map("episode" -> EnumValue("EMPIRE")), - selectionSet = List(simpleField("name")) + selectionSet = List(simpleField("name", index = 44)), + index = 4 ), simpleField( "hero", alias = Some("jediHero"), arguments = Map("episode" -> EnumValue("JEDI")), - selectionSet = List(simpleField("name")) + selectionSet = List(simpleField("name", index = 91)), + index = 55 ) - ) + ), + sourceMapper = SourceMapper(query) ) ) ) @@ -123,9 +132,11 @@ object ParserSpec "list" -> ListValue(List(IntValue(1), IntValue(2), IntValue(3))), "obj" -> ObjectValue(Map("name" -> StringValue("name"))) ), - selectionSet = List(simpleField("name")) + selectionSet = List(simpleField("name", index = 131)), + index = 4 ) - ) + ), + sourceMapper = SourceMapper(query) ) ) ) @@ -139,9 +150,11 @@ object ParserSpec selectionSet = List( simpleField( "sendEmail", - arguments = Map("message" -> StringValue("Hello,\n World!\n\nYours,\n GraphQL. ")) + arguments = Map("message" -> StringValue("Hello,\n World!\n\nYours,\n GraphQL. ")), + index = 2 ) - ) + ), + sourceMapper = SourceMapper(query) ) ) ) @@ -167,12 +180,14 @@ object ParserSpec "user", arguments = Map("id" -> IntValue(4)), selectionSet = List( - simpleField("id"), - simpleField("name"), - simpleField("profilePic", arguments = Map("size" -> VariableValue("devicePicSize"))) - ) + simpleField("id", index = 69), + simpleField("name", index = 76), + simpleField("profilePic", arguments = Map("size" -> VariableValue("devicePicSize")), index = 85) + ), + index = 51 ) - ) + ), + sourceMapper = SourceMapper(query) ) ) ) @@ -191,9 +206,11 @@ object ParserSpec selectionSet = List( simpleField( "experimentalField", - directives = List(Directive("skip", Map("if" -> VariableValue("someTestM")))) + directives = List(Directive("skip", Map("if" -> VariableValue("someTestM")), 57)), + index = 39 ) - ) + ), + sourceMapper = SourceMapper(query) ) ) ) @@ -215,7 +232,8 @@ object ParserSpec Nil ) ), - selectionSet = List(simpleField("nothing")) + selectionSet = List(simpleField("nothing", index = 50)), + sourceMapper = SourceMapper(query) ) ) ) @@ -255,14 +273,17 @@ object ParserSpec simpleField( "friends", arguments = Map("first" -> IntValue(10)), - selectionSet = List(FragmentSpread("friendFields", Nil)) + selectionSet = List(FragmentSpread("friendFields", Nil)), + index = 42 ), simpleField( "mutualFriends", arguments = Map("first" -> IntValue(10)), - selectionSet = List(FragmentSpread("friendFields", Nil)) + selectionSet = List(FragmentSpread("friendFields", Nil)), + index = 95 ) - ) + ), + index = 24 ) ) ), @@ -271,12 +292,13 @@ object ParserSpec NamedType("User", nonNull = false), Nil, List( - simpleField("id"), - simpleField("name"), - simpleField("profilePic", arguments = Map("size" -> IntValue(50))) + simpleField("id", index = 191), + simpleField("name", index = 196), + simpleField("profilePic", arguments = Map("size" -> IntValue(50)), index = 203) ) ) - ) + ), + SourceMapper(query) ) ) ) @@ -307,20 +329,24 @@ object ParserSpec "profiles", arguments = Map("handles" -> ListValue(List(StringValue("zuck"), StringValue("cocacola")))), selectionSet = List( - simpleField("handle"), + simpleField("handle", index = 77), InlineFragment( Some(NamedType("User", nonNull = false)), Nil, - List(simpleField("friends", selectionSet = List(simpleField("count")))) + List( + simpleField("friends", selectionSet = List(simpleField("count", index = 126)), index = 108) + ) ), InlineFragment( Some(NamedType("Page", nonNull = false)), Nil, - List(simpleField("likers", selectionSet = List(simpleField("count")))) + List(simpleField("likers", selectionSet = List(simpleField("count", index = 187)), index = 170)) ) - ) + ), + index = 31 ) - ) + ), + sourceMapper = SourceMapper(query) ) ) ) @@ -349,20 +375,22 @@ object ParserSpec "user", arguments = Map("handle" -> StringValue("zuck")), selectionSet = List( - simpleField("id"), - simpleField("name"), + simpleField("id", index = 82), + simpleField("name", index = 89), InlineFragment( None, - List(Directive("include", Map("if" -> VariableValue("expandedInfo")))), + List(Directive("include", Map("if" -> VariableValue("expandedInfo")), 102)), List( - simpleField("firstName"), - simpleField("lastName"), - simpleField("birthday") + simpleField("firstName", index = 138), + simpleField("lastName", index = 154), + simpleField("birthday", index = 169) ) ) - ) + ), + index = 55 ) - ) + ), + sourceMapper = SourceMapper(query) ) ) ) @@ -390,12 +418,14 @@ object ParserSpec "likeStory", arguments = Map("storyID" -> IntValue(12345)), selectionSet = List( - simpleField("story", selectionSet = List(simpleField("likeCount"))) - ) + simpleField("story", selectionSet = List(simpleField("likeCount", index = 59)), index = 45) + ), + index = 13 ) ) ) - ) + ), + SourceMapper(query) ) ) ) @@ -406,7 +436,10 @@ object ParserSpec | name( | } |}""".stripMargin - assertM(Parser.parseQuery(query).run, fails(equalTo(ParsingError("Position 4:3, found \"}\\n}\"")))) + assertM( + Parser.parseQuery(query).run, + fails(equalTo(ParsingError("Position 4:3, found \"}\\n}\"", locationInfo = Some(LocationInfo(3, 4))))) + ) } ) ) @@ -417,14 +450,16 @@ object ParserSpecUtils { name: Option[String] = None, variableDefinitions: List[VariableDefinition] = Nil, directives: List[Directive] = Nil, - selectionSet: List[Selection] = Nil - ) = Document(List(OperationDefinition(Query, name, variableDefinitions, directives, selectionSet))) + selectionSet: List[Selection] = Nil, + sourceMapper: SourceMapper = SourceMapper.empty + ) = Document(List(OperationDefinition(Query, name, variableDefinitions, directives, selectionSet)), sourceMapper) def simpleField( name: String, alias: Option[String] = None, arguments: Map[String, InputValue] = Map(), directives: List[Directive] = Nil, - selectionSet: List[Selection] = Nil - ) = Field(alias, name, arguments, directives, selectionSet) + selectionSet: List[Selection] = Nil, + index: Int = 0 + ) = Field(alias, name, arguments, directives, selectionSet, index) } diff --git a/core/src/test/scala/caliban/parsing/SourceMapperSpec.scala b/core/src/test/scala/caliban/parsing/SourceMapperSpec.scala new file mode 100644 index 000000000..cf4d291a4 --- /dev/null +++ b/core/src/test/scala/caliban/parsing/SourceMapperSpec.scala @@ -0,0 +1,21 @@ +package caliban.parsing + +import zio.test._ +import Assertion._ +import caliban.parsing.adt.LocationInfo + +object SourceMapperSpec + extends DefaultRunnableSpec( + suite("SourceMapper")( + test("should not throw IndexOutOfBounds") { + assert(SourceMapper("").getLocation(100), equalTo(LocationInfo(101, 1))) + }, + test("should map correctly to the source location") { + val sm = SourceMapper(""" + |a + |b + |""".stripMargin) + assert(sm.getLocation(3), equalTo(LocationInfo(1, 3))) + } + ) + )