From df39845fefb71450f97f77cbfc40e46d7506be1c Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Sun, 11 Aug 2019 16:04:41 -0400 Subject: [PATCH 01/22] Checkpoint: first compiling and minimal solution for GraphQL This is by no means done, but I've now gotten to the bottom of the stack for a few primitive types, and it's all compiling, so let's check it in. --- querki/build.sbt | 8 +- .../app/querki/graphql/FPComputeGraphQL.scala | 382 ++++++++++++++++++ 2 files changed, 387 insertions(+), 3 deletions(-) create mode 100644 querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala diff --git a/querki/build.sbt b/querki/build.sbt index 51197c82e..f2395d2e6 100644 --- a/querki/build.sbt +++ b/querki/build.sbt @@ -61,8 +61,8 @@ lazy val querkiServer = (project in file("scalajvm")).settings( "ai.x" %% "diff" % "1.2.0" % "test", // Only used for debugging at this point: "com.github.pathikrit" %% "better-files" % "2.17.1", - "org.typelevel" %% "cats-core" % "1.3.1", - "org.typelevel" %% "cats-effect" % "1.0.0", + "org.typelevel" %% "cats-core" % "1.6.1", + "org.typelevel" %% "cats-effect" % "1.3.1", "com.github.julien-truffaut" %% "monocle-core" % "1.5.0", "com.github.julien-truffaut" %% "monocle-macro" % "1.5.0", // Updated version of the XML library: @@ -76,7 +76,9 @@ lazy val querkiServer = (project in file("scalajvm")).settings( // In-memory Akka Persistence driver, used for tests. Note that this is for Akka 2.4! "com.github.dnvriend" %% "akka-persistence-inmemory" % "1.3.9" % "test", // In-memory H2 database, used for tests: - "com.h2database" % "h2" % "1.4.192" % "test" + "com.h2database" % "h2" % "1.4.192" % "test", + // For graphql processing: + "org.sangria-graphql" %% "sangria" % "1.4.2" ), // ConductR params diff --git a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala new file mode 100644 index 000000000..abfa3951b --- /dev/null +++ b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala @@ -0,0 +1,382 @@ +package querki.graphql + +import cats.data._ +import cats.data.Validated._ +import cats.effect.IO +import cats.implicits._ +import models.{Thing, OID, ThingId, PType, Property} +import play.api.libs.json._ +import querki.core.MOIDs.{QSetOID, QListOID, ExactlyOneOID, OptionalOID} +import querki.core.MOIDs.{TextTypeOID, LargeTextTypeOID, IntTypeOID, LinkTypeOID} +import querki.core.QLText +import querki.globals._ +import querki.values.{PropAndVal, QValue} +import sangria.ast._ +import sangria.parser.QueryParser + +import scala.util.{Success, Failure} + +trait JsValueable[VT] { + def toJsValue(v: VT): JsValue +} + +object JsValueable { + implicit val intJsValueable = new JsValueable[Int] { + def toJsValue(v: Int) = JsNumber(v) + } + + implicit val textJsValueable = new JsValueable[QLText] { + def toJsValue(v: QLText) = JsString(v.text) + } + + // TODO: instead of returning the OID, we should dive down the graph here: + implicit val linkJsValueable = new JsValueable[OID] { + def toJsValue(v: OID): JsValue = JsString(v.toString) + } + + implicit class JsValueableOps[T: JsValueable](t: T) { + def toJsValue: JsValue = { + implicitly[JsValueable[T]].toJsValue(t) + } + } +} + +class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { + import JsValueable._ + + final val thingQueryName = "_thing" + final val instancesQueryName = "_instances" + final val idArgName = "oid" + final val nameArgName = "name" + + type SyncRes[T] = ValidatedNec[GraphQLError, T] + type Res[T] = EitherT[IO, NonEmptyChain[GraphQLError], T] + + lazy val Core = ecology.api[querki.core.Core] + + def processQuery(query: String): IO[ValidatedNec[GraphQLError, JsValue]] = { + val fields: SyncRes[Vector[Field]] = + parseQuery(query) andThen + (getDefinitions(_)) andThen + (_.map(confirmIsQuery(_)).nonEmptySequence) andThen + (singleOperation(_)) andThen + (_.selections.map(confirmIsField).sequence) + + fields match { + case Valid(sels) => { + val queryResults: Vector[Res[(String, JsValue)]] = sels.map(processQuerySelection(_)) + val inverted: Res[Vector[(String, JsValue)]] = queryResults.sequence + // Surely we can do this better by defining a Semigroup over JsObject: + val result: Res[JsValue] = inverted.map { pairs => + pairs.foldLeft(JsObject(Seq.empty)) { case (obj, (name, jsv)) => + obj + (name -> jsv) + } + } + result.value.map { either => + either.toValidated + } + } + case Invalid(err) => IO.pure(err.invalid) + } + } + + def parseQuery(query: String): SyncRes[Document] = { + QueryParser.parse(query) match { + case Success(document) => document.validNec + case Failure(ex) => ParseFailure(ex.getMessage).invalidNec + } + } + + def getDefinitions(doc: Document): SyncRes[NonEmptyList[Definition]] = { + NonEmptyList.fromList(doc.definitions.toList) match { + case Some(defs) => defs.valid + case None => NoDefinitions.invalidNec + } + } + + def confirmIsQuery(definition: Definition): SyncRes[OperationDefinition] = { + definition match { + case op: OperationDefinition if (op.operationType == OperationType.Query) => op.valid + // TODO: deal with other Operation types! + case op: OperationDefinition => UnhandledOperationType(op.operationType).invalidNec + // TODO: deal with other Definition types! + case _ => UnhandledDefinitionType(definition).invalidNec + } + } + + def confirmIsField(selection: Selection): SyncRes[Field] = { + selection match { + case field: Field => field.valid + case _ => UnhandledSelectionType(selection).invalidNec + } + } + + def singleOperation(ops: NonEmptyList[OperationDefinition]): SyncRes[OperationDefinition] = { + if (ops.length > 1) + TooManyOperations.invalidNec + else + ops.head.valid + } + + /** + * Processes a top-level Query selection. + * + * These are somewhat different, since they follow certain required rules. Lower-level selections are starting + * from an existing set of Things, so they have a lot more flexibility. + * + * This is an IO, because we can have Futures down underneath in the processing. + * + * This returns a name/value pair. The name is the name of the top-level selection; the value is the contents + * of what we found for that selection. + */ + def processQuerySelection(field: Field): Res[(String, JsValue)] = { + if (field.name == thingQueryName) { + // This is a specific-thing query + withThingFromArgument(field, thingQueryName) { thing => + processThing(thing, field) + } + } else if (field.name == instancesQueryName) { + // This is querying all instances of a Model + withThingFromArgument(field, instancesQueryName) { model => + val things = state.descendants(model.id, includeModels = false, includeInstances = true).toList + val processedThings: Res[List[JsValue]] = things.map { thing => + processThing(thing, field) + }.sequence + processedThings.map(JsArray(_)) + } + } else { + resError(IllegalTopSelection(field.name)) + } + } + + /** + * We expect this Field to have either an OID or Name argument, specifying a Thing. Find that Thing, run the + * provided processing function, and tuple the result with the selectionName for the resulting JSON. + */ + def withThingFromArgument(field: Field, selectionName: String)(f: Thing => Res[JsValue]): Res[(String, JsValue)] = { + for { + thing <- getThingFromArgument(field, selectionName).toRes + jsValue <- f(thing) + } + yield (selectionName, jsValue) + } + + /** + * This field should have an argument specifying a Thing. Find that Thing. + */ + def getThingFromArgument(field: Field, selectionName: String): SyncRes[Thing] = { + field.getArgumentStr(idArgName) match { + case Valid(Some(oidStr)) => { + OID.parseOpt(oidStr) match { + case Some(oid) => { + state.anything(oid) match { + case Some(thing) => thing.valid + case None => OIDNotFound(oidStr).invalidNec + } + } + case None => NotAnOID(oidStr).invalidNec + } + } + case Valid(None) => { + field.getArgumentStr(nameArgName) match { + case Valid(Some(thingName)) => { + state.anythingByName(thingName) match { + case Some(thing) => thing.valid + case None => NameNotFound(thingName).invalidNec + } + } + case Valid(None) => MissingRequiredArgument(field, selectionName, s"$idArgName or $nameArgName").invalidNec + case Invalid(err) => err.invalid + } + } + case Invalid(err) => err.invalid + } + } + + /** + * The given Field specifies a Thing. Given that Thing, process its selections, and return the JsValue of the + * results. + */ + def processThing(thing: Thing, parentField: Field): Res[JsValue] = { + val selectionResults: Res[Vector[(String, JsValue)]] = parentField.selections.map { selection => + for { + childField <- confirmIsField(selection).toRes + jsValue <- processField(thing, childField) + } + yield (childField.name, jsValue) + }.sequence + selectionResults.map { pairs => + val pairMap = pairs.toMap + JsObject(pairMap) + } + } + + /** + * Given a Thing, process one Field that represents a Property of that Thing. + */ + def processField(thing: Thing, field: Field): Res[JsValue] = { + getProperty(thing, field).toRes.flatMap { prop => + processProperty(thing, prop, field) + } + } + + def getProperty(thing: Thing, field: Field): SyncRes[Property[_, _]] = { + val name = field.name + val propOpt = for { + thingId <- ThingId.parseOpt(name) + thing <- state.anything(thingId) + prop <- asProp(thing) + } + yield prop + + propOpt.syncOrError(UnknownProperty(name)) + } + + def processProperty(thing: Thing, prop: Property[_, _], field: Field): Res[JsValue] = { + // TODO: this is ugly and coded -- can we make it better? Do note that we need to rehydrate the types here + // *somehow*, so this isn't trivial: + prop.pType.id match { + case IntTypeOID => processTypedProperty(thing, prop.confirmType(Core.IntType), Core.IntType, field) + case TextTypeOID => processTypedProperty(thing, prop.confirmType(Core.TextType), Core.TextType, field) + case LargeTextTypeOID => processTypedProperty(thing, prop.confirmType(Core.LargeTextType), Core.LargeTextType, field) + case LinkTypeOID => processTypedProperty(thing, prop.confirmType(Core.LinkType), Core.LinkType, field) + // TODO: More types! + } + } + + def processTypedProperty[VT: JsValueable](thing: Thing, propOpt: Option[Property[VT, _]], pt: PType[VT], field: Field): Res[JsValue] = { + val resultOpt: Option[Res[JsValue]] = propOpt.map { prop => + thing.getPropOpt(prop) match { + case Some(propAndVal) => processValues(thing, prop, propAndVal.rawList, field) + case None => resError(PropertyNotOnThing(thing, prop)) + } + } + resultOpt.getOrElse(resError(InternalGraphQLError("Hit a Property whose type doesn't confirm!"))) + } + + def processValues[VT: JsValueable](thing: Thing, prop: Property[VT, _], vs: List[VT], field: Field): Res[JsValue] = { + // What we return depends on the Collection of this Property: + prop.cType match { + case ExactlyOneOID => { + if (vs.isEmpty) { + resError(MissingRequiredValue(thing, prop)) + } else { + res(vs.head.toJsValue) + } + } + case OptionalOID => { + vs.headOption match { + case Some(v) => res(v.toJsValue) + // TODO: check the GraphQL spec -- is JsNull correct here? + case None => res(JsNull) + } + } + case QListOID | QSetOID => { + val jsvs = vs.map(_.toJsValue) + res(JsArray(jsvs)) + } + } + } + + def res[T](v: T): Res[T] = { + EitherT.rightT(v) + } + def resError[T](err: => GraphQLError): Res[T] = { + EitherT.leftT(NonEmptyChain(err)) + } + + def asProp(thing: Thing): Option[Property[_, _]] = { + thing match { + case t: Property[_, _] => Some(t) + case _ => None + } + } + + implicit class RichOption[T](tOpt: Option[T]) { + def syncOrError(err: => GraphQLError): SyncRes[T] = { + tOpt match { + case Some(t) => t.valid + case None => err.invalidNec + } + } + } + + implicit class RichField(field: Field) { + def getArgumentStr(name: String): SyncRes[Option[String]] = { + field.arguments.find(_.name == name) match { + case Some(arg) => arg.value match { + case v: StringValue => Some(v.value).valid + case _ => UnhandledArgumentType(arg).invalidNec + } + case None => None.valid + } + } + def getRequiredArgumentStr(name: String): SyncRes[String] = { + getArgumentStr(name) andThen { + _ match { + case Some(v) => v.valid + case None => MissingRequiredArgument(field, name, "string").invalidNec + } + } + } + } + + implicit class RichSyncRes[T](syncRes: SyncRes[T]) { + def toRes: Res[T] = { + syncRes match { + case Valid(t) => EitherT.rightT(t) + case Invalid(e) => EitherT.leftT(e) + } + } + } +} + +sealed trait GraphQLError { + def msg: String +} +case class ParseFailure(msg: String) extends GraphQLError +case object NoDefinitions extends GraphQLError { val msg = "No Definitions provided in the GraphQL Document!" } +case class UnhandledOperationType(opType: OperationType) extends GraphQLError { + val msg = s"Querki does not yet deal with ${opType.getClass.getSimpleName} operations; sorry." +} +case class UnhandledDefinitionType(definition: Definition) extends GraphQLError { + def msg = s"Querki can not yet deal with ${definition.getClass.getSimpleName}" +} +case object TooManyOperations extends GraphQLError { val msg = "Querki can currently only deal with one Query at a time; sorry." } +case class UnhandledSelectionType(selection: Selection) extends GraphQLError { + def msg = s"Querki can not yet deal with ${selection.getClass.getSimpleName} selections; sorry." +} +case class UnhandledArgumentType(arg: Argument) extends GraphQLError { + def msg = s"Querki can not yet deal with ${arg.value.getClass.getSimpleName} arguments there; sorry." +} +case class MissingRequiredArgument(field: Field, name: String, tpe: String) extends GraphQLError { + def msg = s"Field ${field.name} requires a $tpe argument named '$name'" +} +case class UnknownThing(name: String) extends GraphQLError { + def msg = s"There is no Thing named $name" +} +case class IllegalTopSelection(name: String) extends GraphQLError { + def msg = s"The top level of a GraphQL query must be _thing or _instances." +} +case class NotAnOID(str: String) extends GraphQLError { + def msg = s"$str is not a valid OID" +} +case class OIDNotFound(oid: String) extends GraphQLError { + def msg = s"No Thing found with OID $oid" +} +case class NameNotFound(name: String) extends GraphQLError { + def msg = s"No Thing found named $name -- maybe that isn't the correct Link Name?" +} +case class UnknownProperty(name: String) extends GraphQLError { + def msg = s"Unknown Property: $name" +} +case class PropertyNotOnThing(thing: Thing, prop: AnyProp) extends GraphQLError { + def msg = s"${thing.displayName} does not have requested property ${prop.displayName}" +} +case class UnsupportedType(prop: Property[_, _]) extends GraphQLError { + def msg = s"Querki GraphQL does not yet support ${prop.pType.displayName} properties; sorry." +} +case class InternalGraphQLError(msg: String) extends GraphQLError +case class MissingRequiredValue(thing: Thing, prop: Property[_, _]) extends GraphQLError { + def msg = s"Required Property ${prop.displayName} on Thing ${thing.displayName} is empty!" +} \ No newline at end of file From 594785b7c4d83334d956cb6baa10af222d0584e9 Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Tue, 13 Aug 2019 17:55:49 -0400 Subject: [PATCH 02/22] It's ALIVE!!! After a bunch of hacking, we've landed on the much-cleaner FPComputeGraphQL class, which is pure-FP, easier to understand, and actually working for the basics. Not actually unit-tested yet, but the initial test class prints out a couple of queries, and they look as expected. --- querki/scalajvm/app/models/Property.scala | 2 + .../app/querki/graphql/ComputeGraphQL.scala | 166 ++++++++++ .../app/querki/graphql/FPComputeGraphQL.scala | 127 +++++--- .../querki/graphql/OldComputeGraphQL.scala | 299 ++++++++++++++++++ .../querki/graphql/ComputeGraphQLTests.scala | 41 +++ .../graphql/OldComputeGraphQLTests.scala | 30 ++ 6 files changed, 623 insertions(+), 42 deletions(-) create mode 100644 querki/scalajvm/app/querki/graphql/ComputeGraphQL.scala create mode 100644 querki/scalajvm/app/querki/graphql/OldComputeGraphQL.scala create mode 100644 querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala create mode 100644 querki/scalajvm/test/querki/graphql/OldComputeGraphQLTests.scala diff --git a/querki/scalajvm/app/models/Property.scala b/querki/scalajvm/app/models/Property.scala index 01e087c83..617fbc797 100644 --- a/querki/scalajvm/app/models/Property.scala +++ b/querki/scalajvm/app/models/Property.scala @@ -28,6 +28,8 @@ case class Property[VT, RT]( mt:DateTime)(implicit val ecology:Ecology) extends Thing(i, s, m, Kind.Property, pf, mt) { + type valType = VT + def Core = ecology.api[querki.core.Core] def default(implicit state:SpaceState) = { diff --git a/querki/scalajvm/app/querki/graphql/ComputeGraphQL.scala b/querki/scalajvm/app/querki/graphql/ComputeGraphQL.scala new file mode 100644 index 000000000..29758f591 --- /dev/null +++ b/querki/scalajvm/app/querki/graphql/ComputeGraphQL.scala @@ -0,0 +1,166 @@ +package querki.graphql + +import models.{Thing, OID} +import play.api.libs.json.{JsObject, JsValue, JsArray} +import querki.globals.{SpaceState, Ecology} +import querki.util.PublicException +import sangria.ast._ +import sangria.parser.QueryParser + +import scala.concurrent.{Future, ExecutionContext} +import scala.util.{Success, Failure} + +/** + * This object is basically a wrapper around the processQuery() function. + * + * TODO: rewrite this stack in IO terms -- it would be much cleaner and more efficient than raw Futures. And we + * should probably use Cats Validated: trying to do this with pre-2.12 Either is just an exercise in annoyance. + */ +class ComputeGraphQL(implicit state: SpaceState, ec: ExecutionContext, e: Ecology) { + + final val thingQueryName = "_thing" + + type Res = Future[Either[PublicException, JsObject]] + + /** + * Main entry point -- takes a stringified GraphQL query and returns a response. + * + * This returns a Future so that we can potentially do more serious processing in here, when we get + * more sophisticated. + */ + def processQuery(query: String): Res = { + QueryParser.parse(query) match { + case Success(document) => processQuery(document) + case Failure(ex) => ??? // TODO: transform to a Left[PublicException] + } + } + + /** + * We've parsed the query, now actually process it. + */ + def processQuery(document: Document): Res = { + // TODO: deal with an empty definitions list + // TODO: deal with multiple definitions + processDefinition(document.definitions.head) + } + + def processDefinition(definition: Definition): Res = { + definition match { + case od: OperationDefinition => processOperation(od) + case _ => ??? // TODO: handle others, and error on the ones we don't want + } + } + + def coalesceResults(futs: Vector[Res]): Res = { + Future.sequence(futs).map { eithers => + eithers.find(_.isLeft).getOrElse { + // Okay, they all succeeded + val objects: Vector[JsObject] = eithers.map(_.right.get) + val oneObject: JsObject = objects.reduce(_ ++ _) + Right(oneObject) + } + } + } + + /** + * A single Query or Mutation operation. + * + * This is the real guts of the functionality. + */ + def processOperation(operation: OperationDefinition): Res = { + if (operation.operationType != OperationType.Query) { + // TODO: error for now + // TODO: deal with mutations + ??? + } else { + val result = coalesceResults(operation.selections.map(processTopSelection)) + // Wrap the results in a top-level "data" object: + result.map(_.right.map(jsObject => JsObject(Map("data" -> jsObject)))) + } + } + + /** + * A single "selection" -- more or less a distinct lookup. + */ + def processTopSelection(selection: Selection): Res = { + selection match { + case field: Field => processTopFieldSelection(field) + case _ => ??? // TODO: handle the other possibilities, or error + } + } + + def processTopFieldSelection(field: Field): Res = { + val name = field.name + if (name == thingQueryName) { + // We're querying a *specific* thing, which should be specified as an argument: + field.arguments.find(_.name == "id") match { + case Some(thingIdArg) => { + thingIdArg.value match { + case strValue: StringValue => { + val oid = OID(strValue.value) + state.anything(oid) match { + case Some(thing) => { + processThingSelections(thing, field) + } + case None => ??? // TODO: unknown thing ID + } + } + case _ => ??? // TODO: deal with other possible argument types + } + } + case None => { + field.arguments.find(_.name == "name") match { + case Some(thingNameArg) => { + thingNameArg.value match { + case strValue: StringValue => { + state.anythingByName(strValue.value) match { + case Some(thing) => { + processThingSelections(thing, field) + } + case None => ??? // TODO: unknown Thing Name + } + } + case _ => ??? // TODO: deal with other possible argument types + } + } + case None => ??? // TODO: missing required id or name argument + } + } + } + } + state.anythingByName(name) match { + case Some(model) => { + if (model.isModel) { + val resultFuts = state.descendants(model, includeModels = false, includeInstances = true).map { thing => + processThingSelections(thing, field) + }.toList + Future.sequence(resultFuts).map { eithers: List[Either[PublicException, JsObject]] => + eithers.find(_.isLeft).getOrElse { + val jsObjs = eithers.map(_.right.get) + Right(JsObject(Map(name -> JsArray(jsObjs)))) + } + } + } else { + // TODO: only Models are allowed as top-level names + ??? + } + } + case None => ??? // TODO: error + } + } + + def processThingSelections(thing: Thing, field: Field): Res = { + if (field.selections.isEmpty) { + ??? // Error -- we require Things to have fields specified + } else { + coalesceResults(field.selections.map(processThingSelection(thing, _))) + } + } + + def processThingSelection(thing: Thing, selection: Selection): Res = { + selection match { + case field: Field => ??? + case _ => ??? // TODO: error, or deal with it + } + } +} diff --git a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala index abfa3951b..bf9296f00 100644 --- a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala +++ b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala @@ -46,15 +46,45 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { final val thingQueryName = "_thing" final val instancesQueryName = "_instances" - final val idArgName = "oid" - final val nameArgName = "name" + final val idArgName = "_oid" + final val nameArgName = "_name" type SyncRes[T] = ValidatedNec[GraphQLError, T] type Res[T] = EitherT[IO, NonEmptyChain[GraphQLError], T] lazy val Core = ecology.api[querki.core.Core] - def processQuery(query: String): IO[ValidatedNec[GraphQLError, JsValue]] = { + def handle(query: String): IO[JsValue] = { + val result: IO[Either[NonEmptyChain[GraphQLError], JsValue]] = processQuery(query).value + result.map { + _ match { + case Right(jsv) => JsObject(Map("data" -> jsv)) + case Left(errs) => { + val jsErrs: NonEmptyChain[JsValue] = errs.map { err => + val msg = err.msg + err.location.map { loc => + val jsLoc = JsObject(Map( + "line" -> JsNumber(loc.line), + "column" -> JsNumber(loc.column) + )) + JsObject(Map( + "message" -> JsString(msg), + "locations" -> JsArray(Seq(jsLoc)) + )) + }.getOrElse { + JsObject(Map("message" -> JsString(msg))) + } + } + JsObject(Map( + "data" -> JsNull, + "errors" -> JsArray(jsErrs.toNonEmptyList.toList) + )) + } + } + } + } + + def processQuery(query: String): Res[JsValue] = { val fields: SyncRes[Vector[Field]] = parseQuery(query) andThen (getDefinitions(_)) andThen @@ -67,16 +97,13 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { val queryResults: Vector[Res[(String, JsValue)]] = sels.map(processQuerySelection(_)) val inverted: Res[Vector[(String, JsValue)]] = queryResults.sequence // Surely we can do this better by defining a Semigroup over JsObject: - val result: Res[JsValue] = inverted.map { pairs => + inverted.map { pairs => pairs.foldLeft(JsObject(Seq.empty)) { case (obj, (name, jsv)) => obj + (name -> jsv) } } - result.value.map { either => - either.toValidated - } } - case Invalid(err) => IO.pure(err.invalid) + case Invalid(err) => EitherT.leftT(err) } } @@ -98,16 +125,16 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { definition match { case op: OperationDefinition if (op.operationType == OperationType.Query) => op.valid // TODO: deal with other Operation types! - case op: OperationDefinition => UnhandledOperationType(op.operationType).invalidNec + case op: OperationDefinition => UnhandledOperationType(op.operationType, definition.location).invalidNec // TODO: deal with other Definition types! - case _ => UnhandledDefinitionType(definition).invalidNec + case _ => UnhandledDefinitionType(definition, definition.location).invalidNec } } def confirmIsField(selection: Selection): SyncRes[Field] = { selection match { case field: Field => field.valid - case _ => UnhandledSelectionType(selection).invalidNec + case _ => UnhandledSelectionType(selection, selection.location).invalidNec } } @@ -145,7 +172,7 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { processedThings.map(JsArray(_)) } } else { - resError(IllegalTopSelection(field.name)) + resError(IllegalTopSelection(field.name, field.location)) } } @@ -171,10 +198,10 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { case Some(oid) => { state.anything(oid) match { case Some(thing) => thing.valid - case None => OIDNotFound(oidStr).invalidNec + case None => OIDNotFound(oidStr, field.location).invalidNec } } - case None => NotAnOID(oidStr).invalidNec + case None => NotAnOID(oidStr, field.location).invalidNec } } case Valid(None) => { @@ -182,10 +209,10 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { case Valid(Some(thingName)) => { state.anythingByName(thingName) match { case Some(thing) => thing.valid - case None => NameNotFound(thingName).invalidNec + case None => NameNotFound(thingName, field.location).invalidNec } } - case Valid(None) => MissingRequiredArgument(field, selectionName, s"$idArgName or $nameArgName").invalidNec + case Valid(None) => MissingRequiredArgument(field, selectionName, s"$idArgName or $nameArgName", field.location).invalidNec case Invalid(err) => err.invalid } } @@ -215,8 +242,15 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { * Given a Thing, process one Field that represents a Property of that Thing. */ def processField(thing: Thing, field: Field): Res[JsValue] = { - getProperty(thing, field).toRes.flatMap { prop => - processProperty(thing, prop, field) + if (field.name == idArgName) { + // They're asking for the OID of this Thing: + res(JsString(thing.id.toThingId.toString)) + } else if (field.name == nameArgName) { + res(JsString(thing.toThingId.toString)) + } else { + getProperty(thing, field).toRes.flatMap { prop => + processProperty(thing, prop, field) + } } } @@ -229,7 +263,7 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { } yield prop - propOpt.syncOrError(UnknownProperty(name)) + propOpt.syncOrError(UnknownProperty(name, field.location)) } def processProperty(thing: Thing, prop: Property[_, _], field: Field): Res[JsValue] = { @@ -248,10 +282,10 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { val resultOpt: Option[Res[JsValue]] = propOpt.map { prop => thing.getPropOpt(prop) match { case Some(propAndVal) => processValues(thing, prop, propAndVal.rawList, field) - case None => resError(PropertyNotOnThing(thing, prop)) + case None => resError(PropertyNotOnThing(thing, prop, field.location)) } } - resultOpt.getOrElse(resError(InternalGraphQLError("Hit a Property whose type doesn't confirm!"))) + resultOpt.getOrElse(resError(InternalGraphQLError("Hit a Property whose type doesn't confirm!", field.location))) } def processValues[VT: JsValueable](thing: Thing, prop: Property[VT, _], vs: List[VT], field: Field): Res[JsValue] = { @@ -259,7 +293,7 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { prop.cType match { case ExactlyOneOID => { if (vs.isEmpty) { - resError(MissingRequiredValue(thing, prop)) + resError(MissingRequiredValue(thing, prop, field.location)) } else { res(vs.head.toJsValue) } @@ -306,7 +340,7 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { field.arguments.find(_.name == name) match { case Some(arg) => arg.value match { case v: StringValue => Some(v.value).valid - case _ => UnhandledArgumentType(arg).invalidNec + case _ => UnhandledArgumentType(arg, field.location).invalidNec } case None => None.valid } @@ -315,7 +349,7 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { getArgumentStr(name) andThen { _ match { case Some(v) => v.valid - case None => MissingRequiredArgument(field, name, "string").invalidNec + case None => MissingRequiredArgument(field, name, "string", field.location).invalidNec } } } @@ -333,50 +367,59 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { sealed trait GraphQLError { def msg: String + def location: Option[AstLocation] } -case class ParseFailure(msg: String) extends GraphQLError -case object NoDefinitions extends GraphQLError { val msg = "No Definitions provided in the GraphQL Document!" } -case class UnhandledOperationType(opType: OperationType) extends GraphQLError { +case class ParseFailure(msg: String) extends GraphQLError { + val location = None +} +case object NoDefinitions extends GraphQLError { + val msg = "No Definitions provided in the GraphQL Document!" + val location = None +} +case class UnhandledOperationType(opType: OperationType, location: Option[AstLocation]) extends GraphQLError { val msg = s"Querki does not yet deal with ${opType.getClass.getSimpleName} operations; sorry." } -case class UnhandledDefinitionType(definition: Definition) extends GraphQLError { +case class UnhandledDefinitionType(definition: Definition, location: Option[AstLocation]) extends GraphQLError { def msg = s"Querki can not yet deal with ${definition.getClass.getSimpleName}" } -case object TooManyOperations extends GraphQLError { val msg = "Querki can currently only deal with one Query at a time; sorry." } -case class UnhandledSelectionType(selection: Selection) extends GraphQLError { +case object TooManyOperations extends GraphQLError { + val msg = "Querki can currently only deal with one Query at a time; sorry." + val location = None +} +case class UnhandledSelectionType(selection: Selection, location: Option[AstLocation]) extends GraphQLError { def msg = s"Querki can not yet deal with ${selection.getClass.getSimpleName} selections; sorry." } -case class UnhandledArgumentType(arg: Argument) extends GraphQLError { +case class UnhandledArgumentType(arg: Argument, location: Option[AstLocation]) extends GraphQLError { def msg = s"Querki can not yet deal with ${arg.value.getClass.getSimpleName} arguments there; sorry." } -case class MissingRequiredArgument(field: Field, name: String, tpe: String) extends GraphQLError { +case class MissingRequiredArgument(field: Field, name: String, tpe: String, location: Option[AstLocation]) extends GraphQLError { def msg = s"Field ${field.name} requires a $tpe argument named '$name'" } -case class UnknownThing(name: String) extends GraphQLError { +case class UnknownThing(name: String, location: Option[AstLocation]) extends GraphQLError { def msg = s"There is no Thing named $name" } -case class IllegalTopSelection(name: String) extends GraphQLError { +case class IllegalTopSelection(name: String, location: Option[AstLocation]) extends GraphQLError { def msg = s"The top level of a GraphQL query must be _thing or _instances." } -case class NotAnOID(str: String) extends GraphQLError { +case class NotAnOID(str: String, location: Option[AstLocation]) extends GraphQLError { def msg = s"$str is not a valid OID" } -case class OIDNotFound(oid: String) extends GraphQLError { +case class OIDNotFound(oid: String, location: Option[AstLocation]) extends GraphQLError { def msg = s"No Thing found with OID $oid" } -case class NameNotFound(name: String) extends GraphQLError { +case class NameNotFound(name: String, location: Option[AstLocation]) extends GraphQLError { def msg = s"No Thing found named $name -- maybe that isn't the correct Link Name?" } -case class UnknownProperty(name: String) extends GraphQLError { +case class UnknownProperty(name: String, location: Option[AstLocation]) extends GraphQLError { def msg = s"Unknown Property: $name" } -case class PropertyNotOnThing(thing: Thing, prop: AnyProp) extends GraphQLError { +case class PropertyNotOnThing(thing: Thing, prop: AnyProp, location: Option[AstLocation]) extends GraphQLError { def msg = s"${thing.displayName} does not have requested property ${prop.displayName}" } -case class UnsupportedType(prop: Property[_, _]) extends GraphQLError { +case class UnsupportedType(prop: Property[_, _], location: Option[AstLocation]) extends GraphQLError { def msg = s"Querki GraphQL does not yet support ${prop.pType.displayName} properties; sorry." } -case class InternalGraphQLError(msg: String) extends GraphQLError -case class MissingRequiredValue(thing: Thing, prop: Property[_, _]) extends GraphQLError { +case class InternalGraphQLError(msg: String, location: Option[AstLocation]) extends GraphQLError +case class MissingRequiredValue(thing: Thing, prop: Property[_, _], location: Option[AstLocation]) extends GraphQLError { def msg = s"Required Property ${prop.displayName} on Thing ${thing.displayName} is empty!" } \ No newline at end of file diff --git a/querki/scalajvm/app/querki/graphql/OldComputeGraphQL.scala b/querki/scalajvm/app/querki/graphql/OldComputeGraphQL.scala new file mode 100644 index 000000000..67404859b --- /dev/null +++ b/querki/scalajvm/app/querki/graphql/OldComputeGraphQL.scala @@ -0,0 +1,299 @@ +package querki.graphql + +import cats.data.State +import models.{Thing, OID, PType, Property, ThingState} +import querki.core.QLText +import querki.globals.{SpaceState, OID, Ecology} +import sangria.schema +import sangria.schema._ +import sangria.validation.ValueCoercionViolation + +object OldComputeGraphQL { + + /** + * This is a pure but extremely deep function, that takes a SpaceState and computes the GraphQL Schema for it. + * + * Note that the resulting Schema closes over both the SpaceState and the Ecology. I think that's fine -- they're + * both effectively immutable -- but keep it in mind. + */ + def computeGraphQLSchema(state: SpaceState)(implicit ecology: Ecology): Schema[SpaceState, Thing] = { + implicit val s = state + + lazy val Basic = ecology.api[querki.basic.Basic] + lazy val Conventions = ecology.api[querki.conventions.Conventions] + lazy val Core = ecology.api[querki.core.Core] + lazy val Links = ecology.api[querki.links.Links] + + ///////////////// + // + // OID Type + // + // Since OID totally makes sense as a ScalarType, we define a proper one for that + // + + case object OIDCoercionViolation extends ValueCoercionViolation("OID Value Expected") + + def parseOID(s: String) = OID.parseOpt(s) match { + case Some(oid) => Right(oid) + case None => Left(OIDCoercionViolation) + } + + val OIDType = ScalarType[OID]("OID", + coerceOutput = (oid, caps) => oid.toString, + coerceUserInput = { + case s: String => parseOID(s) + case _ => Left(OIDCoercionViolation) + }, + coerceInput = { + case sangria.ast.StringValue(s, _, _, _, _) => parseOID(s) + case _ => Left(OIDCoercionViolation) + } + ) + + /** + * Every Thing has an OID field, automatically, because it would be dumb not to. + */ + val idField = Field[SpaceState, Thing, OID, OID]( + name = "_oid", + fieldType = OIDType, + description = Some("The unique OID of this Thing"), + resolve = ctx => ctx.value.id + ) + + ////////////////////////// + + // In principle, I'd like to build these up using a State; in practice, given how recursive the algorithm is, + // and how constrained it is by the requirements of Sangria, it's much easier to just cheat a little with a + // couple of local vars. + // IMPORTANT: if this ever become async, these become extremely suspicious! This is reasonable only because + // the whole algorithm is single-threaded! + var objectTypesByModel: Map[OID, ObjectType[SpaceState, Thing]] = Map.empty + var fieldsByProp: Map[OID, Field[SpaceState, Thing]] = Map.empty + + import querki.core.MOIDs._ + trait PType2Schema[T] { + type Out = T + + def infoFor(prop: Property[_, _]) = (outputTypeFor(prop), resolverFor(prop)) + + // Note that these functions work on OutputType[Any]. That's sad, but given that Querki's types resolve at + // runtime, there likely isn't much else we can do. + def outputTypeFor(prop: Property[_, _]): OutputType[Any] + def resolverFor(prop: Property[_, _]): Context[SpaceState, Thing] => Action[SpaceState, Any] + + def withCollection(prop: Property[_, _])(pt: => OutputType[T]): OutputType[Any] = { + val inner = pt + prop.cType.id match { + case ExactlyOneOID => inner + case OptionalOID => OptionType(inner) + case QListOID | QSetOID => ListType(inner) + } + } + + def resolve[VT](prop: Property[_, _])(f: VT => T): Context[SpaceState, Thing] => Action[SpaceState, Any] = ctx => { + val thing = ctx.value + val result = prop.cType.id match { + case ExactlyOneOID => f(thing.getFirstOpt(prop).getOrElse(prop.pType.default.elem).asInstanceOf[VT]) + case OptionalOID => thing.getFirstOpt(prop).map(v => f(v.asInstanceOf[VT])) + case QListOID | QSetOID => thing.getPropAll(prop).map(v => f(v.asInstanceOf[VT])) + } + Value(result) + } + def stdResolve(prop: Property[_, _]): Context[SpaceState, Thing] => Action[SpaceState, Any] = { + resolve(prop)(v => v) + } + } + + object Int2Schema extends PType2Schema[Int] { + def outputTypeFor(prop: Property[_, _]): OutputType[Any] = withCollection(prop)(schema.IntType) + def resolverFor(prop: Property[_, _]): Context[SpaceState, Thing] => Action[SpaceState, Any] = stdResolve(prop) + } + object Text2Schema extends PType2Schema[String] { + def outputTypeFor(prop: Property[_, _]): OutputType[Any] = withCollection(prop)(schema.StringType) + def resolverFor(prop: Property[_, _]): Context[SpaceState, Thing] => Action[SpaceState, Any] = + resolve[QLText](prop)(_.text) + } + object YesNo2Schema extends PType2Schema[Boolean] { + def outputTypeFor(prop: Property[_, _]): OutputType[Any] = withCollection(prop)(schema.BooleanType) + def resolverFor(prop: Property[_, _]): Context[SpaceState, Thing] => Action[SpaceState, Any] = stdResolve(prop) + } + object Name2Schema extends PType2Schema[String] { + def outputTypeFor(prop: Property[_, _]): OutputType[Any] = withCollection(prop)(schema.StringType) + def resolverFor(prop: Property[_, _]): Context[SpaceState, Thing] => Action[SpaceState, Any] = stdResolve(prop) + } + case class Model2Schema(linkModelId: OID) extends PType2Schema[Any] { + def outputTypeFor(prop: Property[_, _]): OutputType[Any] = withCollection(prop)(modelToObjectType(linkModelId)) + def resolverFor(prop: Property[_, _]): Context[SpaceState, Thing] => Action[SpaceState, Any] = ??? // TODO + } + object OID2Schema extends PType2Schema[OID] { + def outputTypeFor(prop: Property[_, _]): OutputType[Any] = withCollection(prop)(OIDType) + def resolverFor(prop: Property[_, _]): Context[SpaceState, Thing] => Action[SpaceState, Any] = stdResolve(prop) + } + + def infoFor(prop: Property[_, _]): (OutputType[Any], Context[SpaceState, Thing] => Action[SpaceState, Any]) = { + import querki.core.MOIDs._ + val tpe = prop.pType + + val transformer: PType2Schema[_] = tpe.id match { + case IntTypeOID => Int2Schema + case TextTypeOID => Text2Schema + case YesNoTypeOID => YesNo2Schema + case NameTypeOID => Name2Schema + case LinkTypeOID => { + // Link Properties are complicated, since they tend to point to a specific other Model: + prop.getFirstOpt(Links.LinkModelProp) match { + case Some(linkModelId) => { + Model2Schema(linkModelId) + } + case None => OID2Schema + } + } + } + + transformer.infoFor(prop) + } + +// // TODO: this is way too primitive at this point, and doesn't even try to cope with composite types. Make it +// // much more sophisticated! +// // Also, think about whether we can make it less horribly coupled. Right now, I'm gathering everything here, +// // but that's clearly wrong. In principle we want a typeclass, but in Querki's weakly-typed world I suspect +// // that's simply not an option. +// def outputTypeFor(prop: Property[_, _]): OutputType[prop.valType] = { +// import querki.core.MOIDs._ +// val tpe = prop.pType +// +// val baseType: OutputType[prop.valType] = if (tpe.id == LinkTypeOID) { +// // Link Properties are complicated, since they tend to point to a specific other Model: +// prop.getFirstOpt(Links.LinkModelProp) match { +// case Some(linkModelId) => { +// modelToObjectType(linkModelId).asInstanceOf[OutputType[prop.valType]] +// } +// case None => OIDType.asInstanceOf[OutputType[prop.valType]] +// } +// } else { +// tpe.id match { +// // This is hideous, and really wants Scala 3 Match Types, but for now... +// case IntTypeOID => schema.IntType.asInstanceOf[OutputType[prop.valType]] +// case TextTypeOID => schema.StringType.asInstanceOf[OutputType[prop.valType]] +// case YesNoTypeOID => schema.BooleanType.asInstanceOf[OutputType[prop.valType]] +// case NameTypeOID => schema.StringType.asInstanceOf[OutputType[prop.valType]] +// // TODO: more types... +// } +// } +// } +// +// def fieldInfoFor(prop: Property[_, _]): (OutputType[prop.valType], Context[SpaceState, Thing] => Action[SpaceState, prop.valType]) = { +// import querki.core.MOIDs._ +// val tpe = prop.pType +// +// val (baseType, fetcher) = if (tpe.id == LinkTypeOID) { +// // Link Properties are complicated, since they tend to point to a specific other Model: +// prop.getFirstOpt(Links.LinkModelProp) match { +// case Some(linkModelId) => { +// (modelToObjectType(linkModelId), +// ) +// } +// case None => OIDType +// } +// } else { +// tpe.id match { +// // This is hideous, and really wants Scala 3 Match Types, but for now... +// case IntTypeOID => schema.IntType +// case TextTypeOID => schema.StringType +// case YesNoTypeOID => schema.BooleanType +// case NameTypeOID => schema.StringType +// // TODO: more types... +// } +// } +// +// prop.cType.id match { +// case ExactlyOneOID => baseType +// // This is hideous, and really wants Scala 3 Match Types, but for now... +// case OptionalOID => OptionType(baseType).asInstanceOf[OutputType[prop.valType]] +// case QListOID | QSetOID => ListType(baseType).asInstanceOf[OutputType[prop.valType]] +// } +// } +// +// def resolverForProp(prop: Property[_, _]): Context[SpaceState, Thing] => Action[SpaceState, prop.valType] = ctx => { +// val thing: Thing = ctx.value +// +// ??? +// } + + def propToField(propId: OID): Field[SpaceState, Thing] = { + fieldsByProp.get(propId) match { + case Some(field) => field + case None => { + val field: Field[SpaceState, Thing] = state.prop(propId) match { + case Some(prop) => { + val (outputType, resolver) = infoFor(prop) + Field[SpaceState, Thing, Any, Any]( + name = prop.linkName.getOrElse(propId.toString), + fieldType = outputType, + description = prop.getFirstOpt(Conventions.PropSummary).map(_.text), + resolve = resolver + ) + } + case None => { + // Missing prop, so snip it off: + Field[SpaceState, Thing, Boolean, Boolean](s"UnknownProp$propId", schema.BooleanType, None, resolve = _ => Value(false)) + } + } + fieldsByProp += (propId -> field) + field + } + } + } + + def modelToFields(model: Thing): List[Field[SpaceState, Thing]] = { + // TODO: add ID as a Field: + idField +: model.props.keys.map(propToField).toList + } + + def modelToObjectType(id: OID): ObjectType[SpaceState, Thing] = { + objectTypesByModel.get(id) match { + case Some(objectType) => objectType + case None => { + val objectType: ObjectType[SpaceState, Thing] = state.anything(id) match { + case Some(model) => { + // We need to use the indirect constructor for ObjectType, or we're going to hit infinite recursion: + ObjectType(model.linkName.getOrElse(id.toString), () => modelToFields(model)) + } + case None => { + // Bad pointer, so snip it off: + ObjectType(s"UnknownModel$id", List.empty) + } + } + objectTypesByModel += (id -> objectType) + objectType + } + } + } + + // Populate all of the Models: + val models = state.allModels + // Note that this is side-effecting, because it's all very recursive between the ObjectTypes and Fields. It + // should populate both of the Maps at the top. + models.foreach(model => modelToObjectType(model.id)) + + val OIDArg = Argument("oid", OIDType, "the OID of the desired Instance") + + val Query = ObjectType[SpaceState, Thing]( + "Query", + fields[SpaceState, Thing]( + objectTypesByModel.toList.map { case (modelId, objectType) => + Field[SpaceState, Thing, Thing, Thing]( + // Uncapitalize the name, since that seems to be the convention: + objectType.name.head.toLower + objectType.name.drop(1), + objectType, + arguments = OIDArg :: Nil, + // TODO: deal with the OID not being found: + resolve = ctx => Value(ctx.ctx.anything(ctx arg OIDArg).get) + ) + }:_* + ) + ) + + Schema(Query) + } +} diff --git a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala new file mode 100644 index 000000000..69e678fce --- /dev/null +++ b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala @@ -0,0 +1,41 @@ +package querki.graphql + +import cats.effect._ +import cats.implicits._ +import play.api.libs.json.Json +import querki.test.{CDSpace, QuerkiTests} + +class ComputeGraphQLTests extends QuerkiTests { + "FPComputeGraphQL" should { + "process a basic query" in { + val cdSpace = new CDSpace + + val computer = new FPComputeGraphQL()(cdSpace.state, ecology) + + val eurythmicsOID = cdSpace.eurythmics.id + val thingQuery = + s""" + |query CDQuery { + | _thing(_oid: "$eurythmicsOID") { + | _oid + | _name + | } + |} + """.stripMargin + val thingJsv = computer.handle(thingQuery).unsafeRunSync() + println(Json.prettyPrint(thingJsv)) + + val instancesQuery = + s""" + |query ArtistsQuery { + | _instances(_name: "Artist") { + | _oid + | _name + | } + |} + """.stripMargin + val instancesJsv = computer.handle(instancesQuery).unsafeRunSync() + println(Json.prettyPrint(instancesJsv)) + } + } +} diff --git a/querki/scalajvm/test/querki/graphql/OldComputeGraphQLTests.scala b/querki/scalajvm/test/querki/graphql/OldComputeGraphQLTests.scala new file mode 100644 index 000000000..07af1a304 --- /dev/null +++ b/querki/scalajvm/test/querki/graphql/OldComputeGraphQLTests.scala @@ -0,0 +1,30 @@ +package querki.graphql + +import sangria.macros._ +import querki.test.QuerkiTests +import sangria.parser.QueryParser + +class OldComputeGraphQLTests extends QuerkiTests { + "computeGraphQL" should { +// "produce a reasonable schema" in { +// implicit val s = commonSpace +// +// val schema = ComputeGraphQL.computeGraphQLSchema(s.state) +// println(schema) +// } + + "parse a query" in { + val query = """query HeroAndFriends { + | hero { + | name + | friends { + | name + | } + | } + |}""".stripMargin + + val result = QueryParser.parse(query) + println(result) + } + } +} From 6c9d62b993c325908695fe3e9297517ce62abe2a Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Tue, 13 Aug 2019 17:57:23 -0400 Subject: [PATCH 03/22] Delete the dead ends I had checked in my previous two attempts (neither of which really worked out), just to keep the records, but now let's remove them. --- .../app/querki/graphql/ComputeGraphQL.scala | 166 ---------- .../querki/graphql/OldComputeGraphQL.scala | 299 ------------------ .../graphql/OldComputeGraphQLTests.scala | 30 -- 3 files changed, 495 deletions(-) delete mode 100644 querki/scalajvm/app/querki/graphql/ComputeGraphQL.scala delete mode 100644 querki/scalajvm/app/querki/graphql/OldComputeGraphQL.scala delete mode 100644 querki/scalajvm/test/querki/graphql/OldComputeGraphQLTests.scala diff --git a/querki/scalajvm/app/querki/graphql/ComputeGraphQL.scala b/querki/scalajvm/app/querki/graphql/ComputeGraphQL.scala deleted file mode 100644 index 29758f591..000000000 --- a/querki/scalajvm/app/querki/graphql/ComputeGraphQL.scala +++ /dev/null @@ -1,166 +0,0 @@ -package querki.graphql - -import models.{Thing, OID} -import play.api.libs.json.{JsObject, JsValue, JsArray} -import querki.globals.{SpaceState, Ecology} -import querki.util.PublicException -import sangria.ast._ -import sangria.parser.QueryParser - -import scala.concurrent.{Future, ExecutionContext} -import scala.util.{Success, Failure} - -/** - * This object is basically a wrapper around the processQuery() function. - * - * TODO: rewrite this stack in IO terms -- it would be much cleaner and more efficient than raw Futures. And we - * should probably use Cats Validated: trying to do this with pre-2.12 Either is just an exercise in annoyance. - */ -class ComputeGraphQL(implicit state: SpaceState, ec: ExecutionContext, e: Ecology) { - - final val thingQueryName = "_thing" - - type Res = Future[Either[PublicException, JsObject]] - - /** - * Main entry point -- takes a stringified GraphQL query and returns a response. - * - * This returns a Future so that we can potentially do more serious processing in here, when we get - * more sophisticated. - */ - def processQuery(query: String): Res = { - QueryParser.parse(query) match { - case Success(document) => processQuery(document) - case Failure(ex) => ??? // TODO: transform to a Left[PublicException] - } - } - - /** - * We've parsed the query, now actually process it. - */ - def processQuery(document: Document): Res = { - // TODO: deal with an empty definitions list - // TODO: deal with multiple definitions - processDefinition(document.definitions.head) - } - - def processDefinition(definition: Definition): Res = { - definition match { - case od: OperationDefinition => processOperation(od) - case _ => ??? // TODO: handle others, and error on the ones we don't want - } - } - - def coalesceResults(futs: Vector[Res]): Res = { - Future.sequence(futs).map { eithers => - eithers.find(_.isLeft).getOrElse { - // Okay, they all succeeded - val objects: Vector[JsObject] = eithers.map(_.right.get) - val oneObject: JsObject = objects.reduce(_ ++ _) - Right(oneObject) - } - } - } - - /** - * A single Query or Mutation operation. - * - * This is the real guts of the functionality. - */ - def processOperation(operation: OperationDefinition): Res = { - if (operation.operationType != OperationType.Query) { - // TODO: error for now - // TODO: deal with mutations - ??? - } else { - val result = coalesceResults(operation.selections.map(processTopSelection)) - // Wrap the results in a top-level "data" object: - result.map(_.right.map(jsObject => JsObject(Map("data" -> jsObject)))) - } - } - - /** - * A single "selection" -- more or less a distinct lookup. - */ - def processTopSelection(selection: Selection): Res = { - selection match { - case field: Field => processTopFieldSelection(field) - case _ => ??? // TODO: handle the other possibilities, or error - } - } - - def processTopFieldSelection(field: Field): Res = { - val name = field.name - if (name == thingQueryName) { - // We're querying a *specific* thing, which should be specified as an argument: - field.arguments.find(_.name == "id") match { - case Some(thingIdArg) => { - thingIdArg.value match { - case strValue: StringValue => { - val oid = OID(strValue.value) - state.anything(oid) match { - case Some(thing) => { - processThingSelections(thing, field) - } - case None => ??? // TODO: unknown thing ID - } - } - case _ => ??? // TODO: deal with other possible argument types - } - } - case None => { - field.arguments.find(_.name == "name") match { - case Some(thingNameArg) => { - thingNameArg.value match { - case strValue: StringValue => { - state.anythingByName(strValue.value) match { - case Some(thing) => { - processThingSelections(thing, field) - } - case None => ??? // TODO: unknown Thing Name - } - } - case _ => ??? // TODO: deal with other possible argument types - } - } - case None => ??? // TODO: missing required id or name argument - } - } - } - } - state.anythingByName(name) match { - case Some(model) => { - if (model.isModel) { - val resultFuts = state.descendants(model, includeModels = false, includeInstances = true).map { thing => - processThingSelections(thing, field) - }.toList - Future.sequence(resultFuts).map { eithers: List[Either[PublicException, JsObject]] => - eithers.find(_.isLeft).getOrElse { - val jsObjs = eithers.map(_.right.get) - Right(JsObject(Map(name -> JsArray(jsObjs)))) - } - } - } else { - // TODO: only Models are allowed as top-level names - ??? - } - } - case None => ??? // TODO: error - } - } - - def processThingSelections(thing: Thing, field: Field): Res = { - if (field.selections.isEmpty) { - ??? // Error -- we require Things to have fields specified - } else { - coalesceResults(field.selections.map(processThingSelection(thing, _))) - } - } - - def processThingSelection(thing: Thing, selection: Selection): Res = { - selection match { - case field: Field => ??? - case _ => ??? // TODO: error, or deal with it - } - } -} diff --git a/querki/scalajvm/app/querki/graphql/OldComputeGraphQL.scala b/querki/scalajvm/app/querki/graphql/OldComputeGraphQL.scala deleted file mode 100644 index 67404859b..000000000 --- a/querki/scalajvm/app/querki/graphql/OldComputeGraphQL.scala +++ /dev/null @@ -1,299 +0,0 @@ -package querki.graphql - -import cats.data.State -import models.{Thing, OID, PType, Property, ThingState} -import querki.core.QLText -import querki.globals.{SpaceState, OID, Ecology} -import sangria.schema -import sangria.schema._ -import sangria.validation.ValueCoercionViolation - -object OldComputeGraphQL { - - /** - * This is a pure but extremely deep function, that takes a SpaceState and computes the GraphQL Schema for it. - * - * Note that the resulting Schema closes over both the SpaceState and the Ecology. I think that's fine -- they're - * both effectively immutable -- but keep it in mind. - */ - def computeGraphQLSchema(state: SpaceState)(implicit ecology: Ecology): Schema[SpaceState, Thing] = { - implicit val s = state - - lazy val Basic = ecology.api[querki.basic.Basic] - lazy val Conventions = ecology.api[querki.conventions.Conventions] - lazy val Core = ecology.api[querki.core.Core] - lazy val Links = ecology.api[querki.links.Links] - - ///////////////// - // - // OID Type - // - // Since OID totally makes sense as a ScalarType, we define a proper one for that - // - - case object OIDCoercionViolation extends ValueCoercionViolation("OID Value Expected") - - def parseOID(s: String) = OID.parseOpt(s) match { - case Some(oid) => Right(oid) - case None => Left(OIDCoercionViolation) - } - - val OIDType = ScalarType[OID]("OID", - coerceOutput = (oid, caps) => oid.toString, - coerceUserInput = { - case s: String => parseOID(s) - case _ => Left(OIDCoercionViolation) - }, - coerceInput = { - case sangria.ast.StringValue(s, _, _, _, _) => parseOID(s) - case _ => Left(OIDCoercionViolation) - } - ) - - /** - * Every Thing has an OID field, automatically, because it would be dumb not to. - */ - val idField = Field[SpaceState, Thing, OID, OID]( - name = "_oid", - fieldType = OIDType, - description = Some("The unique OID of this Thing"), - resolve = ctx => ctx.value.id - ) - - ////////////////////////// - - // In principle, I'd like to build these up using a State; in practice, given how recursive the algorithm is, - // and how constrained it is by the requirements of Sangria, it's much easier to just cheat a little with a - // couple of local vars. - // IMPORTANT: if this ever become async, these become extremely suspicious! This is reasonable only because - // the whole algorithm is single-threaded! - var objectTypesByModel: Map[OID, ObjectType[SpaceState, Thing]] = Map.empty - var fieldsByProp: Map[OID, Field[SpaceState, Thing]] = Map.empty - - import querki.core.MOIDs._ - trait PType2Schema[T] { - type Out = T - - def infoFor(prop: Property[_, _]) = (outputTypeFor(prop), resolverFor(prop)) - - // Note that these functions work on OutputType[Any]. That's sad, but given that Querki's types resolve at - // runtime, there likely isn't much else we can do. - def outputTypeFor(prop: Property[_, _]): OutputType[Any] - def resolverFor(prop: Property[_, _]): Context[SpaceState, Thing] => Action[SpaceState, Any] - - def withCollection(prop: Property[_, _])(pt: => OutputType[T]): OutputType[Any] = { - val inner = pt - prop.cType.id match { - case ExactlyOneOID => inner - case OptionalOID => OptionType(inner) - case QListOID | QSetOID => ListType(inner) - } - } - - def resolve[VT](prop: Property[_, _])(f: VT => T): Context[SpaceState, Thing] => Action[SpaceState, Any] = ctx => { - val thing = ctx.value - val result = prop.cType.id match { - case ExactlyOneOID => f(thing.getFirstOpt(prop).getOrElse(prop.pType.default.elem).asInstanceOf[VT]) - case OptionalOID => thing.getFirstOpt(prop).map(v => f(v.asInstanceOf[VT])) - case QListOID | QSetOID => thing.getPropAll(prop).map(v => f(v.asInstanceOf[VT])) - } - Value(result) - } - def stdResolve(prop: Property[_, _]): Context[SpaceState, Thing] => Action[SpaceState, Any] = { - resolve(prop)(v => v) - } - } - - object Int2Schema extends PType2Schema[Int] { - def outputTypeFor(prop: Property[_, _]): OutputType[Any] = withCollection(prop)(schema.IntType) - def resolverFor(prop: Property[_, _]): Context[SpaceState, Thing] => Action[SpaceState, Any] = stdResolve(prop) - } - object Text2Schema extends PType2Schema[String] { - def outputTypeFor(prop: Property[_, _]): OutputType[Any] = withCollection(prop)(schema.StringType) - def resolverFor(prop: Property[_, _]): Context[SpaceState, Thing] => Action[SpaceState, Any] = - resolve[QLText](prop)(_.text) - } - object YesNo2Schema extends PType2Schema[Boolean] { - def outputTypeFor(prop: Property[_, _]): OutputType[Any] = withCollection(prop)(schema.BooleanType) - def resolverFor(prop: Property[_, _]): Context[SpaceState, Thing] => Action[SpaceState, Any] = stdResolve(prop) - } - object Name2Schema extends PType2Schema[String] { - def outputTypeFor(prop: Property[_, _]): OutputType[Any] = withCollection(prop)(schema.StringType) - def resolverFor(prop: Property[_, _]): Context[SpaceState, Thing] => Action[SpaceState, Any] = stdResolve(prop) - } - case class Model2Schema(linkModelId: OID) extends PType2Schema[Any] { - def outputTypeFor(prop: Property[_, _]): OutputType[Any] = withCollection(prop)(modelToObjectType(linkModelId)) - def resolverFor(prop: Property[_, _]): Context[SpaceState, Thing] => Action[SpaceState, Any] = ??? // TODO - } - object OID2Schema extends PType2Schema[OID] { - def outputTypeFor(prop: Property[_, _]): OutputType[Any] = withCollection(prop)(OIDType) - def resolverFor(prop: Property[_, _]): Context[SpaceState, Thing] => Action[SpaceState, Any] = stdResolve(prop) - } - - def infoFor(prop: Property[_, _]): (OutputType[Any], Context[SpaceState, Thing] => Action[SpaceState, Any]) = { - import querki.core.MOIDs._ - val tpe = prop.pType - - val transformer: PType2Schema[_] = tpe.id match { - case IntTypeOID => Int2Schema - case TextTypeOID => Text2Schema - case YesNoTypeOID => YesNo2Schema - case NameTypeOID => Name2Schema - case LinkTypeOID => { - // Link Properties are complicated, since they tend to point to a specific other Model: - prop.getFirstOpt(Links.LinkModelProp) match { - case Some(linkModelId) => { - Model2Schema(linkModelId) - } - case None => OID2Schema - } - } - } - - transformer.infoFor(prop) - } - -// // TODO: this is way too primitive at this point, and doesn't even try to cope with composite types. Make it -// // much more sophisticated! -// // Also, think about whether we can make it less horribly coupled. Right now, I'm gathering everything here, -// // but that's clearly wrong. In principle we want a typeclass, but in Querki's weakly-typed world I suspect -// // that's simply not an option. -// def outputTypeFor(prop: Property[_, _]): OutputType[prop.valType] = { -// import querki.core.MOIDs._ -// val tpe = prop.pType -// -// val baseType: OutputType[prop.valType] = if (tpe.id == LinkTypeOID) { -// // Link Properties are complicated, since they tend to point to a specific other Model: -// prop.getFirstOpt(Links.LinkModelProp) match { -// case Some(linkModelId) => { -// modelToObjectType(linkModelId).asInstanceOf[OutputType[prop.valType]] -// } -// case None => OIDType.asInstanceOf[OutputType[prop.valType]] -// } -// } else { -// tpe.id match { -// // This is hideous, and really wants Scala 3 Match Types, but for now... -// case IntTypeOID => schema.IntType.asInstanceOf[OutputType[prop.valType]] -// case TextTypeOID => schema.StringType.asInstanceOf[OutputType[prop.valType]] -// case YesNoTypeOID => schema.BooleanType.asInstanceOf[OutputType[prop.valType]] -// case NameTypeOID => schema.StringType.asInstanceOf[OutputType[prop.valType]] -// // TODO: more types... -// } -// } -// } -// -// def fieldInfoFor(prop: Property[_, _]): (OutputType[prop.valType], Context[SpaceState, Thing] => Action[SpaceState, prop.valType]) = { -// import querki.core.MOIDs._ -// val tpe = prop.pType -// -// val (baseType, fetcher) = if (tpe.id == LinkTypeOID) { -// // Link Properties are complicated, since they tend to point to a specific other Model: -// prop.getFirstOpt(Links.LinkModelProp) match { -// case Some(linkModelId) => { -// (modelToObjectType(linkModelId), -// ) -// } -// case None => OIDType -// } -// } else { -// tpe.id match { -// // This is hideous, and really wants Scala 3 Match Types, but for now... -// case IntTypeOID => schema.IntType -// case TextTypeOID => schema.StringType -// case YesNoTypeOID => schema.BooleanType -// case NameTypeOID => schema.StringType -// // TODO: more types... -// } -// } -// -// prop.cType.id match { -// case ExactlyOneOID => baseType -// // This is hideous, and really wants Scala 3 Match Types, but for now... -// case OptionalOID => OptionType(baseType).asInstanceOf[OutputType[prop.valType]] -// case QListOID | QSetOID => ListType(baseType).asInstanceOf[OutputType[prop.valType]] -// } -// } -// -// def resolverForProp(prop: Property[_, _]): Context[SpaceState, Thing] => Action[SpaceState, prop.valType] = ctx => { -// val thing: Thing = ctx.value -// -// ??? -// } - - def propToField(propId: OID): Field[SpaceState, Thing] = { - fieldsByProp.get(propId) match { - case Some(field) => field - case None => { - val field: Field[SpaceState, Thing] = state.prop(propId) match { - case Some(prop) => { - val (outputType, resolver) = infoFor(prop) - Field[SpaceState, Thing, Any, Any]( - name = prop.linkName.getOrElse(propId.toString), - fieldType = outputType, - description = prop.getFirstOpt(Conventions.PropSummary).map(_.text), - resolve = resolver - ) - } - case None => { - // Missing prop, so snip it off: - Field[SpaceState, Thing, Boolean, Boolean](s"UnknownProp$propId", schema.BooleanType, None, resolve = _ => Value(false)) - } - } - fieldsByProp += (propId -> field) - field - } - } - } - - def modelToFields(model: Thing): List[Field[SpaceState, Thing]] = { - // TODO: add ID as a Field: - idField +: model.props.keys.map(propToField).toList - } - - def modelToObjectType(id: OID): ObjectType[SpaceState, Thing] = { - objectTypesByModel.get(id) match { - case Some(objectType) => objectType - case None => { - val objectType: ObjectType[SpaceState, Thing] = state.anything(id) match { - case Some(model) => { - // We need to use the indirect constructor for ObjectType, or we're going to hit infinite recursion: - ObjectType(model.linkName.getOrElse(id.toString), () => modelToFields(model)) - } - case None => { - // Bad pointer, so snip it off: - ObjectType(s"UnknownModel$id", List.empty) - } - } - objectTypesByModel += (id -> objectType) - objectType - } - } - } - - // Populate all of the Models: - val models = state.allModels - // Note that this is side-effecting, because it's all very recursive between the ObjectTypes and Fields. It - // should populate both of the Maps at the top. - models.foreach(model => modelToObjectType(model.id)) - - val OIDArg = Argument("oid", OIDType, "the OID of the desired Instance") - - val Query = ObjectType[SpaceState, Thing]( - "Query", - fields[SpaceState, Thing]( - objectTypesByModel.toList.map { case (modelId, objectType) => - Field[SpaceState, Thing, Thing, Thing]( - // Uncapitalize the name, since that seems to be the convention: - objectType.name.head.toLower + objectType.name.drop(1), - objectType, - arguments = OIDArg :: Nil, - // TODO: deal with the OID not being found: - resolve = ctx => Value(ctx.ctx.anything(ctx arg OIDArg).get) - ) - }:_* - ) - ) - - Schema(Query) - } -} diff --git a/querki/scalajvm/test/querki/graphql/OldComputeGraphQLTests.scala b/querki/scalajvm/test/querki/graphql/OldComputeGraphQLTests.scala deleted file mode 100644 index 07af1a304..000000000 --- a/querki/scalajvm/test/querki/graphql/OldComputeGraphQLTests.scala +++ /dev/null @@ -1,30 +0,0 @@ -package querki.graphql - -import sangria.macros._ -import querki.test.QuerkiTests -import sangria.parser.QueryParser - -class OldComputeGraphQLTests extends QuerkiTests { - "computeGraphQL" should { -// "produce a reasonable schema" in { -// implicit val s = commonSpace -// -// val schema = ComputeGraphQL.computeGraphQLSchema(s.state) -// println(schema) -// } - - "parse a query" in { - val query = """query HeroAndFriends { - | hero { - | name - | friends { - | name - | } - | } - |}""".stripMargin - - val result = QueryParser.parse(query) - println(result) - } - } -} From 29b09a2f6229c324cf793b3698dfe19a0bae1662 Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Wed, 14 Aug 2019 18:35:29 -0400 Subject: [PATCH 04/22] Recursion! Finally, we get to the heart and soul of GraphQL: you can now drill arbitrarily deep through Links, and they work pretty much exactly as expected. Fixed a bug in Collection processing. No idea why this wasn't crashing before. Added support for PlainTextType, and special-case checking for the non-inherited Name Property. --- .../app/querki/graphql/FPComputeGraphQL.scala | 100 +++++++++++------- .../querki/graphql/ComputeGraphQLTests.scala | 9 +- .../scalajvm/test/querki/test/CDSpace.scala | 9 +- 3 files changed, 76 insertions(+), 42 deletions(-) diff --git a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala index bf9296f00..39913f84d 100644 --- a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala +++ b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala @@ -6,8 +6,9 @@ import cats.effect.IO import cats.implicits._ import models.{Thing, OID, ThingId, PType, Property} import play.api.libs.json._ -import querki.core.MOIDs.{QSetOID, QListOID, ExactlyOneOID, OptionalOID} -import querki.core.MOIDs.{TextTypeOID, LargeTextTypeOID, IntTypeOID, LinkTypeOID} +import querki.basic.PlainText +import querki.basic.MOIDs._ +import querki.core.MOIDs._ import querki.core.QLText import querki.globals._ import querki.values.{PropAndVal, QValue} @@ -16,34 +17,7 @@ import sangria.parser.QueryParser import scala.util.{Success, Failure} -trait JsValueable[VT] { - def toJsValue(v: VT): JsValue -} - -object JsValueable { - implicit val intJsValueable = new JsValueable[Int] { - def toJsValue(v: Int) = JsNumber(v) - } - - implicit val textJsValueable = new JsValueable[QLText] { - def toJsValue(v: QLText) = JsString(v.text) - } - - // TODO: instead of returning the OID, we should dive down the graph here: - implicit val linkJsValueable = new JsValueable[OID] { - def toJsValue(v: OID): JsValue = JsString(v.toString) - } - - implicit class JsValueableOps[T: JsValueable](t: T) { - def toJsValue: JsValue = { - implicitly[JsValueable[T]].toJsValue(t) - } - } -} - class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { - import JsValueable._ - final val thingQueryName = "_thing" final val instancesQueryName = "_instances" final val idArgName = "_oid" @@ -52,8 +26,45 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { type SyncRes[T] = ValidatedNec[GraphQLError, T] type Res[T] = EitherT[IO, NonEmptyChain[GraphQLError], T] + lazy val Basic = ecology.api[querki.basic.Basic] lazy val Core = ecology.api[querki.core.Core] + // This particular typeclass instance needs to live in here, because it continues to dive down into the stack: + + trait JsValueable[VT] { + def toJsValue(v: VT, field: Field): Res[JsValue] + } + + object JsValueable { + implicit val intJsValueable = new JsValueable[Int] { + def toJsValue(v: Int, field: Field) = res(JsNumber(v)) + } + + implicit val textJsValueable = new JsValueable[QLText] { + def toJsValue(v: QLText, field: Field) = res(JsString(v.text)) + } + implicit val plainTextJsValueable = new JsValueable[PlainText] { + def toJsValue(v: PlainText, field: Field) = res(JsString(v.text)) + } + + implicit val linkJsValueable = new JsValueable[OID] { + def toJsValue(v: OID, field: Field) = { + // This is the really interesting one. This is a link, so we recurse down into it: + state.anything(v) match { + case Some(thing) => processThing(thing, field) + case None => resError(OIDNotFound(v.toThingId.toString, field.location)) + } + } + } + + implicit class JsValueableOps[T: JsValueable](t: T) { + def toJsValue(field: Field): Res[JsValue] = { + implicitly[JsValueable[T]].toJsValue(t, field) + } + } + } + import JsValueable._ + def handle(query: String): IO[JsValue] = { val result: IO[Either[NonEmptyChain[GraphQLError], JsValue]] = processQuery(query).value result.map { @@ -267,14 +278,15 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { } def processProperty(thing: Thing, prop: Property[_, _], field: Field): Res[JsValue] = { - // TODO: this is ugly and coded -- can we make it better? Do note that we need to rehydrate the types here + // TODO: this is ugly and hardcoded -- can we make it better? Do note that we need to rehydrate the types here // *somehow*, so this isn't trivial: prop.pType.id match { case IntTypeOID => processTypedProperty(thing, prop.confirmType(Core.IntType), Core.IntType, field) case TextTypeOID => processTypedProperty(thing, prop.confirmType(Core.TextType), Core.TextType, field) case LargeTextTypeOID => processTypedProperty(thing, prop.confirmType(Core.LargeTextType), Core.LargeTextType, field) case LinkTypeOID => processTypedProperty(thing, prop.confirmType(Core.LinkType), Core.LinkType, field) - // TODO: More types! + case PlainTextOID => processTypedProperty(thing, prop.confirmType(Basic.PlainTextType), Basic.PlainTextType, field) + case _ => resError(UnsupportedType(prop, field.location)) } } @@ -282,7 +294,16 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { val resultOpt: Option[Res[JsValue]] = propOpt.map { prop => thing.getPropOpt(prop) match { case Some(propAndVal) => processValues(thing, prop, propAndVal.rawList, field) - case None => resError(PropertyNotOnThing(thing, prop, field.location)) + case None => { + if (prop.id == DisplayNameOID) { + // Display Name is a very special case. It's specifically non-inherited, so it's entirely plausible that + // getPropOpt() might show it as entirely not existing. But it's really common, so we don't want to + // error on it. So we instead call a spade a spade: + res(JsString("")) + } else { + resError(PropertyNotOnThing(thing, prop, field.location)) + } + } } } resultOpt.getOrElse(resError(InternalGraphQLError("Hit a Property whose type doesn't confirm!", field.location))) @@ -290,24 +311,29 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { def processValues[VT: JsValueable](thing: Thing, prop: Property[VT, _], vs: List[VT], field: Field): Res[JsValue] = { // What we return depends on the Collection of this Property: - prop.cType match { + prop.cType.id match { case ExactlyOneOID => { if (vs.isEmpty) { resError(MissingRequiredValue(thing, prop, field.location)) } else { - res(vs.head.toJsValue) + vs.head.toJsValue(field) } } case OptionalOID => { vs.headOption match { - case Some(v) => res(v.toJsValue) + case Some(v) => v.toJsValue(field) // TODO: check the GraphQL spec -- is JsNull correct here? case None => res(JsNull) } } case QListOID | QSetOID => { - val jsvs = vs.map(_.toJsValue) - res(JsArray(jsvs)) + val jsvs: Res[List[JsValue]] = vs.map(_.toJsValue(field)).sequence + jsvs.map(JsArray(_)) + } + case other => { + // We don't expect this to happen until and unless we open up the possibility of more Collections: + QLog.error(s"FPComputeGraphQL: request to process a collection of type ${prop.cType} for Property ${prop.displayName}") + res(JsNull) } } } diff --git a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala index 69e678fce..ce7944acc 100644 --- a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala +++ b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala @@ -4,6 +4,7 @@ import cats.effect._ import cats.implicits._ import play.api.libs.json.Json import querki.test.{CDSpace, QuerkiTests} +import querki.util.QLog class ComputeGraphQLTests extends QuerkiTests { "FPComputeGraphQL" should { @@ -27,10 +28,14 @@ class ComputeGraphQLTests extends QuerkiTests { val instancesQuery = s""" - |query ArtistsQuery { - | _instances(_name: "Artist") { + |query AlbumQuery { + | _instances(_name: "Album") { | _oid | _name + | Artists { + | _name + | Name + | } | } |} """.stripMargin diff --git a/querki/scalajvm/test/querki/test/CDSpace.scala b/querki/scalajvm/test/querki/test/CDSpace.scala index c0ace1f73..464e02073 100644 --- a/querki/scalajvm/test/querki/test/CDSpace.scala +++ b/querki/scalajvm/test/querki/test/CDSpace.scala @@ -7,14 +7,17 @@ import querki.ecology._ * chewier data. It will get gradually enhanced, but do a full retest when you do so. */ class CDSpace(implicit ecologyIn:Ecology) extends CommonSpace { - val artistModel = new SimpleTestThing("Artist") + val Basic = ecology.api[querki.basic.Basic] + val DisplayName = Basic.DisplayNameProp + + val artistModel = new SimpleTestThing("Artist", DisplayName()) val genreModel = new SimpleTestThing("Genre") val genres = new TestProperty(Tags.NewTagSetType, QSet, "Genres") val eurythmics = new TestThing("Eurythmics", artistModel, genres("Rock")) - val tmbg = new TestThing("They Might Be Giants", artistModel, genres("Rock", "Weird")) - val blackmores = new TestThing("Blackmores Night", artistModel, genres("Rock", "Folk")) + val tmbg = new TestThing("They Might Be Giants", artistModel, genres("Rock", "Weird"), DisplayName("They Might Be Giants")) + val blackmores = new TestThing("Blackmores Night", artistModel, genres("Rock", "Folk"), DisplayName("Blackmores Night")) val whitney = new TestThing("Whitney Houston", artistModel, genres("Pop")) val weirdAl = new TestThing("Weird Al", artistModel, genres("Parody")) From 2ba0ea0f1c7f7168e840b7912c7fd54ed7c53ebc Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Thu, 15 Aug 2019 18:22:14 -0400 Subject: [PATCH 05/22] Tags are now working correctly Tags are weird, and probably violate basic principles of GraphQL, because the way are interpreted depends on how they are called. You can invoke them as simple primitives, and the simple name will be returned. Or, if you have reason to believe they are reified Things, you can invoke them with sub-selections, and it will dereference through them the same as with Links. --- .../app/querki/graphql/FPComputeGraphQL.scala | 30 ++++++++++++++++++- .../querki/graphql/ComputeGraphQLTests.scala | 26 +++++++++++++--- .../scalajvm/test/querki/test/CDSpace.scala | 4 +++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala index 39913f84d..f227047d5 100644 --- a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala +++ b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala @@ -9,6 +9,7 @@ import play.api.libs.json._ import querki.basic.PlainText import querki.basic.MOIDs._ import querki.core.MOIDs._ +import querki.tags.MOIDs._ import querki.core.QLText import querki.globals._ import querki.values.{PropAndVal, QValue} @@ -28,6 +29,7 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { lazy val Basic = ecology.api[querki.basic.Basic] lazy val Core = ecology.api[querki.core.Core] + lazy val Tags = ecology.api[querki.tags.Tags] // This particular typeclass instance needs to live in here, because it continues to dive down into the stack: @@ -57,6 +59,31 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { } } + /** + * Tags are a weird neither-fish-nor-fowl, and they violate the GraphQL convention that things need to be + * rigidly typed. This gets evaluated differently depending on how it is called. If there are sub-selections, + * we try to dereference it; if not, we just return the tag's text. + */ + val tagJsValueable = new JsValueable[PlainText] { + def toJsValue(v: PlainText, field: Field) = { + if (field.selections.isEmpty) { + // Treat the tag as simple text: + res(JsString(v.text)) + } else { + // There are sub-selections, so dereference it: + val name = v.text + val thing = state.anythingByName(name) match { + case Some(thing) => thing + case None => state.anythingByDisplayName(name) match { + case Some(thing) => thing + case None => Tags.getTag(name, state) + } + } + processThing(thing, field) + } + } + } + implicit class JsValueableOps[T: JsValueable](t: T) { def toJsValue(field: Field): Res[JsValue] = { implicitly[JsValueable[T]].toJsValue(t, field) @@ -286,11 +313,12 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { case LargeTextTypeOID => processTypedProperty(thing, prop.confirmType(Core.LargeTextType), Core.LargeTextType, field) case LinkTypeOID => processTypedProperty(thing, prop.confirmType(Core.LinkType), Core.LinkType, field) case PlainTextOID => processTypedProperty(thing, prop.confirmType(Basic.PlainTextType), Basic.PlainTextType, field) + case NewTagSetOID => processTypedProperty(thing, prop.confirmType(Tags.NewTagSetType), Tags.NewTagSetType, field)(tagJsValueable) case _ => resError(UnsupportedType(prop, field.location)) } } - def processTypedProperty[VT: JsValueable](thing: Thing, propOpt: Option[Property[VT, _]], pt: PType[VT], field: Field): Res[JsValue] = { + def processTypedProperty[VT](thing: Thing, propOpt: Option[Property[VT, _]], pt: PType[VT], field: Field)(implicit ev: JsValueable[VT]): Res[JsValue] = { val resultOpt: Option[Res[JsValue]] = propOpt.map { prop => thing.getPropOpt(prop) match { case Some(propAndVal) => processValues(thing, prop, propAndVal.rawList, field) diff --git a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala index ce7944acc..4e2a3c9af 100644 --- a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala +++ b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala @@ -13,6 +13,11 @@ class ComputeGraphQLTests extends QuerkiTests { val computer = new FPComputeGraphQL()(cdSpace.state, ecology) + def runQuery(query: String): Unit = { + val jsv = computer.handle(query).unsafeRunSync() + println(Json.prettyPrint(jsv)) + } + val eurythmicsOID = cdSpace.eurythmics.id val thingQuery = s""" @@ -23,8 +28,7 @@ class ComputeGraphQLTests extends QuerkiTests { | } |} """.stripMargin - val thingJsv = computer.handle(thingQuery).unsafeRunSync() - println(Json.prettyPrint(thingJsv)) + runQuery(thingQuery) val instancesQuery = s""" @@ -35,12 +39,26 @@ class ComputeGraphQLTests extends QuerkiTests { | Artists { | _name | Name + | Genres | } | } |} """.stripMargin - val instancesJsv = computer.handle(instancesQuery).unsafeRunSync() - println(Json.prettyPrint(instancesJsv)) + runQuery(instancesQuery) + + val drillDownTagsQuery = + """ + |query DrillDownTagQuery { + | _thing(_name: "Gordon-Bok") { + | Genres { + | Exemplar { + | _name + | } + | } + | } + |} + """.stripMargin + runQuery(drillDownTagsQuery) } } } diff --git a/querki/scalajvm/test/querki/test/CDSpace.scala b/querki/scalajvm/test/querki/test/CDSpace.scala index 464e02073..bbd62631e 100644 --- a/querki/scalajvm/test/querki/test/CDSpace.scala +++ b/querki/scalajvm/test/querki/test/CDSpace.scala @@ -11,6 +11,7 @@ class CDSpace(implicit ecologyIn:Ecology) extends CommonSpace { val DisplayName = Basic.DisplayNameProp val artistModel = new SimpleTestThing("Artist", DisplayName()) + val exemplar = new TestProperty(LinkType, Optional, "Exemplar", Links.LinkModelProp(artistModel)) val genreModel = new SimpleTestThing("Genre") val genres = new TestProperty(Tags.NewTagSetType, QSet, "Genres") @@ -20,6 +21,9 @@ class CDSpace(implicit ecologyIn:Ecology) extends CommonSpace { val blackmores = new TestThing("Blackmores Night", artistModel, genres("Rock", "Folk"), DisplayName("Blackmores Night")) val whitney = new TestThing("Whitney Houston", artistModel, genres("Pop")) val weirdAl = new TestThing("Weird Al", artistModel, genres("Parody")) + val bok = new TestThing("Gordon Bok", artistModel, genres("Folk")) + + val folk = new TestThing("Folk", genreModel, exemplar(blackmores)) val artistsProp = new TestProperty(LinkType, QSet, "Artists", Links.LinkModelProp(artistModel)) From e64420fd6c44aebbe8848e5972f728873bab2289 Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Sat, 17 Aug 2019 11:14:53 -0400 Subject: [PATCH 06/22] Support for top-level aliases Since we only support two top-level queries, I expect aliases to be really quite important. Fortunately, the code is structured correctly so they're really easy. --- querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala | 4 +++- .../scalajvm/test/querki/graphql/ComputeGraphQLTests.scala | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala index f227047d5..460f2f501 100644 --- a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala +++ b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala @@ -219,11 +219,13 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { * provided processing function, and tuple the result with the selectionName for the resulting JSON. */ def withThingFromArgument(field: Field, selectionName: String)(f: Thing => Res[JsValue]): Res[(String, JsValue)] = { + // If an alias was specified, we use that to return the result: + val returnName = field.alias.getOrElse(selectionName) for { thing <- getThingFromArgument(field, selectionName).toRes jsValue <- f(thing) } - yield (selectionName, jsValue) + yield (returnName, jsValue) } /** diff --git a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala index 4e2a3c9af..cfbd27618 100644 --- a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala +++ b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala @@ -22,7 +22,7 @@ class ComputeGraphQLTests extends QuerkiTests { val thingQuery = s""" |query CDQuery { - | _thing(_oid: "$eurythmicsOID") { + | justEurythmics: _thing(_oid: "$eurythmicsOID") { | _oid | _name | } @@ -33,7 +33,7 @@ class ComputeGraphQLTests extends QuerkiTests { val instancesQuery = s""" |query AlbumQuery { - | _instances(_name: "Album") { + | artistsForAlbums: _instances(_name: "Album") { | _oid | _name | Artists { @@ -62,3 +62,5 @@ class ComputeGraphQLTests extends QuerkiTests { } } } + +// TODO: rendering of text fields, unit tests, support from Console, and real plumbing \ No newline at end of file From b03c2ad1ad3e5bc1ecd5c503b8c000553dcf656d Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Sat, 17 Aug 2019 11:47:53 -0400 Subject: [PATCH 07/22] Beginnings of unit tests --- .../querki/graphql/ComputeGraphQLTests.scala | 113 +++++++++++------- 1 file changed, 70 insertions(+), 43 deletions(-) diff --git a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala index cfbd27618..c4b8a89ad 100644 --- a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala +++ b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala @@ -2,63 +2,90 @@ package querki.graphql import cats.effect._ import cats.implicits._ -import play.api.libs.json.Json -import querki.test.{CDSpace, QuerkiTests} +import org.scalactic.source.Position +import play.api.libs.json.{Json, JsObject, JsValue, JsString} +import querki.test.{TestSpace, CDSpace, QuerkiTests} import querki.util.QLog class ComputeGraphQLTests extends QuerkiTests { - "FPComputeGraphQL" should { - "process a basic query" in { - val cdSpace = new CDSpace - - val computer = new FPComputeGraphQL()(cdSpace.state, ecology) + def runQueryAndCheck[S <: TestSpace](query: String)(check: JsValue => Unit)(implicit space: S, p: Position): Unit = { + val computer = new FPComputeGraphQL()(space.state, ecology) + val jsv = computer.handle(query).unsafeRunSync() + check(jsv) + } - def runQuery(query: String): Unit = { - val jsv = computer.handle(query).unsafeRunSync() - println(Json.prettyPrint(jsv)) + def runQueryAndCheckData[S <: TestSpace](query: String)(check: JsObject => Unit)(implicit space: S, p: Position): Unit = { + runQueryAndCheck(query) { jsv => + val data = (jsv \ "data").getOrElse(fail(s"Query result didn't have a data field: $jsv")) + data match { + case jso: JsObject => check(jso) + case other => fail(s"Resulting data field wasn't a JsObject: $other") } + } + } + + def runQueryAndPrettyPrint[S <: TestSpace](query: String)(implicit space: S, p: Position): Unit = { + runQueryAndCheck(query)(jsv => println(Json.prettyPrint(jsv))) + } + "FPComputeGraphQL" should { + "process a basic query" in { + implicit val cdSpace = new CDSpace val eurythmicsOID = cdSpace.eurythmics.id - val thingQuery = + + runQueryAndCheckData( s""" |query CDQuery { - | justEurythmics: _thing(_oid: "$eurythmicsOID") { + | _thing(_oid: "$eurythmicsOID") { | _oid | _name | } |} """.stripMargin - runQuery(thingQuery) - - val instancesQuery = - s""" - |query AlbumQuery { - | artistsForAlbums: _instances(_name: "Album") { - | _oid - | _name - | Artists { - | _name - | Name - | Genres - | } - | } - |} - """.stripMargin - runQuery(instancesQuery) - - val drillDownTagsQuery = - """ - |query DrillDownTagQuery { - | _thing(_name: "Gordon-Bok") { - | Genres { - | Exemplar { - | _name - | } - | } - | } - |} - """.stripMargin - runQuery(drillDownTagsQuery) + ) { data => + (data \ "_thing" \ "_oid").get shouldBe (JsString(eurythmicsOID.toThingId.toString)) + (data \ "_thing" \ "_name").get shouldBe (JsString(cdSpace.eurythmics.linkName.get)) + } +// val thingQuery = +// s""" +// |query CDQuery { +// | justEurythmics: _thing(_oid: "$eurythmicsOID") { +// | _oid +// | _name +// | } +// |} +// """.stripMargin +// runQuery(thingQuery) +// +// val instancesQuery = +// s""" +// |query AlbumQuery { +// | artistsForAlbums: _instances(_name: "Album") { +// | _oid +// | _name +// | Artists { +// | _name +// | Name +// | Genres +// | } +// | } +// |} +// """.stripMargin +// runQuery(instancesQuery) +// +// val drillDownTagsQuery = +// """ +// |query DrillDownTagQuery { +// | _thing(_name: "Gordon-Bok") { +// | Genres { +// | Exemplar { +// | _name +// | } +// | } +// | } +// |} +// """.stripMargin +// runQuery(drillDownTagsQuery) } } } From 28ce5846ac23943244c074f74e11c7689df37a7e Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Sat, 17 Aug 2019 12:55:21 -0400 Subject: [PATCH 08/22] Unit test refactoring --- .../querki/graphql/ComputeGraphQLTests.scala | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala index c4b8a89ad..8f9d8ba20 100644 --- a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala +++ b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala @@ -28,6 +28,25 @@ class ComputeGraphQLTests extends QuerkiTests { runQueryAndCheck(query)(jsv => println(Json.prettyPrint(jsv))) } + implicit class RichJsObject(jsv: JsObject) { + def field(path: String)(implicit p: Position): JsValue = + (jsv \ path).getOrElse(fail(s"Couldn't find path $path in object $jsv")) + + def obj(path: String)(implicit p: Position): JsObject = { + field(path) match { + case o: JsObject => o + case other => fail(s"Field $path wasn't a String: $other") + } + } + + def string(path: String)(implicit p: Position): String = { + field(path) match { + case JsString(s) => s + case other => fail(s"Field $path wasn't a String: $other") + } + } + } + "FPComputeGraphQL" should { "process a basic query" in { implicit val cdSpace = new CDSpace @@ -43,8 +62,9 @@ class ComputeGraphQLTests extends QuerkiTests { |} """.stripMargin ) { data => - (data \ "_thing" \ "_oid").get shouldBe (JsString(eurythmicsOID.toThingId.toString)) - (data \ "_thing" \ "_name").get shouldBe (JsString(cdSpace.eurythmics.linkName.get)) + val thing = data.obj("_thing") + thing.string("_oid") shouldBe (eurythmicsOID.toThingId.toString) + thing.string("_name") shouldBe (cdSpace.eurythmics.linkName.get) } // val thingQuery = // s""" From 180ba7139749543a68e71716daa97602a3c1b888 Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Sat, 17 Aug 2019 13:30:34 -0400 Subject: [PATCH 09/22] All of the printouts so far are now unit tests The unit tests are by no means comprehensive yet -- in particular, we aren't testing any errors -- but the standard happy paths are now verified. --- .../querki/graphql/ComputeGraphQLTests.scala | 172 +++++++++++++----- 1 file changed, 122 insertions(+), 50 deletions(-) diff --git a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala index 8f9d8ba20..10b822649 100644 --- a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala +++ b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala @@ -3,7 +3,7 @@ package querki.graphql import cats.effect._ import cats.implicits._ import org.scalactic.source.Position -import play.api.libs.json.{Json, JsObject, JsValue, JsString} +import play.api.libs.json._ import querki.test.{TestSpace, CDSpace, QuerkiTests} import querki.util.QLog @@ -28,14 +28,14 @@ class ComputeGraphQLTests extends QuerkiTests { runQueryAndCheck(query)(jsv => println(Json.prettyPrint(jsv))) } - implicit class RichJsObject(jsv: JsObject) { + implicit class RichJsValue(jsv: JsValue) { def field(path: String)(implicit p: Position): JsValue = (jsv \ path).getOrElse(fail(s"Couldn't find path $path in object $jsv")) def obj(path: String)(implicit p: Position): JsObject = { field(path) match { case o: JsObject => o - case other => fail(s"Field $path wasn't a String: $other") + case other => fail(s"Field $path wasn't an Object: $other") } } @@ -45,6 +45,22 @@ class ComputeGraphQLTests extends QuerkiTests { case other => fail(s"Field $path wasn't a String: $other") } } + + def array(path: String)(implicit p: Position): Seq[JsValue] = { + field(path) match { + case JsArray(a) => a + case other => fail(s"Field $path wasn't an Array: $other") + } + } + + def name: String = string("_name") + def hasName(n: String) = name == n + } + + implicit class RichJsSequence(seq: Seq[JsValue]) { + def findByName(name: String)(implicit p: Position): JsValue = { + seq.find(_.hasName(name)).getOrElse(fail(s"Couldn't find an element named $name in $seq")) + } } "FPComputeGraphQL" should { @@ -54,60 +70,116 @@ class ComputeGraphQLTests extends QuerkiTests { runQueryAndCheckData( s""" - |query CDQuery { - | _thing(_oid: "$eurythmicsOID") { - | _oid - | _name - | } - |} + |query CDQuery { + | _thing(_oid: "$eurythmicsOID") { + | _oid + | _name + | } + |} """.stripMargin ) { data => val thing = data.obj("_thing") thing.string("_oid") shouldBe (eurythmicsOID.toThingId.toString) thing.string("_name") shouldBe (cdSpace.eurythmics.linkName.get) } -// val thingQuery = -// s""" -// |query CDQuery { -// | justEurythmics: _thing(_oid: "$eurythmicsOID") { -// | _oid -// | _name -// | } -// |} -// """.stripMargin -// runQuery(thingQuery) -// -// val instancesQuery = -// s""" -// |query AlbumQuery { -// | artistsForAlbums: _instances(_name: "Album") { -// | _oid -// | _name -// | Artists { -// | _name -// | Name -// | Genres -// | } -// | } -// |} -// """.stripMargin -// runQuery(instancesQuery) -// -// val drillDownTagsQuery = -// """ -// |query DrillDownTagQuery { -// | _thing(_name: "Gordon-Bok") { -// | Genres { -// | Exemplar { -// | _name -// | } -// | } -// | } -// |} -// """.stripMargin -// runQuery(drillDownTagsQuery) + } + + "handle top-level aliases" in { + implicit val cdSpace = new CDSpace + val eurythmicsOID = cdSpace.eurythmics.id + + runQueryAndCheckData( + s""" + |query CDQuery { + | justEurythmics: _thing(_oid: "$eurythmicsOID") { + | _oid + | _name + | } + |} + """.stripMargin + ) { data => + val thing = data.obj("justEurythmics") + thing.string("_oid") shouldBe (eurythmicsOID.toThingId.toString) + thing.string("_name") shouldBe (cdSpace.eurythmics.linkName.get) + } + } + + // Also tests: + // * Getting the initial object by name instead of OID + // * Working with all the Instances of a Model + // * Fetching Display Names + // * Getting Tags as names + "dereference and drill down into Links in Instances" in { + implicit val cdSpace = new CDSpace + + runQueryAndCheckData( + s""" + |query AlbumQuery { + | artistsForAlbums: _instances(_name: "Album") { + | _oid + | _name + | Artists { + | _name + | Name + | Genres + | } + | } + |} + """.stripMargin + ) { data => + val albums = data.array("artistsForAlbums") + + // Spot-check some elements in here: + locally { + val album = albums.findByName("Be-Yourself-Tonight") + val artist = album.array("Artists").findByName("Eurythmics") + val genres = artist.array("Genres") + genres.length shouldBe (1) + genres.head shouldBe (JsString("Rock")) + } + + locally { + val album = albums.findByName("Classical-Randomness") + album.array("Artists") shouldBe empty + } + + locally { + val album = albums.findByName("Flood") + val artist = album.array("Artists").find(_.string("Name") == "They Might Be Giants").get + val genres = artist.array("Genres") + genres.length shouldBe (2) + genres should contain (JsString("Rock")) + genres should contain (JsString("Weird")) + } + } + } + + "dereference and drill into Tags when requested" in { + implicit val cdSpace = new CDSpace + + runQueryAndCheckData( + """ + |query DrillDownTagQuery { + | _thing(_name: "Gordon-Bok") { + | Genres { + | Exemplar { + | _name + | } + | } + | } + |} + """.stripMargin + ) { data => + val exemplarName = data + .obj("_thing") + .array("Genres") + .head + .obj("Exemplar") + .name + exemplarName shouldBe ("Blackmores-Night") + } } } } -// TODO: rendering of text fields, unit tests, support from Console, and real plumbing \ No newline at end of file +// TODO: rendering of text fields, support from Console, and real plumbing \ No newline at end of file From b1fb8534957e7f107231dcb44bfda6fee5eec791 Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Sun, 18 Aug 2019 11:25:19 -0400 Subject: [PATCH 10/22] We now render text fields to HTML when requested Also, now copes with multi-word Property names, by using underscore. --- .../app/querki/graphql/FPComputeGraphQL.scala | 53 ++++++++++++++----- .../querki/graphql/ComputeGraphQLTests.scala | 47 ++++++++++++++-- 2 files changed, 83 insertions(+), 17 deletions(-) diff --git a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala index 460f2f501..72e940c71 100644 --- a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala +++ b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala @@ -12,13 +12,13 @@ import querki.core.MOIDs._ import querki.tags.MOIDs._ import querki.core.QLText import querki.globals._ -import querki.values.{PropAndVal, QValue} +import querki.values.{PropAndVal, QValue, RequestContext} import sangria.ast._ import sangria.parser.QueryParser import scala.util.{Success, Failure} -class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { +class FPComputeGraphQL(implicit rc: RequestContext, state: SpaceState, ecology: Ecology) { final val thingQueryName = "_thing" final val instancesQueryName = "_instances" final val idArgName = "_oid" @@ -29,28 +29,43 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { lazy val Basic = ecology.api[querki.basic.Basic] lazy val Core = ecology.api[querki.core.Core] + lazy val QL = ecology.api[querki.ql.QL] lazy val Tags = ecology.api[querki.tags.Tags] // This particular typeclass instance needs to live in here, because it continues to dive down into the stack: trait JsValueable[VT] { - def toJsValue(v: VT, field: Field): Res[JsValue] + def toJsValue(v: VT, field: Field, thing: Thing): Res[JsValue] } object JsValueable { implicit val intJsValueable = new JsValueable[Int] { - def toJsValue(v: Int, field: Field) = res(JsNumber(v)) + def toJsValue(v: Int, field: Field, thing: Thing) = res(JsNumber(v)) } implicit val textJsValueable = new JsValueable[QLText] { - def toJsValue(v: QLText, field: Field) = res(JsString(v.text)) + def toJsValue(v: QLText, field: Field, thing: Thing): Res[JsValue] = { + field.getArgumentBoolean("render") match { + case Valid(Some(true)) => { + val thingContext = thing.thisAsContext + EitherT.right(IO.fromFuture(IO { + val wikitextFuture = QL.process(v, thingContext, lexicalThing = Some(thing)) + val htmlFuture = wikitextFuture.map(_.span.toString) + htmlFuture.map(JsString(_)) + })) + } + // They didn't say to render it, so just return the literal text: + case Valid(Some(false)) | Valid(None) => res(JsString(v.text)) + case Invalid(err) => EitherT.leftT(err) + } + } } implicit val plainTextJsValueable = new JsValueable[PlainText] { - def toJsValue(v: PlainText, field: Field) = res(JsString(v.text)) + def toJsValue(v: PlainText, field: Field, thing: Thing) = res(JsString(v.text)) } implicit val linkJsValueable = new JsValueable[OID] { - def toJsValue(v: OID, field: Field) = { + def toJsValue(v: OID, field: Field, thing: Thing) = { // This is the really interesting one. This is a link, so we recurse down into it: state.anything(v) match { case Some(thing) => processThing(thing, field) @@ -65,7 +80,7 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { * we try to dereference it; if not, we just return the tag's text. */ val tagJsValueable = new JsValueable[PlainText] { - def toJsValue(v: PlainText, field: Field) = { + def toJsValue(v: PlainText, field: Field, thing: Thing) = { if (field.selections.isEmpty) { // Treat the tag as simple text: res(JsString(v.text)) @@ -85,8 +100,8 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { } implicit class JsValueableOps[T: JsValueable](t: T) { - def toJsValue(field: Field): Res[JsValue] = { - implicitly[JsValueable[T]].toJsValue(t, field) + def toJsValue(field: Field, thing: Thing): Res[JsValue] = { + implicitly[JsValueable[T]].toJsValue(t, field, thing: Thing) } } } @@ -297,7 +312,8 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { def getProperty(thing: Thing, field: Field): SyncRes[Property[_, _]] = { val name = field.name val propOpt = for { - thingId <- ThingId.parseOpt(name) + // Since neither spaces nor dashes are legal in GraphQL field names, we have to use underscore: + thingId <- ThingId.parseOpt(name.replace('_', '-')) thing <- state.anything(thingId) prop <- asProp(thing) } @@ -346,18 +362,18 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { if (vs.isEmpty) { resError(MissingRequiredValue(thing, prop, field.location)) } else { - vs.head.toJsValue(field) + vs.head.toJsValue(field, thing) } } case OptionalOID => { vs.headOption match { - case Some(v) => v.toJsValue(field) + case Some(v) => v.toJsValue(field, thing) // TODO: check the GraphQL spec -- is JsNull correct here? case None => res(JsNull) } } case QListOID | QSetOID => { - val jsvs: Res[List[JsValue]] = vs.map(_.toJsValue(field)).sequence + val jsvs: Res[List[JsValue]] = vs.map(_.toJsValue(field, thing)).sequence jsvs.map(JsArray(_)) } case other => { @@ -392,6 +408,15 @@ class FPComputeGraphQL(implicit state: SpaceState, ecology: Ecology) { } implicit class RichField(field: Field) { + def getArgumentBoolean(name: String): SyncRes[Option[Boolean]] = { + field.arguments.find(_.name == name) match { + case Some(arg) => arg.value match { + case v: BooleanValue => Some(v.value).valid + case _ => UnhandledArgumentType(arg, field.location).invalidNec + } + case None => None.valid + } + } def getArgumentStr(name: String): SyncRes[Option[String]] = { field.arguments.find(_.name == name) match { case Some(arg) => arg.value match { diff --git a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala index 10b822649..f3eb5ba7f 100644 --- a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala +++ b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala @@ -5,11 +5,10 @@ import cats.implicits._ import org.scalactic.source.Position import play.api.libs.json._ import querki.test.{TestSpace, CDSpace, QuerkiTests} -import querki.util.QLog class ComputeGraphQLTests extends QuerkiTests { def runQueryAndCheck[S <: TestSpace](query: String)(check: JsValue => Unit)(implicit space: S, p: Position): Unit = { - val computer = new FPComputeGraphQL()(space.state, ecology) + val computer = new FPComputeGraphQL()(getRc, space.state, ecology) val jsv = computer.handle(query).unsafeRunSync() check(jsv) } @@ -180,6 +179,48 @@ class ComputeGraphQLTests extends QuerkiTests { } } } + + // This also tests Properties with spaces in their names; you must use underscore for this: + "show text properties raw by default" in { + implicit val cdSpace = new CDSpace + + runQueryAndCheckData( + """ + |query RawTextQuery { + | _thing(_name: "My-Favorites") { + | Show_Favorites + | } + |} + """.stripMargin + ) { data => + val favesString = data + .obj("_thing") + .string("Show_Favorites") + + favesString shouldBe ("My favorite bands are: [[My Favorites -> _bulleted]]") + } + } + + "show text properties rendered when requested" in { + implicit val cdSpace = new CDSpace + + runQueryAndCheckData( + """ + |query RawTextQuery { + | _thing(_name: "My-Favorites") { + | Show_Favorites(render: true) + | } + |} + """.stripMargin + ) { data => + val favesString = data + .obj("_thing") + .string("Show_Favorites") + + favesString shouldBe ("My favorite bands are: \n") + } + } + } -// TODO: rendering of text fields, support from Console, and real plumbing \ No newline at end of file +// TODO: rendering of text fields, QL functions, unit tests for errors, support from Console, and real plumbing \ No newline at end of file From 838b881d788e9ca3d2dafe3e6af34df6e1f67802 Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Sun, 18 Aug 2019 18:03:29 -0400 Subject: [PATCH 11/22] More sophisticated rendering of text fields We now provide separate "render" and "mode" arguments -- if rendering is turned on, you can select which render mode you want. Along the way, spruced up the handling of arguments to reduce boilerplate. Also, fixed an apparently long-standing and untested bug in CDSpace. --- .../app/querki/graphql/FPComputeGraphQL.scala | 118 ++++++++++++------ .../querki/graphql/ComputeGraphQLTests.scala | 24 ++-- .../scalajvm/test/querki/test/CDSpace.scala | 2 +- 3 files changed, 99 insertions(+), 45 deletions(-) diff --git a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala index 72e940c71..53a8fa366 100644 --- a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala +++ b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala @@ -4,7 +4,7 @@ import cats.data._ import cats.data.Validated._ import cats.effect.IO import cats.implicits._ -import models.{Thing, OID, ThingId, PType, Property} +import models.{Thing, OID, ThingId, DisplayText, PType, Property, Wikitext} import play.api.libs.json._ import querki.basic.PlainText import querki.basic.MOIDs._ @@ -12,7 +12,7 @@ import querki.core.MOIDs._ import querki.tags.MOIDs._ import querki.core.QLText import querki.globals._ -import querki.values.{PropAndVal, QValue, RequestContext} +import querki.values.{PropAndVal, RequestContext, QValue} import sangria.ast._ import sangria.parser.QueryParser @@ -45,17 +45,32 @@ class FPComputeGraphQL(implicit rc: RequestContext, state: SpaceState, ecology: implicit val textJsValueable = new JsValueable[QLText] { def toJsValue(v: QLText, field: Field, thing: Thing): Res[JsValue] = { - field.getArgumentBoolean("render") match { - case Valid(Some(true)) => { + // TODO: make getArgumentEnum smarter, so that it only accepts values of a specific Enumeration: + val args: SyncRes[(Boolean, String)] = + (field.getArgumentBoolean("render", true), field.getArgumentEnum("mode", "STRIP")).mapN(Tuple2.apply) + args match { + case Valid((true, mode)) => { + def renderFromMode(wikitext: Wikitext): DisplayText = { + mode match { + case "RAW" => wikitext.raw + case "STRIP" => wikitext.strip + case "HTML" => wikitext.display + } + } + + // Render the QL... val thingContext = thing.thisAsContext EitherT.right(IO.fromFuture(IO { val wikitextFuture = QL.process(v, thingContext, lexicalThing = Some(thing)) - val htmlFuture = wikitextFuture.map(_.span.toString) - htmlFuture.map(JsString(_)) + val textFuture: Future[DisplayText] = + wikitextFuture.map(renderFromMode(_)) + textFuture.map(displayText => JsString(displayText.toString)) })) } - // They didn't say to render it, so just return the literal text: - case Valid(Some(false)) | Valid(None) => res(JsString(v.text)) + case Valid((false, _)) => { + // We're not rendering, so return the exact text: + res(JsString(v.text)) + } case Invalid(err) => EitherT.leftT(err) } } @@ -247,8 +262,8 @@ class FPComputeGraphQL(implicit rc: RequestContext, state: SpaceState, ecology: * This field should have an argument specifying a Thing. Find that Thing. */ def getThingFromArgument(field: Field, selectionName: String): SyncRes[Thing] = { - field.getArgumentStr(idArgName) match { - case Valid(Some(oidStr)) => { + field.getArgumentStr(idArgName, "") match { + case Valid(oidStr) if (!oidStr.isEmpty) => { OID.parseOpt(oidStr) match { case Some(oid) => { state.anything(oid) match { @@ -259,15 +274,15 @@ class FPComputeGraphQL(implicit rc: RequestContext, state: SpaceState, ecology: case None => NotAnOID(oidStr, field.location).invalidNec } } - case Valid(None) => { - field.getArgumentStr(nameArgName) match { - case Valid(Some(thingName)) => { + case Valid(_) => { + field.getArgumentStr(nameArgName, "") match { + case Valid(thingName) if (!thingName.isEmpty) => { state.anythingByName(thingName) match { case Some(thing) => thing.valid case None => NameNotFound(thingName, field.location).invalidNec } } - case Valid(None) => MissingRequiredArgument(field, selectionName, s"$idArgName or $nameArgName", field.location).invalidNec + case Valid(_) => MissingRequiredArgument(field, selectionName, s"$idArgName or $nameArgName", field.location).invalidNec case Invalid(err) => err.invalid } } @@ -407,33 +422,64 @@ class FPComputeGraphQL(implicit rc: RequestContext, state: SpaceState, ecology: } } - implicit class RichField(field: Field) { - def getArgumentBoolean(name: String): SyncRes[Option[Boolean]] = { - field.arguments.find(_.name == name) match { - case Some(arg) => arg.value match { - case v: BooleanValue => Some(v.value).valid - case _ => UnhandledArgumentType(arg, field.location).invalidNec - } - case None => None.valid + trait GraphQLValue[T <: Value, R] { + def name: String + def fromValue(v: Value): Option[T] + def value(t: T): R + } + + implicit val BooleanV = new GraphQLValue[BooleanValue, Boolean] { + val name = "Boolean" + def fromValue(v: Value): Option[BooleanValue] = { + v match { + case b: BooleanValue => Some(b) + case _ => None } } - def getArgumentStr(name: String): SyncRes[Option[String]] = { - field.arguments.find(_.name == name) match { - case Some(arg) => arg.value match { - case v: StringValue => Some(v.value).valid - case _ => UnhandledArgumentType(arg, field.location).invalidNec - } - case None => None.valid + def value(t: BooleanValue): Boolean = t.value + } + + implicit val StringV = new GraphQLValue[StringValue, String] { + val name = "String" + def fromValue(v: Value): Option[StringValue] = { + v match { + case b: StringValue => Some(b) + case _ => None + } + } + def value(t: StringValue): String = t.value + } + + implicit val EnumV = new GraphQLValue[EnumValue, String] { + val name = "Enum" + def fromValue(v: Value): Option[EnumValue] = { + v match { + case b: EnumValue => Some(b) + case _ => None } } - def getRequiredArgumentStr(name: String): SyncRes[String] = { - getArgumentStr(name) andThen { - _ match { - case Some(v) => v.valid - case None => MissingRequiredArgument(field, name, "string", field.location).invalidNec + def value(t: EnumValue): String = t.value + } + + implicit class RichField(field: Field) { + def getArgument[T <: Value, R](name: String, default: => R)(implicit graphQLValue: GraphQLValue[T, R]): SyncRes[R] = { + field.arguments.find(_.name == name) match { + case Some(arg) => { + graphQLValue.fromValue(arg.value) match { + case Some(v) => graphQLValue.value(v).valid + case None => UnexpectedArgumentType(arg, "Boolean", field.location).invalidNec + } } + case None => default.valid } } + + def getArgumentBoolean(name: String, default: => Boolean): SyncRes[Boolean] = + getArgument[BooleanValue, Boolean](name, default) + def getArgumentStr(name: String, default: => String): SyncRes[String] = + getArgument[StringValue, String](name, default) + def getArgumentEnum(name: String, default: => String): SyncRes[String] = + getArgument[EnumValue, String](name, default) } implicit class RichSyncRes[T](syncRes: SyncRes[T]) { @@ -470,8 +516,8 @@ case object TooManyOperations extends GraphQLError { case class UnhandledSelectionType(selection: Selection, location: Option[AstLocation]) extends GraphQLError { def msg = s"Querki can not yet deal with ${selection.getClass.getSimpleName} selections; sorry." } -case class UnhandledArgumentType(arg: Argument, location: Option[AstLocation]) extends GraphQLError { - def msg = s"Querki can not yet deal with ${arg.value.getClass.getSimpleName} arguments there; sorry." +case class UnexpectedArgumentType(arg: Argument, expected: String, location: Option[AstLocation]) extends GraphQLError { + def msg = s"${arg.value.getClass.getSimpleName} requires an argument of type $expected." } case class MissingRequiredArgument(field: Field, name: String, tpe: String, location: Option[AstLocation]) extends GraphQLError { def msg = s"Field ${field.name} requires a $tpe argument named '$name'" diff --git a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala index f3eb5ba7f..d80cfd3e0 100644 --- a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala +++ b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala @@ -181,14 +181,14 @@ class ComputeGraphQLTests extends QuerkiTests { } // This also tests Properties with spaces in their names; you must use underscore for this: - "show text properties raw by default" in { + "show text properties raw when requested" in { implicit val cdSpace = new CDSpace runQueryAndCheckData( """ |query RawTextQuery { | _thing(_name: "My-Favorites") { - | Show_Favorites + | Show_Favorites(render: false) | } |} """.stripMargin @@ -197,7 +197,7 @@ class ComputeGraphQLTests extends QuerkiTests { .obj("_thing") .string("Show_Favorites") - favesString shouldBe ("My favorite bands are: [[My Favorites -> _bulleted]]") + favesString shouldBe ("My favorite bands are: [[Favorite Artists -> _bulleted]]") } } @@ -206,21 +206,29 @@ class ComputeGraphQLTests extends QuerkiTests { runQueryAndCheckData( """ - |query RawTextQuery { + |query HtmlTextQuery { | _thing(_name: "My-Favorites") { - | Show_Favorites(render: true) + | Show_Favorites(render: true, mode: HTML) | } |} """.stripMargin - ) { data => + ) + { data => val favesString = data .obj("_thing") .string("Show_Favorites") - favesString shouldBe ("My favorite bands are: \n") + favesString shouldBe ( + """
My favorite bands are:
+ |""".stripMargin) } } } -// TODO: rendering of text fields, QL functions, unit tests for errors, support from Console, and real plumbing \ No newline at end of file +// TODO: QL functions, unit tests for errors, support from Console, and real plumbing \ No newline at end of file diff --git a/querki/scalajvm/test/querki/test/CDSpace.scala b/querki/scalajvm/test/querki/test/CDSpace.scala index bbd62631e..5ac26a8fd 100644 --- a/querki/scalajvm/test/querki/test/CDSpace.scala +++ b/querki/scalajvm/test/querki/test/CDSpace.scala @@ -55,5 +55,5 @@ class CDSpace(implicit ecologyIn:Ecology) extends CommonSpace { favoriteArtistsProp(tmbg, blackmores), interestingArtistsProp(eurythmics), otherArtistsProp("Weird Al"), - faveDisplayProp("My favorite bands are: [[My Favorites -> _bulleted]]")) + faveDisplayProp("My favorite bands are: [[Favorite Artists -> _bulleted]]")) } From 2fcd24f6fd56422a1caba5a52f78593d3d78f453 Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Mon, 19 Aug 2019 20:24:11 -0400 Subject: [PATCH 12/22] Pulled GraphQLValue out to its own file --- .../app/querki/graphql/FPComputeGraphQL.scala | 39 -------------- .../app/querki/graphql/GraphQLValue.scala | 53 +++++++++++++++++++ .../querki/graphql/ComputeGraphQLTests.scala | 2 +- 3 files changed, 54 insertions(+), 40 deletions(-) create mode 100644 querki/scalajvm/app/querki/graphql/GraphQLValue.scala diff --git a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala index 53a8fa366..d373dc84d 100644 --- a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala +++ b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala @@ -422,45 +422,6 @@ class FPComputeGraphQL(implicit rc: RequestContext, state: SpaceState, ecology: } } - trait GraphQLValue[T <: Value, R] { - def name: String - def fromValue(v: Value): Option[T] - def value(t: T): R - } - - implicit val BooleanV = new GraphQLValue[BooleanValue, Boolean] { - val name = "Boolean" - def fromValue(v: Value): Option[BooleanValue] = { - v match { - case b: BooleanValue => Some(b) - case _ => None - } - } - def value(t: BooleanValue): Boolean = t.value - } - - implicit val StringV = new GraphQLValue[StringValue, String] { - val name = "String" - def fromValue(v: Value): Option[StringValue] = { - v match { - case b: StringValue => Some(b) - case _ => None - } - } - def value(t: StringValue): String = t.value - } - - implicit val EnumV = new GraphQLValue[EnumValue, String] { - val name = "Enum" - def fromValue(v: Value): Option[EnumValue] = { - v match { - case b: EnumValue => Some(b) - case _ => None - } - } - def value(t: EnumValue): String = t.value - } - implicit class RichField(field: Field) { def getArgument[T <: Value, R](name: String, default: => R)(implicit graphQLValue: GraphQLValue[T, R]): SyncRes[R] = { field.arguments.find(_.name == name) match { diff --git a/querki/scalajvm/app/querki/graphql/GraphQLValue.scala b/querki/scalajvm/app/querki/graphql/GraphQLValue.scala new file mode 100644 index 000000000..1e14e0338 --- /dev/null +++ b/querki/scalajvm/app/querki/graphql/GraphQLValue.scala @@ -0,0 +1,53 @@ +package querki.graphql + +import sangria.ast.{StringValue, Value, BooleanValue, EnumValue} + +trait GraphQLValue[T <: Value, R] { + def name: String + def fromValue(v: Value): Option[T] + def value(t: T): R +} + +object GraphQLValue { + implicit val BooleanV = new GraphQLValue[BooleanValue, Boolean] { + + import sangria.ast.Value + + val name = "Boolean" + + def fromValue(v: Value): Option[BooleanValue] = { + v match { + case b: BooleanValue => Some(b) + case _ => None + } + } + + def value(t: BooleanValue): Boolean = t.value + } + + implicit val StringV = new GraphQLValue[StringValue, String] { + val name = "String" + + def fromValue(v: Value): Option[StringValue] = { + v match { + case b: StringValue => Some(b) + case _ => None + } + } + + def value(t: StringValue): String = t.value + } + + implicit val EnumV = new GraphQLValue[EnumValue, String] { + val name = "Enum" + + def fromValue(v: Value): Option[EnumValue] = { + v match { + case b: EnumValue => Some(b) + case _ => None + } + } + + def value(t: EnumValue): String = t.value + } +} \ No newline at end of file diff --git a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala index d80cfd3e0..9b02754a6 100644 --- a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala +++ b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala @@ -231,4 +231,4 @@ class ComputeGraphQLTests extends QuerkiTests { } -// TODO: QL functions, unit tests for errors, support from Console, and real plumbing \ No newline at end of file +// TODO: QL functions and dynamic QL queries, unit tests for errors, support from Console, and real plumbing \ No newline at end of file From 7110323c2ecc8f778096e136f1523c3659a3b0c7 Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Mon, 19 Aug 2019 20:35:11 -0400 Subject: [PATCH 13/22] Refactorings to pull stuff out of FPComputeGraphQL That file has simply gotten unwieldy, so lifted some bits out. --- .../app/querki/graphql/FPComputeGraphQL.scala | 97 +-------------- .../app/querki/graphql/GraphQLValue.scala | 3 - .../app/querki/graphql/JsValueable.scala | 113 ++++++++++++++++++ .../scalajvm/app/querki/graphql/package.scala | 9 ++ 4 files changed, 125 insertions(+), 97 deletions(-) create mode 100644 querki/scalajvm/app/querki/graphql/JsValueable.scala create mode 100644 querki/scalajvm/app/querki/graphql/package.scala diff --git a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala index d373dc84d..314a46e12 100644 --- a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala +++ b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala @@ -18,110 +18,19 @@ import sangria.parser.QueryParser import scala.util.{Success, Failure} -class FPComputeGraphQL(implicit rc: RequestContext, state: SpaceState, ecology: Ecology) { +class FPComputeGraphQL(implicit val rc: RequestContext, val state: SpaceState, val ecology: Ecology) + extends JsValueableMixin +{ final val thingQueryName = "_thing" final val instancesQueryName = "_instances" final val idArgName = "_oid" final val nameArgName = "_name" - type SyncRes[T] = ValidatedNec[GraphQLError, T] - type Res[T] = EitherT[IO, NonEmptyChain[GraphQLError], T] - lazy val Basic = ecology.api[querki.basic.Basic] lazy val Core = ecology.api[querki.core.Core] lazy val QL = ecology.api[querki.ql.QL] lazy val Tags = ecology.api[querki.tags.Tags] - // This particular typeclass instance needs to live in here, because it continues to dive down into the stack: - - trait JsValueable[VT] { - def toJsValue(v: VT, field: Field, thing: Thing): Res[JsValue] - } - - object JsValueable { - implicit val intJsValueable = new JsValueable[Int] { - def toJsValue(v: Int, field: Field, thing: Thing) = res(JsNumber(v)) - } - - implicit val textJsValueable = new JsValueable[QLText] { - def toJsValue(v: QLText, field: Field, thing: Thing): Res[JsValue] = { - // TODO: make getArgumentEnum smarter, so that it only accepts values of a specific Enumeration: - val args: SyncRes[(Boolean, String)] = - (field.getArgumentBoolean("render", true), field.getArgumentEnum("mode", "STRIP")).mapN(Tuple2.apply) - args match { - case Valid((true, mode)) => { - def renderFromMode(wikitext: Wikitext): DisplayText = { - mode match { - case "RAW" => wikitext.raw - case "STRIP" => wikitext.strip - case "HTML" => wikitext.display - } - } - - // Render the QL... - val thingContext = thing.thisAsContext - EitherT.right(IO.fromFuture(IO { - val wikitextFuture = QL.process(v, thingContext, lexicalThing = Some(thing)) - val textFuture: Future[DisplayText] = - wikitextFuture.map(renderFromMode(_)) - textFuture.map(displayText => JsString(displayText.toString)) - })) - } - case Valid((false, _)) => { - // We're not rendering, so return the exact text: - res(JsString(v.text)) - } - case Invalid(err) => EitherT.leftT(err) - } - } - } - implicit val plainTextJsValueable = new JsValueable[PlainText] { - def toJsValue(v: PlainText, field: Field, thing: Thing) = res(JsString(v.text)) - } - - implicit val linkJsValueable = new JsValueable[OID] { - def toJsValue(v: OID, field: Field, thing: Thing) = { - // This is the really interesting one. This is a link, so we recurse down into it: - state.anything(v) match { - case Some(thing) => processThing(thing, field) - case None => resError(OIDNotFound(v.toThingId.toString, field.location)) - } - } - } - - /** - * Tags are a weird neither-fish-nor-fowl, and they violate the GraphQL convention that things need to be - * rigidly typed. This gets evaluated differently depending on how it is called. If there are sub-selections, - * we try to dereference it; if not, we just return the tag's text. - */ - val tagJsValueable = new JsValueable[PlainText] { - def toJsValue(v: PlainText, field: Field, thing: Thing) = { - if (field.selections.isEmpty) { - // Treat the tag as simple text: - res(JsString(v.text)) - } else { - // There are sub-selections, so dereference it: - val name = v.text - val thing = state.anythingByName(name) match { - case Some(thing) => thing - case None => state.anythingByDisplayName(name) match { - case Some(thing) => thing - case None => Tags.getTag(name, state) - } - } - processThing(thing, field) - } - } - } - - implicit class JsValueableOps[T: JsValueable](t: T) { - def toJsValue(field: Field, thing: Thing): Res[JsValue] = { - implicitly[JsValueable[T]].toJsValue(t, field, thing: Thing) - } - } - } - import JsValueable._ - def handle(query: String): IO[JsValue] = { val result: IO[Either[NonEmptyChain[GraphQLError], JsValue]] = processQuery(query).value result.map { diff --git a/querki/scalajvm/app/querki/graphql/GraphQLValue.scala b/querki/scalajvm/app/querki/graphql/GraphQLValue.scala index 1e14e0338..5b2456b63 100644 --- a/querki/scalajvm/app/querki/graphql/GraphQLValue.scala +++ b/querki/scalajvm/app/querki/graphql/GraphQLValue.scala @@ -10,9 +10,6 @@ trait GraphQLValue[T <: Value, R] { object GraphQLValue { implicit val BooleanV = new GraphQLValue[BooleanValue, Boolean] { - - import sangria.ast.Value - val name = "Boolean" def fromValue(v: Value): Option[BooleanValue] = { diff --git a/querki/scalajvm/app/querki/graphql/JsValueable.scala b/querki/scalajvm/app/querki/graphql/JsValueable.scala new file mode 100644 index 000000000..44c819388 --- /dev/null +++ b/querki/scalajvm/app/querki/graphql/JsValueable.scala @@ -0,0 +1,113 @@ +package querki.graphql + +import cats.data._ +import cats.data.Validated._ +import cats.implicits._ +import cats.effect.IO + +import sangria.ast.Field + +import querki.basic.PlainText +import querki.core.QLText +import querki.globals._ +import models.{Thing, DisplayText, OID, Wikitext} + +import play.api.libs.json.{JsValue, JsString, JsNumber} + +/** + * Typeclass that represents the notion of a type that can be converted to a JsValue. + * + * @tparam VT the VType of a Querki PType + */ +trait JsValueable[VT] { + def toJsValue(v: VT, field: Field, thing: Thing): Res[JsValue] +} + +/** + * Instances of JsValueable. + * + * This is structured as a mixin with FPComputeGraphQL. They're really bound at the hip, but we cake-pattern them + * just to reduce the size of that file a bit. + */ +trait JsValueableMixin { self: FPComputeGraphQL => + implicit val intJsValueable = new JsValueable[Int] { + def toJsValue(v: Int, field: Field, thing: Thing) = res(JsNumber(v)) + } + + implicit val textJsValueable = new JsValueable[QLText] { + def toJsValue(v: QLText, field: Field, thing: Thing): Res[JsValue] = { + // TODO: make getArgumentEnum smarter, so that it only accepts values of a specific Enumeration: + val args: SyncRes[(Boolean, String)] = + (field.getArgumentBoolean("render", true), field.getArgumentEnum("mode", "STRIP")).mapN(Tuple2.apply) + args match { + case Valid((true, mode)) => { + def renderFromMode(wikitext: Wikitext): DisplayText = { + mode match { + case "RAW" => wikitext.raw + case "STRIP" => wikitext.strip + case "HTML" => wikitext.display + } + } + + // Render the QL... + val thingContext = thing.thisAsContext + EitherT.right(IO.fromFuture(IO { + val wikitextFuture = QL.process(v, thingContext, lexicalThing = Some(thing)) + val textFuture: Future[DisplayText] = + wikitextFuture.map(renderFromMode(_)) + textFuture.map(displayText => JsString(displayText.toString)) + })) + } + case Valid((false, _)) => { + // We're not rendering, so return the exact text: + res(JsString(v.text)) + } + case Invalid(err) => EitherT.leftT(err) + } + } + } + implicit val plainTextJsValueable = new JsValueable[PlainText] { + def toJsValue(v: PlainText, field: Field, thing: Thing) = res(JsString(v.text)) + } + + implicit val linkJsValueable = new JsValueable[OID] { + def toJsValue(v: OID, field: Field, thing: Thing) = { + // This is the really interesting one. This is a link, so we recurse down into it: + state.anything(v) match { + case Some(thing) => processThing(thing, field) + case None => resError(OIDNotFound(v.toThingId.toString, field.location)) + } + } + } + + /** + * Tags are a weird neither-fish-nor-fowl, and they violate the GraphQL convention that things need to be + * rigidly typed. This gets evaluated differently depending on how it is called. If there are sub-selections, + * we try to dereference it; if not, we just return the tag's text. + */ + val tagJsValueable = new JsValueable[PlainText] { + def toJsValue(v: PlainText, field: Field, thing: Thing) = { + if (field.selections.isEmpty) { + // Treat the tag as simple text: + res(JsString(v.text)) + } else { + // There are sub-selections, so dereference it: + val name = v.text + val thing = state.anythingByName(name) match { + case Some(thing) => thing + case None => state.anythingByDisplayName(name) match { + case Some(thing) => thing + case None => Tags.getTag(name, state) + } + } + processThing(thing, field) + } + } + } + + implicit class JsValueableOps[T: JsValueable](t: T) { + def toJsValue(field: Field, thing: Thing): Res[JsValue] = { + implicitly[JsValueable[T]].toJsValue(t, field, thing: Thing) + } + } +} diff --git a/querki/scalajvm/app/querki/graphql/package.scala b/querki/scalajvm/app/querki/graphql/package.scala new file mode 100644 index 000000000..db6d26f1b --- /dev/null +++ b/querki/scalajvm/app/querki/graphql/package.scala @@ -0,0 +1,9 @@ +package querki + +import cats.effect.IO +import cats.data.{EitherT, NonEmptyChain, ValidatedNec} + +package object graphql { + type SyncRes[T] = ValidatedNec[GraphQLError, T] + type Res[T] = EitherT[IO, NonEmptyChain[GraphQLError], T] +} From 2a3343682b0e20165f34783b47c19ebde40418a4 Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Mon, 19 Aug 2019 23:27:48 -0400 Subject: [PATCH 14/22] Un-hardcode the rehydration of types Took a while to figure out how to do this, but it's lovely: I've puzzled out how to go from Querki untyped world to regaining types when I need them. Yay! --- .../app/querki/graphql/FPComputeGraphQL.scala | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala index 314a46e12..7dbfab6ff 100644 --- a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala +++ b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala @@ -246,20 +246,44 @@ class FPComputeGraphQL(implicit val rc: RequestContext, val state: SpaceState, v propOpt.syncOrError(UnknownProperty(name, field.location)) } - def processProperty(thing: Thing, prop: Property[_, _], field: Field): Res[JsValue] = { - // TODO: this is ugly and hardcoded -- can we make it better? Do note that we need to rehydrate the types here - // *somehow*, so this isn't trivial: - prop.pType.id match { - case IntTypeOID => processTypedProperty(thing, prop.confirmType(Core.IntType), Core.IntType, field) - case TextTypeOID => processTypedProperty(thing, prop.confirmType(Core.TextType), Core.TextType, field) - case LargeTextTypeOID => processTypedProperty(thing, prop.confirmType(Core.LargeTextType), Core.LargeTextType, field) - case LinkTypeOID => processTypedProperty(thing, prop.confirmType(Core.LinkType), Core.LinkType, field) - case PlainTextOID => processTypedProperty(thing, prop.confirmType(Basic.PlainTextType), Basic.PlainTextType, field) - case NewTagSetOID => processTypedProperty(thing, prop.confirmType(Tags.NewTagSetType), Tags.NewTagSetType, field)(tagJsValueable) - case _ => resError(UnsupportedType(prop, field.location)) + case class PTypeInfo[VT](ptin: PType[VT], jsvin: JsValueable[VT]) { + type VType = VT + def pType: PType[VType] = ptin + def jsv: JsValueable[VType] = jsvin + def confirm(prop: AnyProp): Option[Property[VType, _]] = prop.confirmType(pType) + } + def getPTypeInfo(pType: PType[_]): PTypeInfo[_] = { + def infoFor[VT: JsValueable](pt: PType[VT]): PTypeInfo[VT] = { + PTypeInfo(pt, implicitly[JsValueable[VT]]) + } + + pType.id match { + case IntTypeOID => infoFor(Core.IntType) + case TextTypeOID => infoFor(Core.TextType) + case LargeTextTypeOID => infoFor(Core.LargeTextType) + case LinkTypeOID => infoFor(Core.LinkType) + case PlainTextOID => infoFor(Basic.PlainTextType) + case NewTagSetOID => PTypeInfo(Tags.NewTagSetType, tagJsValueable) } } + def processProperty(thing: Thing, prop: Property[_, _], field: Field): Res[JsValue] = { + val info = getPTypeInfo(prop.pType) + processTypedProperty[info.VType](thing, info.confirm(prop), info.pType, field)(info.jsv) + +// // TODO: this is ugly and hardcoded -- can we make it better? Do note that we need to rehydrate the types here +// // *somehow*, so this isn't trivial: +// prop.pType.id match { +// case IntTypeOID => processTypedProperty(thing, prop.confirmType(Core.IntType), Core.IntType, field) +// case TextTypeOID => processTypedProperty(thing, prop.confirmType(Core.TextType), Core.TextType, field) +// case LargeTextTypeOID => processTypedProperty(thing, prop.confirmType(Core.LargeTextType), Core.LargeTextType, field) +// case LinkTypeOID => processTypedProperty(thing, prop.confirmType(Core.LinkType), Core.LinkType, field) +// case PlainTextOID => processTypedProperty(thing, prop.confirmType(Basic.PlainTextType), Basic.PlainTextType, field) +// case NewTagSetOID => processTypedProperty(thing, prop.confirmType(Tags.NewTagSetType), Tags.NewTagSetType, field)(tagJsValueable) +// case _ => resError(UnsupportedType(prop, field.location)) +// } + } + def processTypedProperty[VT](thing: Thing, propOpt: Option[Property[VT, _]], pt: PType[VT], field: Field)(implicit ev: JsValueable[VT]): Res[JsValue] = { val resultOpt: Option[Res[JsValue]] = propOpt.map { prop => thing.getPropOpt(prop) match { From 6a7db78ffc4b4f3878112e98465fe954310b1a75 Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Mon, 19 Aug 2019 23:36:52 -0400 Subject: [PATCH 15/22] Now all the way back to previous functionality, more cleanly. We now deal with unknown types properly again, and have added some documentation. --- .../app/querki/graphql/FPComputeGraphQL.scala | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala index 7dbfab6ff..91f26abe8 100644 --- a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala +++ b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala @@ -246,42 +246,48 @@ class FPComputeGraphQL(implicit val rc: RequestContext, val state: SpaceState, v propOpt.syncOrError(UnknownProperty(name, field.location)) } + /** + * This represents a "rehydrated" PType, capturing the VType such that we can then use it in further processing. + * + * We do things this way so that multiple code paths can use the rehydrated values, without excessive boilerplate. + * + * Theoretically, some of the stuff in here is just silly and pointless, but the compiler isn't smart enough to + * realize that the VT parameter and VType type member are the same without this massaging, and VType is the + * point of the exercise: that is what allows us to pass the rehydrated type around. + */ case class PTypeInfo[VT](ptin: PType[VT], jsvin: JsValueable[VT]) { type VType = VT def pType: PType[VType] = ptin def jsv: JsValueable[VType] = jsvin def confirm(prop: AnyProp): Option[Property[VType, _]] = prop.confirmType(pType) } - def getPTypeInfo(pType: PType[_]): PTypeInfo[_] = { + + /** + * Given a raw PType, fetch the PTypeInfo for it, if it's a known one. + */ + def getPTypeInfo(pType: PType[_]): Option[PTypeInfo[_]] = { def infoFor[VT: JsValueable](pt: PType[VT]): PTypeInfo[VT] = { PTypeInfo(pt, implicitly[JsValueable[VT]]) } + // TODO: prebuild and cache the instances of PTypeInfo, now that the mechanism is working. We can probably just + // have a Map[OID, PTypeInfo] that we fetch these from: pType.id match { - case IntTypeOID => infoFor(Core.IntType) - case TextTypeOID => infoFor(Core.TextType) - case LargeTextTypeOID => infoFor(Core.LargeTextType) - case LinkTypeOID => infoFor(Core.LinkType) - case PlainTextOID => infoFor(Basic.PlainTextType) - case NewTagSetOID => PTypeInfo(Tags.NewTagSetType, tagJsValueable) + case IntTypeOID => Some(infoFor(Core.IntType)) + case TextTypeOID => Some(infoFor(Core.TextType)) + case LargeTextTypeOID => Some(infoFor(Core.LargeTextType)) + case LinkTypeOID => Some(infoFor(Core.LinkType)) + case PlainTextOID => Some(infoFor(Basic.PlainTextType)) + case NewTagSetOID => Some(PTypeInfo(Tags.NewTagSetType, tagJsValueable)) + case _ => None } } def processProperty(thing: Thing, prop: Property[_, _], field: Field): Res[JsValue] = { - val info = getPTypeInfo(prop.pType) - processTypedProperty[info.VType](thing, info.confirm(prop), info.pType, field)(info.jsv) - -// // TODO: this is ugly and hardcoded -- can we make it better? Do note that we need to rehydrate the types here -// // *somehow*, so this isn't trivial: -// prop.pType.id match { -// case IntTypeOID => processTypedProperty(thing, prop.confirmType(Core.IntType), Core.IntType, field) -// case TextTypeOID => processTypedProperty(thing, prop.confirmType(Core.TextType), Core.TextType, field) -// case LargeTextTypeOID => processTypedProperty(thing, prop.confirmType(Core.LargeTextType), Core.LargeTextType, field) -// case LinkTypeOID => processTypedProperty(thing, prop.confirmType(Core.LinkType), Core.LinkType, field) -// case PlainTextOID => processTypedProperty(thing, prop.confirmType(Basic.PlainTextType), Basic.PlainTextType, field) -// case NewTagSetOID => processTypedProperty(thing, prop.confirmType(Tags.NewTagSetType), Tags.NewTagSetType, field)(tagJsValueable) -// case _ => resError(UnsupportedType(prop, field.location)) -// } + getPTypeInfo(prop.pType) match { + case Some(info) => processTypedProperty[info.VType](thing, info.confirm(prop), info.pType, field)(info.jsv) + case None => resError(UnsupportedType(prop, field.location)) + } } def processTypedProperty[VT](thing: Thing, propOpt: Option[Property[VT, _]], pt: PType[VT], field: Field)(implicit ev: JsValueable[VT]): Res[JsValue] = { From 0e05d1021d458606d1a799d43dc19ad3dca35613 Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Tue, 20 Aug 2019 09:17:35 -0400 Subject: [PATCH 16/22] We now deal with QL Functions Also, fixed a bug that was preventing Querki built-in functions that start with an underscore from working properly as GraphQL Fields. --- .../app/querki/graphql/FPComputeGraphQL.scala | 42 ++++++++++++++++--- .../app/querki/graphql/JsValueable.scala | 8 ++++ .../querki/graphql/ComputeGraphQLTests.scala | 28 +++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala index 91f26abe8..f555dda42 100644 --- a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala +++ b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala @@ -235,9 +235,11 @@ class FPComputeGraphQL(implicit val rc: RequestContext, val state: SpaceState, v def getProperty(thing: Thing, field: Field): SyncRes[Property[_, _]] = { val name = field.name + // Since neither spaces nor dashes are legal in GraphQL field names, we have to use underscore. But a *leading* + // underscore is a Querki built-in, so leave it alone: + val propName = name.take(1) + name.drop(1).replace('_', '-') val propOpt = for { - // Since neither spaces nor dashes are legal in GraphQL field names, we have to use underscore: - thingId <- ThingId.parseOpt(name.replace('_', '-')) + thingId <- ThingId.parseOpt(propName) thing <- state.anything(thingId) prop <- asProp(thing) } @@ -279,6 +281,7 @@ class FPComputeGraphQL(implicit val rc: RequestContext, val state: SpaceState, v case LinkTypeOID => Some(infoFor(Core.LinkType)) case PlainTextOID => Some(infoFor(Basic.PlainTextType)) case NewTagSetOID => Some(PTypeInfo(Tags.NewTagSetType, tagJsValueable)) + case QLTypeOID => Some(PTypeInfo(Basic.QLType, functionJsValueable)) case _ => None } } @@ -286,7 +289,7 @@ class FPComputeGraphQL(implicit val rc: RequestContext, val state: SpaceState, v def processProperty(thing: Thing, prop: Property[_, _], field: Field): Res[JsValue] = { getPTypeInfo(prop.pType) match { case Some(info) => processTypedProperty[info.VType](thing, info.confirm(prop), info.pType, field)(info.jsv) - case None => resError(UnsupportedType(prop, field.location)) + case None => resError(UnsupportedType(prop.pType, field.location)) } } @@ -338,6 +341,35 @@ class FPComputeGraphQL(implicit val rc: RequestContext, val state: SpaceState, v } } + def processQL(ql: QLText, thing: Thing, field: Field): Res[JsValue] = { + val thingContext = thing.thisAsContext + val qvRes: Res[QValue] = EitherT.right(IO.fromFuture(IO { + QL.processMethod(ql, thingContext, lexicalThing = Some(thing)) + })) + qvRes.flatMap(qv => processQValue(qv, thing, field)) + } + + def processQValue(qv: QValue, thing: Thing, field: Field): Res[JsValue] = { + getPTypeInfo(qv.pType) match { + case Some(ptInfo) => { + // The 2.11 compiler isn't quite smart enough to thread together the VType relationships here unless we + // spell them out: + implicit val jsValueable: JsValueable[ptInfo.VType] = ptInfo.jsv + val vs: List[ptInfo.VType] = qv.rawList(ptInfo.pType) + val results = vs.map(_.toJsValue(field, thing)).sequence + // Function results are always returned as JsArray, no matter how many results come out, to make it reasonably + // predictable at the client end: + results.map(JsArray(_)) + } + case None => resError(UnsupportedType(qv.pType, field.location)) + } + } + + //////////////////////////////////////// + // + // Utility Functions + // + def res[T](v: T): Res[T] = { EitherT.rightT(v) } @@ -443,8 +475,8 @@ case class UnknownProperty(name: String, location: Option[AstLocation]) extends case class PropertyNotOnThing(thing: Thing, prop: AnyProp, location: Option[AstLocation]) extends GraphQLError { def msg = s"${thing.displayName} does not have requested property ${prop.displayName}" } -case class UnsupportedType(prop: Property[_, _], location: Option[AstLocation]) extends GraphQLError { - def msg = s"Querki GraphQL does not yet support ${prop.pType.displayName} properties; sorry." +case class UnsupportedType(pType: PType[_], location: Option[AstLocation]) extends GraphQLError { + def msg = s"Querki GraphQL does not yet support ${pType.displayName} properties; sorry." } case class InternalGraphQLError(msg: String, location: Option[AstLocation]) extends GraphQLError case class MissingRequiredValue(thing: Thing, prop: Property[_, _], location: Option[AstLocation]) extends GraphQLError { diff --git a/querki/scalajvm/app/querki/graphql/JsValueable.scala b/querki/scalajvm/app/querki/graphql/JsValueable.scala index 44c819388..1ed0c71b9 100644 --- a/querki/scalajvm/app/querki/graphql/JsValueable.scala +++ b/querki/scalajvm/app/querki/graphql/JsValueable.scala @@ -105,6 +105,14 @@ trait JsValueableMixin { self: FPComputeGraphQL => } } + /** + * Another serious exception: Functions result in a JsArray of whatever type comes out of the function. + * Depending on that result, you may or may not be able to drill down into them. + */ + val functionJsValueable = new JsValueable[QLText] { + def toJsValue(v: QLText, field: Field, thing: Thing) = processQL(v, thing, field) + } + implicit class JsValueableOps[T: JsValueable](t: T) { def toJsValue(field: Field, thing: Thing): Res[JsValue] = { implicitly[JsValueable[T]].toJsValue(t, field, thing: Thing) diff --git a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala index 9b02754a6..153492d1b 100644 --- a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala +++ b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala @@ -5,6 +5,7 @@ import cats.implicits._ import org.scalactic.source.Position import play.api.libs.json._ import querki.test.{TestSpace, CDSpace, QuerkiTests} +import querki.util.QLog class ComputeGraphQLTests extends QuerkiTests { def runQueryAndCheck[S <: TestSpace](query: String)(check: JsValue => Unit)(implicit space: S, p: Position): Unit = { @@ -229,6 +230,33 @@ class ComputeGraphQLTests extends QuerkiTests { } } + "work with the Apply function property" in { + implicit val s = new CDSpace { + val complexArtists = + new SimpleTestThing( + "Complex Artists", + Basic.ApplyMethod("Artist._instances -> _filter(Genres -> _count -> _greaterThan(1))")) + } + + runQueryAndCheckData( + """query ComplexArtistsQuery { + | complex: _thing(_name: "Complex-Artists") { + | _apply { + | _oid + | _name + | } + | } + |} + """.stripMargin + ) { data => + val results = data + .obj("complex") + .array("_apply") + + results.length shouldBe (2) + } + } + } // TODO: QL functions and dynamic QL queries, unit tests for errors, support from Console, and real plumbing \ No newline at end of file From 2a31f84a3dcbc27a385eaaf4f8d491856d3d92fb Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Thu, 22 Aug 2019 17:31:38 -0400 Subject: [PATCH 17/22] We can now evaluate QL expressions passed as parameters This makes the system crazy-powerful, allowing easy use of QL filters and such in the context of GraphQL. Non-standard, but hella useful. --- .../app/querki/graphql/FPComputeGraphQL.scala | 18 +++++++++++++++- .../querki/graphql/ComputeGraphQLTests.scala | 21 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala index f555dda42..764079093 100644 --- a/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala +++ b/querki/scalajvm/app/querki/graphql/FPComputeGraphQL.scala @@ -23,8 +23,10 @@ class FPComputeGraphQL(implicit val rc: RequestContext, val state: SpaceState, v { final val thingQueryName = "_thing" final val instancesQueryName = "_instances" + final val expQueryName = "_exp" final val idArgName = "_oid" final val nameArgName = "_name" + final val qlArgName = "_ql" lazy val Basic = ecology.api[querki.basic.Basic] lazy val Core = ecology.api[querki.core.Core] @@ -148,6 +150,17 @@ class FPComputeGraphQL(implicit val rc: RequestContext, val state: SpaceState, v }.sequence processedThings.map(JsArray(_)) } + } else if (field.name == expQueryName) { + val selectionName = field.name + val returnName = field.alias.getOrElse(selectionName) + field.getArgumentStr(qlArgName, "") match { + case Valid(qlExp) if (!qlExp.isEmpty) => { + processQL(QLText(qlExp), state, field) + .map((returnName, _)) + } + case Valid(_) => resError(MissingQLExp(field.location)) + case Invalid(err) => EitherT.leftT(err) + } } else { resError(IllegalTopSelection(field.name, field.location)) } @@ -481,4 +494,7 @@ case class UnsupportedType(pType: PType[_], location: Option[AstLocation]) exten case class InternalGraphQLError(msg: String, location: Option[AstLocation]) extends GraphQLError case class MissingRequiredValue(thing: Thing, prop: Property[_, _], location: Option[AstLocation]) extends GraphQLError { def msg = s"Required Property ${prop.displayName} on Thing ${thing.displayName} is empty!" -} \ No newline at end of file +} +case class MissingQLExp(location: Option[AstLocation]) extends GraphQLError { + def msg = s"_exp queries requires a _ql argument with the expression to process!" +} diff --git a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala index 153492d1b..3ffa04d4f 100644 --- a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala +++ b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala @@ -257,6 +257,25 @@ class ComputeGraphQLTests extends QuerkiTests { } } + "allow me to evaluate QL dynamically" in { + implicit val s = new CDSpace + + runQueryAndCheckData( + """query ComplexArtistsQuery { + | dynamic: _exp(_ql: "Artist._instances -> _filter(Genres -> _count -> _greaterThan(1))") { + | _oid + | _name + | } + |} + """.stripMargin + ) { data => + val results = data + .array("dynamic") + + results.length shouldBe (2) + } + } + } -// TODO: QL functions and dynamic QL queries, unit tests for errors, support from Console, and real plumbing \ No newline at end of file +// TODO: unit tests for errors, support from Console, and real plumbing \ No newline at end of file From 269b7b2d494ca351829bb0870a28d1dd4e939ea8 Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Sat, 24 Aug 2019 15:22:45 -0400 Subject: [PATCH 18/22] Added the `Run GraphQL()` Console Command This should make it easier to quickly craft a query, to then use in an API. --- .../app/querki/console/ConsoleEcot.scala | 2 +- .../app/querki/graphql/GraphQLEcot.scala | 65 +++++++++++++++++++ querki/scalajvm/app/querki/ql/AST.scala | 4 +- .../app/querki/system/SystemCreator.scala | 1 + 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 querki/scalajvm/app/querki/graphql/GraphQLEcot.scala diff --git a/querki/scalajvm/app/querki/console/ConsoleEcot.scala b/querki/scalajvm/app/querki/console/ConsoleEcot.scala index 2e2584c80..a5276634f 100644 --- a/querki/scalajvm/app/querki/console/ConsoleEcot.scala +++ b/querki/scalajvm/app/querki/console/ConsoleEcot.scala @@ -41,7 +41,7 @@ class ConsoleEcot(e:Ecology) extends QuerkiEcot(e) with querki.core.MethodDefs w } val qlContext = QLContext(ExactlyOne(LinkType(state)), Some(context.rc), TimeProvider.qlEndTime) val cmdText = QLText(cmdStr) - + // First, we process the command as QL. Note that we do this completely ignoring permissions // (aside from the built-in read permission), but that's okay -- processMethod() is pure. QL.processMethod(cmdText, qlContext).flatMap { qv => diff --git a/querki/scalajvm/app/querki/graphql/GraphQLEcot.scala b/querki/scalajvm/app/querki/graphql/GraphQLEcot.scala new file mode 100644 index 000000000..aa44bd164 --- /dev/null +++ b/querki/scalajvm/app/querki/graphql/GraphQLEcot.scala @@ -0,0 +1,65 @@ +package querki.graphql + +import play.api.libs.json.Json +import querki.console.CommandEffectArgs +import querki.console.ConsoleFunctions.{ErrorResult, DisplayTextResult} +import querki.ecology.{QuerkiEcot, EcotIds} +import querki.globals._ +import querki.ql.{QLExp, QLTextStage, UnQLText} + +object MOIDs extends EcotIds(76) { + val GraphQLCommandOID = moid(1) +} + +class GraphQLEcot(e:Ecology) extends QuerkiEcot(e) with querki.core.MethodDefs { + import MOIDs._ + + val AccessControl = initRequires[querki.security.AccessControl] + val Basic = initRequires[querki.basic.Basic] + val Console = initRequires[querki.console.Console] + + lazy val GraphQLCommand = Console.defineSpaceCommand( + GraphQLCommandOID, + "Run GraphQL", + "Processes the given GraphQL query and shows the result", + // This is intentionally very low-security, since it shouldn't be able to do anything you can't do anyway: + Seq(AccessControl.CanReadProp) + ) { + case CommandEffectArgs(inv, api) => + implicit val state = inv.state + + // AFAIK we don't have a "raw text" type yet, and letting the query get processed as PlainText or QLText + // winds up mutating it. So extract the raw String. + // TODO: introduce or find a proper internal Raw String type, for situations like this where I want + // exactly the contents of the parameter, with zero processing: + def extractQuery(qlExp: QLExp): Option[String] = { + for { + phrase <- qlExp.phrases.headOption + stage <- phrase.ops.headOption + textStage <- stage match { + case ts: QLTextStage => Some(ts) + case _ => None + } + } + yield textStage.contents.reconstructString + } + + val result = for { + paramOpt <- inv.rawParam(0) + queryOpt = extractQuery(paramOpt) + if (queryOpt.isDefined) + query = queryOpt.get + computer = new FPComputeGraphQL()(inv.context.request, inv.state, ecology) + built = computer.handle(query) + jsv <- inv.fut(built.unsafeToFuture()) + out = Json.prettyPrint(jsv) + } + yield DisplayTextResult(out) + + result.get.map(_.headOption.getOrElse(ErrorResult(s"You need to specify the GraphQL as a parameter"))) + } + + override lazy val props = Seq( + GraphQLCommand + ) +} diff --git a/querki/scalajvm/app/querki/ql/AST.scala b/querki/scalajvm/app/querki/ql/AST.scala index 98e630846..4b1a03e5c 100644 --- a/querki/scalajvm/app/querki/ql/AST.scala +++ b/querki/scalajvm/app/querki/ql/AST.scala @@ -67,7 +67,7 @@ private[ql] sealed abstract class QLTextPart { override def toString = reconstructString } -private[ql] case class UnQLText(text:String) extends QLTextPart { +case class UnQLText(text:String) extends QLTextPart { def reconstructString:String = text } private[ql] sealed abstract class QLStage(collFlag:Option[String]) { @@ -80,7 +80,7 @@ private[ql] sealed abstract class QLStage(collFlag:Option[String]) { override def toString = reconstructString } -private[ql] case class QLTextStage(contents:ParsedQLText, collFlag:Option[String]) extends QLStage(collFlag) { +case class QLTextStage(contents:ParsedQLText, collFlag:Option[String]) extends QLStage(collFlag) { def reconstructString = collFlag.getOrElse("") + "\"\"" + contents.reconstructString + "\"\"" override def clearUseCollection = collFlag.isEmpty diff --git a/querki/scalajvm/app/querki/system/SystemCreator.scala b/querki/scalajvm/app/querki/system/SystemCreator.scala index e3a51544e..9c8f609a1 100644 --- a/querki/scalajvm/app/querki/system/SystemCreator.scala +++ b/querki/scalajvm/app/querki/system/SystemCreator.scala @@ -116,6 +116,7 @@ object SystemCreator { new querki.datamodel.ChoiceEcot(ecology) // 73 new querki.notifications.UserlandNotifierEcot(ecology) // 74 // 75 + new querki.graphql.GraphQLEcot(ecology) // 76 } def createAllEcots(ecology:Ecology, actorSystem:Option[ActorSystem], asyncInitTarget:ActorRef):Ecology = { From e78a0c3cdca230fc420c3fcdb9f75174a7df9937 Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Sun, 25 Aug 2019 14:57:02 -0400 Subject: [PATCH 19/22] Added the `Run GraphQL()` Console Command This should make it easier to quickly craft a query, to then use in an API. --- querki/scalajvm/test/querki/test/mid/PropFuncs.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/querki/scalajvm/test/querki/test/mid/PropFuncs.scala b/querki/scalajvm/test/querki/test/mid/PropFuncs.scala index 347e9674a..2a5aa5c4b 100644 --- a/querki/scalajvm/test/querki/test/mid/PropFuncs.scala +++ b/querki/scalajvm/test/querki/test/mid/PropFuncs.scala @@ -33,7 +33,7 @@ trait PropFuncs { for { collProp <- fetchStd(_.core.collectionProp) tpeProp <- fetchStd(_.core.typeProp) - nameProp <- fetchStd(_.basic.displayNameProp) + nameProp <- fetchStd(_.core.nameProp) propModel <- fetchStd(_.core.urProp) propId <- createThing(propModel, nameProp :=> name, From 6b2eba4fbba185d4028412fb9921f06db80ab4fa Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Sun, 25 Aug 2019 15:16:57 -0400 Subject: [PATCH 20/22] Mid-level unit test for the Run GraphQL command Adds the ability to test Console Commands at the mid-level. Note that the previous commit fixed a fairly long-standing bug in the mid-tests, which were creating Properties slightly incorrectly. --- .../test/querki/console/ConsoleMidFuncs.scala | 28 ++++++++++ .../querki/graphql/ComputeGraphQLTests.scala | 35 ------------ .../test/querki/graphql/GraphQLMidTests.scala | 56 +++++++++++++++++++ .../test/querki/graphql/package.scala | 44 +++++++++++++++ 4 files changed, 128 insertions(+), 35 deletions(-) create mode 100644 querki/scalajvm/test/querki/console/ConsoleMidFuncs.scala create mode 100644 querki/scalajvm/test/querki/graphql/GraphQLMidTests.scala create mode 100644 querki/scalajvm/test/querki/graphql/package.scala diff --git a/querki/scalajvm/test/querki/console/ConsoleMidFuncs.scala b/querki/scalajvm/test/querki/console/ConsoleMidFuncs.scala new file mode 100644 index 000000000..7f582d416 --- /dev/null +++ b/querki/scalajvm/test/querki/console/ConsoleMidFuncs.scala @@ -0,0 +1,28 @@ +package querki.console + +import autowire._ + +import querki.globals._ +import querki.test.mid._ + +import AllFuncs._ + +import ConsoleFunctions._ + +trait ConsoleMidFuncs { + def consoleCommand(cmd: String): TestOp[CommandResult] = + TestOp.client { _[ConsoleFunctions].consoleCommand(cmd).call() } + + ////////// + + def consoleCommandWithTextResult(cmd: String): TestOp[String] = { + consoleCommand(cmd).map { result => + result match { + case DisplayTextResult(text) => text + case _ => throw new Exception(s"Got unexpected result $result from command $cmd") + } + } + } +} + +object ConsoleMidFuncs extends ConsoleMidFuncs diff --git a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala index 3ffa04d4f..09ad83142 100644 --- a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala +++ b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala @@ -28,41 +28,6 @@ class ComputeGraphQLTests extends QuerkiTests { runQueryAndCheck(query)(jsv => println(Json.prettyPrint(jsv))) } - implicit class RichJsValue(jsv: JsValue) { - def field(path: String)(implicit p: Position): JsValue = - (jsv \ path).getOrElse(fail(s"Couldn't find path $path in object $jsv")) - - def obj(path: String)(implicit p: Position): JsObject = { - field(path) match { - case o: JsObject => o - case other => fail(s"Field $path wasn't an Object: $other") - } - } - - def string(path: String)(implicit p: Position): String = { - field(path) match { - case JsString(s) => s - case other => fail(s"Field $path wasn't a String: $other") - } - } - - def array(path: String)(implicit p: Position): Seq[JsValue] = { - field(path) match { - case JsArray(a) => a - case other => fail(s"Field $path wasn't an Array: $other") - } - } - - def name: String = string("_name") - def hasName(n: String) = name == n - } - - implicit class RichJsSequence(seq: Seq[JsValue]) { - def findByName(name: String)(implicit p: Position): JsValue = { - seq.find(_.hasName(name)).getOrElse(fail(s"Couldn't find an element named $name in $seq")) - } - } - "FPComputeGraphQL" should { "process a basic query" in { implicit val cdSpace = new CDSpace diff --git a/querki/scalajvm/test/querki/graphql/GraphQLMidTests.scala b/querki/scalajvm/test/querki/graphql/GraphQLMidTests.scala new file mode 100644 index 000000000..f738c21f5 --- /dev/null +++ b/querki/scalajvm/test/querki/graphql/GraphQLMidTests.scala @@ -0,0 +1,56 @@ +package querki.graphql + +import org.scalatest.Matchers._ +import querki.test.mid._ +import AllFuncs._ +import org.scalatest.tags.Slow +import play.api.libs.json._ +import querki.console.ConsoleMidFuncs._ + +object GraphQLMidTests { + // This is just testing the Run GraphQL command in the Console; actually testing GraphQL features happens in + // ComputeGraphQL tests. So we set up an extremely simple Space for testing: + lazy val basicGraphQLTest: TestOp[Unit] = { + for { + _ <- step("GraphQL smoketest suite") + std <- getStd + basicUser = TestUser("GraphQL Test User") + basicSpaceName = "GraphQL Test Space" + + loginResults <- newUser(basicUser) + mainSpace <- createSpace(basicSpaceName) + propId <- makeProperty("First Property", exactlyOne, textType) + modelId <- makeModel("First Model", propId :=> "") + instanceId <- makeThing(modelId, "First Instance", propId :=> "Instance value") + + graphQL = + """ + |query SimpleGraphQLTest { + | _instances(_name: "First-Model") { + | _oid + | _name + | First_Property + | } + |} + """.stripMargin + cmd = s"""Run GraphQL(""$graphQL"")""" + result <- consoleCommandWithTextResult(cmd) + json = Json.parse(result) + instance = ((json \\ "data").head \ "_instances").head + _ = (instance \ "_name").as[String] shouldBe "First-Instance" + _ = (instance \ "First_Property").as[String] shouldBe "Instance value" + } + yield () + } +} + +@Slow +class GraphQLMidTests extends MidTestBase { + import GraphQLMidTests._ + + "The Run GraphQL command" should { + "work with some simple GraphQL" in { + runTest(basicGraphQLTest) + } + } +} diff --git a/querki/scalajvm/test/querki/graphql/package.scala b/querki/scalajvm/test/querki/graphql/package.scala new file mode 100644 index 000000000..9c4584bcc --- /dev/null +++ b/querki/scalajvm/test/querki/graphql/package.scala @@ -0,0 +1,44 @@ +package querki + +import org.scalactic.source.Position +import org.scalatest.Matchers._ +import play.api.libs.json.{JsArray, JsValue, JsString, JsObject} + +package object graphql { + + implicit class RichJsValue(jsv: JsValue) { + def field(path: String)(implicit p: Position): JsValue = + (jsv \ path).getOrElse(fail(s"Couldn't find path $path in object $jsv")) + + def obj(path: String)(implicit p: Position): JsObject = { + field(path) match { + case o: JsObject => o + case other => fail(s"Field $path wasn't an Object: $other") + } + } + + def string(path: String)(implicit p: Position): String = { + field(path) match { + case JsString(s) => s + case other => fail(s"Field $path wasn't a String: $other") + } + } + + def array(path: String)(implicit p: Position): Seq[JsValue] = { + field(path) match { + case JsArray(a) => a + case other => fail(s"Field $path wasn't an Array: $other") + } + } + + def name: String = string("_name") + def hasName(n: String) = name == n + } + + implicit class RichJsSequence(seq: Seq[JsValue]) { + def findByName(name: String)(implicit p: Position): JsValue = { + seq.find(_.hasName(name)).getOrElse(fail(s"Couldn't find an element named $name in $seq")) + } + } + +} From d5ce361b2c2c812ef01d7ba3f41d04c7b8af2d1e Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Mon, 2 Sep 2019 17:12:13 -0400 Subject: [PATCH 21/22] Added plumbing for GraphQL We now have a controller, and unit tests, for the real entry point. I think we're ready to roll... Made the controller stack (which is old and a bit crufty) more appropriately typeful, so that application-level controllers can be explicit about the parser and (more immediately relevant) can know in a properly-typed way what the body type is. Note that we specifically ask for the content-type to be "application/graphql" -- we're not enforcing that in a hard-assed way, but you can't just use, eg, application/text. GraphQL is now also exposed at the API level, although nothing is using that yet. --- .../querki/graphql/GraphQLFunctions.scala | 17 ++++++++++++ .../app/controllers/ApplicationBase.scala | 14 +++++----- .../app/controllers/GraphQLController.scala | 26 +++++++++++++++++++ .../app/querki/graphql/GraphQLEcot.scala | 7 +++++ .../querki/graphql/GraphQLFunctionsImpl.scala | 23 ++++++++++++++++ querki/scalajvm/conf/routes | 4 +++ .../querki/graphql/ComputeGraphQLTests.scala | 2 +- .../test/querki/graphql/GraphQLMidTests.scala | 26 +++++++++++++++++++ .../test/querki/test/mid/TestState.scala | 4 +++ .../test/querki/test/mid/WSFuncs.scala | 11 ++++++++ 10 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 querki/scala/shared/src/main/scala/querki/graphql/GraphQLFunctions.scala create mode 100644 querki/scalajvm/app/controllers/GraphQLController.scala create mode 100644 querki/scalajvm/app/querki/graphql/GraphQLFunctionsImpl.scala create mode 100644 querki/scalajvm/test/querki/test/mid/WSFuncs.scala diff --git a/querki/scala/shared/src/main/scala/querki/graphql/GraphQLFunctions.scala b/querki/scala/shared/src/main/scala/querki/graphql/GraphQLFunctions.scala new file mode 100644 index 000000000..38f0b1e2e --- /dev/null +++ b/querki/scala/shared/src/main/scala/querki/graphql/GraphQLFunctions.scala @@ -0,0 +1,17 @@ +package querki.graphql + +import scala.concurrent.Future + +trait GraphQLFunctions { + /** + * Run the given GraphQL expression, and return the result. + * + * Note that the result is a JSON String, which will contain errors if there are any. This should never + * return a failed Future. + * + * @param graphQL a GraphQL expression that is legal for Querki + * @param pretty iff true, the resulting String will be pretty-printed + * @return the resulting JSON structure, rendered as a String + */ + def runGraphQL(graphQL: String, pretty: Boolean = false): Future[String] +} diff --git a/querki/scalajvm/app/controllers/ApplicationBase.scala b/querki/scalajvm/app/controllers/ApplicationBase.scala index 6d44cdc24..ef62e1d71 100644 --- a/querki/scalajvm/app/controllers/ApplicationBase.scala +++ b/querki/scalajvm/app/controllers/ApplicationBase.scala @@ -118,7 +118,7 @@ trait ApplicationBase extends Controller with EcologyMember { // This reflects the fact that there are many more or less public pages. It is the responsibility // of the caller to use this flag sensibly. Note that RequestContext.requester is guaranteed to // be set iff requireLogin is true. - def withUser[B](requireLogin:Boolean, parser:BodyParser[B] = BodyParsers.parse.anyContent)(f: PlayRequestContext => Future[Result]) = withAuth(parser) { user => implicit request => + def withUser[B](requireLogin:Boolean, parser:BodyParser[B] = BodyParsers.parse.anyContent)(f: PlayRequestContextFull[B] => Future[Result]) = withAuth(parser) { user => implicit request => if (requireLogin && user == User.Anonymous) { Future.successful(onUnauthorized(request)) } else { @@ -168,10 +168,10 @@ trait ApplicationBase extends Controller with EcologyMember { * Note that this is the usual replacement for withSpace -- it fetches just enough info to * send messages off to the UserSession level, and nothing more. */ - def withRouting - (ownerIdStr:String, spaceId:String) - (f: (PlayRequestContext => Future[Result])):EssentialAction = - withUser(false) { originalRC => + def withRouting[B] + (ownerIdStr:String, spaceId:String, parser:BodyParser[B] = BodyParsers.parse.anyContent) + (f: (PlayRequestContextFull[B] => Future[Result])):EssentialAction = + withUser(false, parser) { originalRC => try { // Give the listeners a chance to chime in. Note that this is where things like invitation // management come into play. @@ -212,8 +212,8 @@ trait ApplicationBase extends Controller with EcologyMember { def write[Result: Writer](r: Result) = upickle.default.write(r) } - def withLocalClient(ownerId:String, spaceIdStr:String)(cb:(PlayRequestContext, LocalClient) => Future[Result]) = - withRouting(ownerId, spaceIdStr) + def withLocalClient[B](ownerId:String, spaceIdStr:String, parser:BodyParser[B] = BodyParsers.parse.anyContent)(cb:(PlayRequestContextFull[B], LocalClient) => Future[Result]) = + withRouting(ownerId, spaceIdStr, parser) { implicit rawRc => // Unlike the API calls, we have to assume we have a name-style ThingId here: SpaceOps.getSpaceId(rawRc.ownerId, spaceIdStr).flatMap { spaceId => diff --git a/querki/scalajvm/app/controllers/GraphQLController.scala b/querki/scalajvm/app/controllers/GraphQLController.scala new file mode 100644 index 000000000..530d27cd7 --- /dev/null +++ b/querki/scalajvm/app/controllers/GraphQLController.scala @@ -0,0 +1,26 @@ +package controllers + +import java.nio.charset.StandardCharsets + +import autowire._ +import javax.inject.{Inject, Provider} +import querki.globals._ +import querki.graphql.GraphQLFunctions + +import scala.concurrent.Future + +class GraphQLController @Inject() (val appProv:Provider[play.api.Application]) extends ApplicationBase { + def graphQL(ownerId: String, spaceIdStr: String) = withLocalClient(ownerId, spaceIdStr) { (rc, client) => + val resultOpt = for { + rawBuffer <- rc.request.body.asRaw + bytes <- rawBuffer.asBytes() + query = bytes.decodeString(StandardCharsets.UTF_8) + } + yield client[GraphQLFunctions].runGraphQL(query).call() + + resultOpt.map { resultFut => + resultFut.map(Ok(_)) + }.getOrElse( + Future.successful(BadRequest("The content-type should be 'application/graphql', and there must be UTF-8 encoded GraphQL in the body"))) + } +} diff --git a/querki/scalajvm/app/querki/graphql/GraphQLEcot.scala b/querki/scalajvm/app/querki/graphql/GraphQLEcot.scala index aa44bd164..55d0bfd83 100644 --- a/querki/scalajvm/app/querki/graphql/GraphQLEcot.scala +++ b/querki/scalajvm/app/querki/graphql/GraphQLEcot.scala @@ -18,6 +18,13 @@ class GraphQLEcot(e:Ecology) extends QuerkiEcot(e) with querki.core.MethodDefs { val Basic = initRequires[querki.basic.Basic] val Console = initRequires[querki.console.Console] + lazy val ApiRegistry = interface[querki.api.ApiRegistry] + lazy val SpaceOps = interface[querki.spaces.SpaceOps] + + override def postInit: Unit = { + ApiRegistry.registerApiImplFor[GraphQLFunctions, GraphQLFunctionsImpl](SpaceOps.spaceRegion, requiresLogin = false) + } + lazy val GraphQLCommand = Console.defineSpaceCommand( GraphQLCommandOID, "Run GraphQL", diff --git a/querki/scalajvm/app/querki/graphql/GraphQLFunctionsImpl.scala b/querki/scalajvm/app/querki/graphql/GraphQLFunctionsImpl.scala new file mode 100644 index 000000000..0da2b0048 --- /dev/null +++ b/querki/scalajvm/app/querki/graphql/GraphQLFunctionsImpl.scala @@ -0,0 +1,23 @@ +package querki.graphql + +import play.api.libs.json.Json +import querki.globals._ +import querki.api.{SpaceApiImpl, AutowireParams} + +import scala.concurrent.Future + +class GraphQLFunctionsImpl(info: AutowireParams)(implicit e: Ecology) extends SpaceApiImpl(info, e) with GraphQLFunctions { + + def doRoute(req: Request): Future[String] = route[GraphQLFunctions](this)(req) + + def runGraphQL(query: String, pretty: Boolean = false): Future[String] = { + val computer = new FPComputeGraphQL()(rc, state, ecology) + val built = computer.handle(query) + built.unsafeToFuture().map { json => + if (pretty) + Json.prettyPrint(json) + else + Json.stringify(json) + } + } +} diff --git a/querki/scalajvm/conf/routes b/querki/scalajvm/conf/routes index 3116b970e..4fe9ef9c0 100644 --- a/querki/scalajvm/conf/routes +++ b/querki/scalajvm/conf/routes @@ -57,7 +57,11 @@ POST /resetPassword @controllers.LoginController.doResetPassword # Redirect pointers to Things to go to the Client: GET /u/:userName/:spaceId/*thingId @controllers.ClientController.thingRedirect(userName, spaceId, thingId) +# GraphQL API queries: +POST /graphql/:userName/:spaceId @controllers.GraphQLController.graphQL(userName, spaceId) + # JSON API inquiries: +# TODO: delete this entire code path, now that we have GraphQL: GET /json/:userName/:spaceId/:thingId @controllers.JsonController.json(userName, spaceId, thingId, propIdStr = "") GET /json/:userName/:spaceId/:thingId/:propId @controllers.JsonController.json(userName, spaceId, thingId, propId) diff --git a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala index 09ad83142..c0062516c 100644 --- a/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala +++ b/querki/scalajvm/test/querki/graphql/ComputeGraphQLTests.scala @@ -243,4 +243,4 @@ class ComputeGraphQLTests extends QuerkiTests { } -// TODO: unit tests for errors, support from Console, and real plumbing \ No newline at end of file +// TODO: unit tests for errors, and real plumbing \ No newline at end of file diff --git a/querki/scalajvm/test/querki/graphql/GraphQLMidTests.scala b/querki/scalajvm/test/querki/graphql/GraphQLMidTests.scala index f738c21f5..fe6f0d2ac 100644 --- a/querki/scalajvm/test/querki/graphql/GraphQLMidTests.scala +++ b/querki/scalajvm/test/querki/graphql/GraphQLMidTests.scala @@ -1,10 +1,15 @@ package querki.graphql +import java.nio.charset.StandardCharsets + import org.scalatest.Matchers._ import querki.test.mid._ import AllFuncs._ +import controllers.GraphQLController import org.scalatest.tags.Slow import play.api.libs.json._ +import play.api.test._ +import play.api.test.Helpers._ import querki.console.ConsoleMidFuncs._ object GraphQLMidTests { @@ -17,12 +22,14 @@ object GraphQLMidTests { basicUser = TestUser("GraphQL Test User") basicSpaceName = "GraphQL Test Space" + // Set up the Space: loginResults <- newUser(basicUser) mainSpace <- createSpace(basicSpaceName) propId <- makeProperty("First Property", exactlyOne, textType) modelId <- makeModel("First Model", propId :=> "") instanceId <- makeThing(modelId, "First Instance", propId :=> "Instance value") + // Test it directly: graphQL = """ |query SimpleGraphQLTest { @@ -39,6 +46,25 @@ object GraphQLMidTests { instance = ((json \\ "data").head \ "_instances").head _ = (instance \ "_name").as[String] shouldBe "First-Instance" _ = (instance \ "First_Property").as[String] shouldBe "Instance value" + + // Test the plumbing: + request = + FakeRequest("POST", s"/graphql/${basicUser.handle}/${mainSpace.underlying.toString}") + .withHeaders("Content-Type" -> "application/graphql") + .withBody(graphQL) + responseBody <- TestOp.fut { state => + import state.harness._ + for { + result <- call(controller[GraphQLController].graphQL(basicUser.handle, mainSpace.underlying.toString), request) + byteString <- result.body.consumeData + json = byteString.decodeString(StandardCharsets.UTF_8) + } + yield (state, json) + } + plumbingJson = Json.parse(responseBody) + plumbingInstance = ((plumbingJson \\ "data").head \ "_instances").head + _ = (plumbingInstance \ "_name").as[String] shouldBe "First-Instance" + _ = (plumbingInstance \ "First_Property").as[String] shouldBe "Instance value" } yield () } diff --git a/querki/scalajvm/test/querki/test/mid/TestState.scala b/querki/scalajvm/test/querki/test/mid/TestState.scala index 3c2cdef83..522e57fa7 100644 --- a/querki/scalajvm/test/querki/test/mid/TestState.scala +++ b/querki/scalajvm/test/querki/test/mid/TestState.scala @@ -58,6 +58,10 @@ case class HarnessInfo(test: MidTestBase) { lazy val app = test.app lazy val ecology = test.ecology lazy val injector = app.injector + + // These are implicit so you can simply `import state.harness._` to get at them: + implicit def executionContext = app.actorSystem.dispatcher + implicit def materializer = app.materializer def controller[T : ClassTag] = injector.instanceOf[T] } diff --git a/querki/scalajvm/test/querki/test/mid/WSFuncs.scala b/querki/scalajvm/test/querki/test/mid/WSFuncs.scala new file mode 100644 index 000000000..acc9b4fad --- /dev/null +++ b/querki/scalajvm/test/querki/test/mid/WSFuncs.scala @@ -0,0 +1,11 @@ +package querki.test.mid + +import play.api.libs.ws.{WSClient, WSRequest} + +class WSFuncs { + def wsUrl(url: String): TestOp[WSRequest] = TestOp.withState { testState => + testState.harness.controller[WSClient].url(url) + } +} + +object WSFuncs extends WSFuncs From 366943ebea68caaa784e3a2cba9598655b19beb8 Mon Sep 17 00:00:00 2001 From: "Mark \"Justin du Coeur\" Waks" Date: Tue, 3 Sep 2019 17:58:30 -0400 Subject: [PATCH 22/22] Release 2.9.0 Here we go... --- querki/build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/querki/build.sbt b/querki/build.sbt index f2395d2e6..6f081265c 100644 --- a/querki/build.sbt +++ b/querki/build.sbt @@ -7,7 +7,7 @@ lazy val clients = Seq(querkiClient) lazy val scalaV = "2.11.12" lazy val akkaV = "2.4.18" lazy val enumeratumV = "1.5.3" -lazy val appV = "2.8.6" +lazy val appV = "2.9.0" lazy val sharedSrcDir = "scala"