Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 46 additions & 9 deletions modules/circe/src/main/scala/circemapping.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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}")
Expand Down
47 changes: 47 additions & 0 deletions modules/circe/src/test/scala/CirceData.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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))
)
)
)
}
154 changes: 154 additions & 0 deletions modules/circe/src/test/scala/CirceSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
Expand Down Expand Up @@ -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))
}
}
Loading