diff --git a/modules/generic/src/main/scala-2/genericmapping2.scala b/modules/generic/src/main/scala-2/genericmapping2.scala index e6460ed3..60fedab8 100644 --- a/modules/generic/src/main/scala-2/genericmapping2.scala +++ b/modules/generic/src/main/scala-2/genericmapping2.scala @@ -33,7 +33,7 @@ trait ScalaVersionSpecificGenericMappingLike[F[_]] extends Mapping[F] { self: Ge ): MkObjectCursorBuilder[T] = new MkObjectCursorBuilder[T] { def apply(tpe: Type): ObjectCursorBuilder[T] = { - def fieldMap: Map[String, (Context, T, Option[Cursor], Env) => Result[Cursor]] = { + def fieldMap: FieldMap[T] = { val keys: List[String] = unsafeToList[Symbol](keys0()).map(_.name) val elems = unsafeToList[CursorBuilder[Any]](elems0.instances) keys.zip(elems.zipWithIndex).map { diff --git a/modules/generic/src/main/scala/Argument.scala b/modules/generic/src/main/scala/Argument.scala new file mode 100644 index 00000000..5ddc75bb --- /dev/null +++ b/modules/generic/src/main/scala/Argument.scala @@ -0,0 +1,34 @@ +// Copyright (c) 2016-2020 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package edu.gemini.grackle +package generic + +import edu.gemini.grackle._ +import edu.gemini.grackle.Query._ +import cats.syntax.all._ + +final case class Argument[A](name: String, extract: Value => Option[A]) +final case class FieldArguments(query: String, extractors: List[Argument[_]]) + +object FieldArguments { + def moveArgsToEnv(fieldArguments: List[FieldArguments]): PartialFunction[Select, Result[Query]] = + PartialFunction + .fromFunction[Select, Option[Result[Query]]] { + case Select(query, bindings, child) => + fieldArguments.collectFirst { + case se if se.query === query => Result(Environment(buildEnv(bindings, se), Select(query, Nil, child))) + } + } + .andThen { case Some(value) => value } + + def buildEnv(bindings: List[Binding], se: FieldArguments): Cursor.Env = + bindings.foldLeft(Cursor.Env.empty) { + case (accEnv, Binding(name, value)) => + val newEnv = se.extractors + .collectFirst { case e if e.name === name => e.extract(value) } + .map(e => Cursor.Env(name -> e)) + .getOrElse(Cursor.Env.empty) + accEnv.add(newEnv) + } +} diff --git a/modules/generic/src/main/scala/EffMappingBuilder.scala b/modules/generic/src/main/scala/EffMappingBuilder.scala new file mode 100644 index 00000000..e58b7a5d --- /dev/null +++ b/modules/generic/src/main/scala/EffMappingBuilder.scala @@ -0,0 +1,33 @@ +// Copyright (c) 2016-2020 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package edu.gemini.grackle +package generic + +import edu.gemini.grackle._ +import cats._ + +final case class EffMappingBuilder[F[_]](queries: List[EffQuery[F]]) { + + def withQuery(eq: EffQuery[F]): EffMappingBuilder[F] = EffMappingBuilder(eq :: queries) + + // NOTE - there's an assumption that schema must have a Query top-level type. This would go in docs. + def build(schema0: Schema)(implicit m: Monad[F]): GenericMapping[F] = { + val rootQueryType = schema0.ref("Query") + new GenericMapping[F] { + def effects: List[RootEffect] = queries.map(_.buildRootEffect(this)) + final override val schema: Schema = schema0 + final override val typeMappings: List[TypeMapping] = + List(ObjectMapping(rootQueryType, effects)) + final override val selectElaborator: QueryCompiler.SelectElaborator = + new QueryCompiler.SelectElaborator( + Map(rootQueryType -> FieldArguments.moveArgsToEnv(queries.flatMap(_.argExtractors))) + ) + } + } +} + +object EffMappingBuilder { + def empty[F[_]]: EffMappingBuilder[F] = EffMappingBuilder(Nil) + def single[F[_]](eq: EffQuery[F]) = EffMappingBuilder(List(eq)) +} diff --git a/modules/generic/src/main/scala/EffQuery.scala b/modules/generic/src/main/scala/EffQuery.scala new file mode 100644 index 00000000..c1e76978 --- /dev/null +++ b/modules/generic/src/main/scala/EffQuery.scala @@ -0,0 +1,106 @@ +// Copyright (c) 2016-2020 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package edu.gemini.grackle +package generic + +import edu.gemini.grackle._ +import edu.gemini.grackle.Cursor.Env +import cats._ +import cats.syntax.all._ + +import scala.reflect.ClassTag + +final case class EffQuery[F[_]]( + topLevelField: String, + argExtractors: List[FieldArguments], + effectfulCursor: (Path, Env) => F[Result[Cursor]] +) { + def buildRootEffect(mapping: GenericMapping[F]): mapping.RootEffect = + mapping.RootEffect.computeCursor(topLevelField)((_, path, env) => effectfulCursor(path, env)) +} + +object EffQuery { + def noArgs[F[_]: MonadThrow, A: CursorBuilder](topLevelField: String, process: F[A]): EffQuery[F] = + EffQuery(topLevelField, Nil, effectfulCursor[F, A](_ => process.map(Result(_)))) + + def arg1[F[_]: MonadThrow, Arg1: ClassTag, A: CursorBuilder]( + topLevelField: String, + process: Arg1 => F[A], + arg: Argument[Arg1] + ): EffQuery[F] = + EffQuery( + topLevelField, + List(FieldArguments(topLevelField, List(arg))), + effectfulCursor(processWithArg[F, Arg1, A](arg.name, _, process)) + ) + + def arg2[F[_]: MonadThrow, Arg1: ClassTag, Arg2: ClassTag, A: CursorBuilder]( + topLevelField: String, + process: (Arg1, Arg2) => F[A], + arg1: Argument[Arg1], + arg2: Argument[Arg2] + ): EffQuery[F] = + EffQuery( + topLevelField, + List(FieldArguments(topLevelField, List(arg1, arg2))), + effectfulCursor(processWithArg2[F, Arg1, Arg2, A](arg1.name, arg2.name, _, process.curried)) + ) + + def arg3[F[_]: MonadThrow, Arg1: ClassTag, Arg2: ClassTag, Arg3: ClassTag, A: CursorBuilder]( + topLevelField: String, + process: (Arg1, Arg2, Arg3) => F[A], + arg1: Argument[Arg1], + arg2: Argument[Arg2], + arg3: Argument[Arg3] + ): EffQuery[F] = + EffQuery( + topLevelField, + List(FieldArguments(topLevelField, List(arg1, arg2, arg3))), + effectfulCursor(processWithArg3[F, Arg1, Arg2,Arg3, A](arg1.name, arg2.name, arg3.name, _, process.curried)) + ) + + def effectfulCursor[F[_]: MonadThrow, A: CursorBuilder]( + process: Env => F[Result[A]] + )(path: Path, env: Env): F[Result[Cursor]] = + process(env) + .map(_.flatMap(GenericMapping.genericCursor(path, env, _))) + .handleError(e => Result.failure[Cursor](errorMessage(e))) + + def processWithArg[F[_]: Applicative, Arg: ClassTag, A]( + argName: String, + env: Env, + process: Arg => F[A] + ): F[Result[A]] = + argOrFail[Arg](argName, env).traverse(process) + + def processWithArg2[F[_]: Applicative, Arg1: ClassTag, Arg2: ClassTag, A]( + arg1Name: String, + arg2Name: String, + env: Env, + process: Arg1 => Arg2 => F[A] + ): F[Result[A]] = + argOrFail[Arg1](arg1Name, env).map(process).flatTraverse(processWithArg[F, Arg2, A](arg2Name, env, _)) + + def processWithArg3[F[_]: Applicative, Arg1: ClassTag, Arg2: ClassTag, Arg3: ClassTag, A]( + arg1Name: String, + arg2Name: String, + arg3Name: String, + env: Env, + process: Arg1 => Arg2 => Arg3 => F[A] + ): F[Result[A]] = + argOrFail[Arg1](arg1Name, env) + .map(process) + .flatTraverse(processWithArg2[F, Arg2, Arg3, A](arg2Name, arg3Name, env, _)) + + private def argOrFail[Arg1: ClassTag](name: String, env: Env) = + env.get[Arg1](name).fold(missingArgFailure[Arg1](name, env))(Result(_)) + + private def missingArgFailure[A](argName: String, env: Env): Result[A] = + Result.failure[A](s"Missing argument `$argName` in $env") + + private def errorMessage(error: Throwable): String = + Option(error.getMessage) + .orElse(Option(error.getCause).map(_.getMessage)) + .getOrElse("Effectful query failed without a useful error message.") +} diff --git a/modules/generic/src/main/scala/EffQueryBuilder.scala b/modules/generic/src/main/scala/EffQueryBuilder.scala new file mode 100644 index 00000000..7515d132 --- /dev/null +++ b/modules/generic/src/main/scala/EffQueryBuilder.scala @@ -0,0 +1,61 @@ +// Copyright (c) 2016-2020 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package edu.gemini.grackle +package generic + +import edu.gemini.grackle._ +import cats._ + +import scala.reflect.ClassTag + +object EffQueryBuilder { + def noArgs[F[_]] = EffQueryBuilder[F](Nil) +} + +// TODO split this out into an external facing module, with internal separate. Make the intention obvious. +final case class EffQueryBuilder[F[_]](argExtractors: List[FieldArguments]) { + def withInt(name: String) = withArg[Int](name) + def withId(name: String) = withArgPf[String](name, { case Value.IDValue(s) => s }) + def withString(name: String) = withArg[String](name) + def withFloat(name: String) = withArg[Double](name) + def withBool(name: String) = withArg[Boolean](name) + + def withArg[Arg: FromQueryValue: ClassTag](name: String) = + withArgPf[Arg](name, FromQueryValue.toPartialFunction[Arg]) + + def withArgPf[Arg: ClassTag](name: String, extractor: PartialFunction[Value, Arg]): EffQueryBuilderArg1[F, Arg] = + EffQueryBuilderArg1(Argument(name, extractor.lift)) + + def build[A: CursorBuilder](query: String, process: F[A])(implicit f: MonadThrow[F]): EffQuery[F] = EffQuery.noArgs(query, process) + +} + +final case class EffQueryBuilderArg1[F[_], Arg1: ClassTag](arg: Argument[Arg1]) { + def withInt(name: String) = withArg[Int](name) + def withId(name: String) = withArgPf[String](name, { case Value.IDValue(s) => s }) + def withString(name: String) = withArg[String](name) + def withFloat(name: String) = withArg[Double](name) + def withBool(name: String) = withArg[Boolean](name) + + def withArg[Arg: FromQueryValue: ClassTag](name: String) = + withArgPf[Arg](name, FromQueryValue.toPartialFunction[Arg]) + + def withArgPf[Arg: ClassTag]( + name: String, + extractor: PartialFunction[Value, Arg] + ): EffQueryBuilderArg2[F, Arg1, Arg] = + EffQueryBuilderArg2(arg, Argument(name, extractor.lift)) + + def build[A: CursorBuilder](query: String, process: Arg1 => F[A])(implicit f: MonadThrow[F]): EffQuery[F] = + EffQuery.arg1(query, process, arg) +} + +final case class EffQueryBuilderArg2[F[_], Arg1: ClassTag, Arg2: ClassTag]( + arg1: Argument[Arg1], + arg2: Argument[Arg2] +) { + + def build[A: CursorBuilder](query: String, process: (Arg1, Arg2) => F[A])(implicit f: MonadThrow[F]): EffQuery[F] = + EffQuery.arg2(query, process, arg1, arg2) +} diff --git a/modules/generic/src/main/scala/FromQueryValue.scala b/modules/generic/src/main/scala/FromQueryValue.scala new file mode 100644 index 00000000..253ee8c6 --- /dev/null +++ b/modules/generic/src/main/scala/FromQueryValue.scala @@ -0,0 +1,52 @@ +// Copyright (c) 2016-2020 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package edu.gemini.grackle +package generic + +import edu.gemini.grackle._ +import cats.syntax.all._ + +import scala.util.Try +import java.time.ZonedDateTime + +trait FromQueryValue[A] { + def attempt(value: Value): Option[A] + + final def flatMap[B](f: A => Option[B]): FromQueryValue[B] = attempt(_).flatMap(f) +} + +object FromQueryValue { + def apply[A: FromQueryValue] = implicitly[FromQueryValue[A]] + def toPartialFunction[A: FromQueryValue]: PartialFunction[Value, A] = + PartialFunction.fromFunction(FromQueryValue[A].attempt).andThen { case Some(a) => a } + + implicit val string: FromQueryValue[String] = { + case Value.StringValue(s) => Some(s) + case _ => None + } + + implicit val float: FromQueryValue[Double] = { + case Value.FloatValue(s) => Some(s) + case _ => None + } + + implicit val int: FromQueryValue[Int] = { + case Value.IntValue(s) => Some(s) + case _ => None + } + + implicit val bool: FromQueryValue[Boolean] = { + case Value.BooleanValue(s) => Some(s) + case _ => None + } + + implicit val zdt: FromQueryValue[ZonedDateTime] = string.flatMap(s => Try(ZonedDateTime.parse(s)).toOption) + + implicit def opt[A: FromQueryValue]: FromQueryValue[Option[A]] = FromQueryValue[A].attempt(_).map(Option(_)) + + implicit def list[A: FromQueryValue]: FromQueryValue[List[A]] = { + case Value.ListValue(elems) => elems.traverse(x => FromQueryValue[A].attempt(x)) + case _ => None + } +} diff --git a/modules/generic/src/main/scala/genericmapping.scala b/modules/generic/src/main/scala/genericmapping.scala index 726d1495..b95535a7 100644 --- a/modules/generic/src/main/scala/genericmapping.scala +++ b/modules/generic/src/main/scala/genericmapping.scala @@ -16,11 +16,8 @@ trait GenericMappingLike[F[_]] extends ScalaVersionSpecificGenericMappingLike[F] type CursorBuilder[T] = edu.gemini.grackle.generic.CursorBuilder[T] - def genericCursor[T](path: Path, env: Env, t: T)(implicit cb: => CursorBuilder[T]): Result[Cursor] = - if(path.isRoot) - cb.build(Context(path.rootTpe), t, None, env) - else - DeferredCursor(path, (context, parent) => cb.build(context, t, Some(parent), env)).rightIor + def genericCursor[T](path: Path, env: Env, t: T)(implicit cb: => CursorBuilder[T]): Result[Cursor] = + GenericMapping.genericCursor(path, env, t)(cb) override def mkCursorForField(parent: Cursor, fieldName: String, resultName: Option[String]): Result[Cursor] = { val context = parent.context @@ -56,3 +53,11 @@ trait GenericMappingLike[F[_]] extends ScalaVersionSpecificGenericMappingLike[F] def transformField[U](fieldName: String)(f: T => Result[U])(implicit cb: => CursorBuilder[U]): ObjectCursorBuilder[T] } } + +object GenericMapping { + def genericCursor[T](path: Path, env: Env, t: T)(implicit cb: => CursorBuilder[T]): Result[Cursor] = + if(path.isRoot) + cb.build(Context(path.rootTpe), t, None, env) + else + DeferredCursor(path, (context, parent) => cb.build(context, t, Some(parent), env)).rightIor +} \ No newline at end of file diff --git a/modules/generic/src/test/scala/effectssimple.scala b/modules/generic/src/test/scala/effectssimple.scala new file mode 100644 index 00000000..a31c8ec1 --- /dev/null +++ b/modules/generic/src/test/scala/effectssimple.scala @@ -0,0 +1,108 @@ +// Copyright (c) 2016-2020 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package edu.gemini.grackle +package generic + +import edu.gemini.grackle.syntax._ +import cats.tests.CatsSuite +import cats.effect.unsafe.implicits.global +import cats.effect.IO +import java.time.ZonedDateTime +import io.circe.Json + +// Higher-level example to demonstrate building a read-only mapping backed by effectful functions +object BlogMapping { + val schema = + schema""" + type Query { + blog(id: ID): Blog + blogs(before: DateTime, ids: [ID!]): [Blog!]! + } + + scalar Uri + scalar DateTime + + type Blog { + id: ID! + title: String! + link: Uri! + date: DateTime! + } + """ + + def blogs: List[Blog] = + List( + Blog("hello-world", "", "", ZonedDateTime.now()) + ) + + def blogProcessor(id: String): IO[Option[Blog]] = + IO { + blogs.find(_.id == id) + } + + def blogsProcessor(maybeBefore: Option[ZonedDateTime], ids: Option[List[String]]): IO[List[Blog]] = + IO { + val before = maybeBefore.getOrElse(ZonedDateTime.now()) + ids match { + case None => blogs.filter(_.dateTime.isBefore(before)) + case Some(value) => blogs.filter(x => x.dateTime.isBefore(before) && value.contains(x.id)) + } + } + +} + +final case class Blog(id: String, title: String, link: String, dateTime: ZonedDateTime) + +object Blog { + // TODO: It's not currently possible to write this instance due to the scope of `semiauto` + // and there not being a way to manually define a `CursorBuilder`. This is the final piece, other than polishing the API + // (Clean up argument building, extract type classes, etc). + implicit def cb: CursorBuilder[Blog] = ??? +} + +final class BlogMappingSpec extends CatsSuite { + import BlogMapping._ + + test("generic effect") { + val query = """ + query { + blog(id: "hello-world") { + title + dateTime + } + } + """ + + val expected = json""" + { + "data" : { + "foo" : { + "s" : "hi", + "n" : 42 + } + } + } + """ + + val blogsQuery: EffQuery[IO] = EffQueryBuilder + .noArgs[IO] + .withArg[Option[ZonedDateTime]]("before") + .withArg[Option[List[String]]]("ids") + .build("blogs", blogsProcessor(_, _)) + + val blogQuery: EffQuery[IO] = + EffQueryBuilder.noArgs[IO].withString("id").build("blog", blogProcessor(_)) + + val thingy: GenericMapping[IO] = + EffMappingBuilder + .single[IO](blogQuery) + .withQuery(blogsQuery) + .build(BlogMapping.schema) + + val result: Json = thingy.compileAndRun(query).unsafeRunSync() + // println(result) + + assert(result == expected) + } +}