diff --git a/modules/circe/src/main/scala/circemapping.scala b/modules/circe/src/main/scala/circemapping.scala index 0346350e..c4439f00 100644 --- a/modules/circe/src/main/scala/circemapping.scala +++ b/modules/circe/src/main/scala/circemapping.scala @@ -20,7 +20,7 @@ import scala.collection.Factory import cats.MonadThrow import cats.implicits._ import fs2.Stream -import io.circe.{Encoder, Json} +import io.circe.{Encoder, Json, JsonNumber} import org.tpolecat.sourcepos.SourcePos import grackle._ @@ -57,6 +57,37 @@ trait CirceMappingLike[F[_]] extends Mapping[F] { computeJson(fieldName)((p, e) => effect(p, e).map(_.map(enc(_)))) } + // Coerces the value to a built-in scalar type, or None if it does not coerce + private def coerceBuiltin(tpe: Type, value: Json): Option[Json] = { + def numberFromString: Option[JsonNumber] = value.asString.flatMap(JsonNumber.fromString) + + // No built-in scalar coerces from an object, array or null + if (value.isObject || value.isArray || value.isNull) None + else + tpe match { + // Strings "true" and "false" coerce to Boolean + case BooleanType => + if (value.isBoolean) Some(value) + else value.asString.flatMap(_.toBooleanOption).map(Json.fromBoolean) + // Booleans and numbers coerce to their JSON text, e.g. "true" or "42" + case StringType => + if (value.isString) Some(value) + else Some(Json.fromString(value.noSpaces)) + // ID can come from String or Int, but coerces to String + case IDType => + if (value.isString) Some(value) + else value.asNumber.flatMap(_.toBigInt).map(i => Json.fromString(i.toString)) + // Strings such as "42" coerce to Int + case IntType => + value.asNumber.orElse(numberFromString).flatMap(_.toLong).map(Json.fromLong) + // Strings such as "1.5" coerce to Float + case FloatType => + if (value.isNumber) Some(value) + else numberFromString.map(Json.fromJsonNumber) + case _ => None + } + } + def circeCursor(path: Path, env: Env, value: Json): Cursor = if (path.isRoot) CirceCursor(Context(path.rootTpe), value, None, env) @@ -95,6 +126,17 @@ trait CirceMappingLike[F[_]] extends Mapping[F] { implicit val pos: SourcePos ) extends CirceFieldMapping + object CirceCursor { + // Coerce built-in scalars up-front, so predicates and `asLeaf` see the same value. + // Values that do not coerce stay unchanged. The `asLeaf` method reports them as errors. + def apply(context: Context, focus: Json, parent: Option[Cursor], env: Env): CirceCursor = + new CirceCursor( + context, + coerceBuiltin(context.tpe.dealias, focus).getOrElse(focus), + parent, + env) + } + case class CirceCursor( context: Context, focus: Json, @@ -117,14 +159,9 @@ trait CirceMappingLike[F[_]] extends Mapping[F] { def asLeaf: Result[Json] = tpe.dealias match { - case BooleanType if focus.isBoolean => focus.success - case StringType | IDType if focus.isString => focus.success - case IntType if focus.isNumber => - focus - .asNumber - .flatMap(_.toLong.map(Json.fromLong)) - .toResultOrError(s"Expected Int found ${focus.noSpaces}") - case FloatType if focus.isNumber => focus.success + case t: ScalarType if t.isBuiltIn => + coerceBuiltin(t, focus).toResult( + s"Cannot coerce JSON ${focus.name} value '${focus.noSpaces}' to type ${t.name}") case e: EnumType if focus.isString => if (focus.asString.exists(e.hasValue)) focus.success else Result.internalError(s"Expected Enum ${e.name}, found ${focus.noSpaces}") diff --git a/modules/circe/src/test/scala/CirceData.scala b/modules/circe/src/test/scala/CirceData.scala index 2ff09e28..41172deb 100644 --- a/modules/circe/src/test/scala/CirceData.scala +++ b/modules/circe/src/test/scala/CirceData.scala @@ -127,3 +127,50 @@ object TestCirceMapping extends CirceMapping[IO] { Elab.transformChild(_ => Count(Select("children"))) } } + +object TestCirceScalarCoercionMapping extends CirceMapping[IO] { + val schema = + schema""" + type Query { + int: String! + float: String! + bool: String! + string: String! + array: String + object: String + bigInt: Int! + intFromString: Int! + floatFromString: Float! + boolFromString: Boolean! + idFromInt: ID! + badInt: Int + badFloat: Float + badBool: Boolean + } + """ + + val QueryType = schema.ref("Query") + + val typeMappings = + List( + ObjectMapping( + tpe = QueryType, + fieldMappings = List( + CirceField("int", Json.fromInt(42)), + CirceField("float", Json.fromDoubleOrNull(1.5)), + CirceField("bool", Json.True), + CirceField("string", Json.fromString("foo")), + CirceField("array", Json.arr(Json.fromInt(1))), + CirceField("object", Json.obj("a" -> Json.fromInt(1))), + CirceField("bigInt", Json.fromLong(3000000000L)), + CirceField("intFromString", Json.fromString("42")), + CirceField("floatFromString", Json.fromString("1.5")), + CirceField("boolFromString", Json.fromString("true")), + CirceField("idFromInt", Json.fromInt(23)), + CirceField("badInt", Json.fromString("foo")), + CirceField("badFloat", Json.True), + CirceField("badBool", Json.fromInt(1)) + ) + ) + ) +} diff --git a/modules/circe/src/test/scala/CirceSuite.scala b/modules/circe/src/test/scala/CirceSuite.scala index 3b681702..2a9d95ef 100644 --- a/modules/circe/src/test/scala/CirceSuite.scala +++ b/modules/circe/src/test/scala/CirceSuite.scala @@ -15,9 +15,14 @@ package grackle.circetests +import io.circe.Json import io.circe.literal._ import munit.CatsEffectSuite +import grackle.{Env, Path, Predicate} +import grackle.PathTerm.UniquePath +import grackle.Predicate.{Const, Eql} + final class CirceSuite extends CatsEffectSuite { test("scalars") { val query = """ @@ -339,4 +344,153 @@ final class CirceSuite extends CatsEffectSuite { assertIO(res, expected) } + + test("booleans and numbers are coerced to String") { + val query = """ + query { + int + float + bool + string + } + """ + + val expected = json""" + { + "data" : { + "int" : "42", + "float" : "1.5", + "bool" : "true", + "string" : "foo" + } + } + """ + + val res = TestCirceScalarCoercionMapping.compileAndRun(query) + + assertIO(res, expected) + } + + test("lists and objects are not coerced to String") { + val query = """ + query { + array + object + } + """ + + val expected = json""" + { + "errors" : [ + { + "message" : "Cannot coerce JSON Array value '[1]' to type String", + "locations" : [ { "line" : 3, "column" : 9 } ], + "path" : [ "array" ] + }, + { + "message" : "Cannot coerce JSON Object value '{\"a\":1}' to type String", + "locations" : [ { "line" : 4, "column" : 9 } ], + "path" : [ "object" ] + } + ], + "data" : { + "array" : null, + "object" : null + } + } + """ + + val res = TestCirceScalarCoercionMapping.compileAndRun(query) + + assertIO(res, expected) + } + + test("large Int values and strings are coerced to Int, Float, Boolean and ID") { + val query = """ + query { + bigInt + intFromString + floatFromString + boolFromString + idFromInt + } + """ + + val expected = json""" + { + "data" : { + "bigInt" : 3000000000, + "intFromString" : 42, + "floatFromString" : 1.5, + "boolFromString" : true, + "idFromInt" : "23" + } + } + """ + + val res = TestCirceScalarCoercionMapping.compileAndRun(query) + + assertIO(res, expected) + } + + test("values that do not coerce to Int, Float or Boolean are errors") { + val query = """ + query { + badInt + badFloat + badBool + } + """ + + val expected = json""" + { + "errors" : [ + { + "message" : "Cannot coerce JSON String value '\"foo\"' to type Int", + "locations" : [ { "line" : 3, "column" : 9 } ], + "path" : [ "badInt" ] + }, + { + "message" : "Cannot coerce JSON Boolean value 'true' to type Float", + "locations" : [ { "line" : 4, "column" : 9 } ], + "path" : [ "badFloat" ] + }, + { + "message" : "Cannot coerce JSON Number value '1' to type Boolean", + "locations" : [ { "line" : 5, "column" : 9 } ], + "path" : [ "badBool" ] + } + ], + "data" : { + "badInt" : null, + "badFloat" : null, + "badBool" : null + } + } + """ + + val res = TestCirceScalarCoercionMapping.compileAndRun(query) + + assertIO(res, expected) + } + + test("filters compare coerced values") { + import TestCirceScalarCoercionMapping._ + + val cursor = + circeCursor( + Path.from(QueryType), + Env.empty, + json"""{ "bigInt": 3000000000, "intFromString": "42", "idFromInt": 23, "boolFromString": "true" }""" + ) + + def eql(field: String, value: Json): Predicate = + Eql(UniquePath[Json](List(field)), Const(value)) + + assertEquals(eql("bigInt", Json.fromLong(3000000000L))(cursor).toOption, Some(true)) + assertEquals(eql("intFromString", Json.fromInt(42))(cursor).toOption, Some(true)) + assertEquals(eql("intFromString", Json.fromString("42"))(cursor).toOption, Some(false)) + assertEquals(eql("idFromInt", Json.fromString("23"))(cursor).toOption, Some(true)) + assertEquals(eql("boolFromString", Json.True)(cursor).toOption, Some(true)) + } }