Skip to content

Commit

Permalink
Merge pull request #466 from gemini-hlsw/topic/directives
Browse files Browse the repository at this point in the history
Directives support, elaborator rework and query algebra simplification
  • Loading branch information
milessabin authored Oct 11, 2023
2 parents eb8ae3f + c5aefa6 commit 4aa9177
Show file tree
Hide file tree
Showing 115 changed files with 5,530 additions and 2,716 deletions.
32 changes: 16 additions & 16 deletions demo/src/main/scala/demo/starwars/StarWarsMapping.scala
Original file line number Diff line number Diff line change
Expand Up @@ -73,22 +73,22 @@ trait StarWarsMapping[F[_]] extends GenericMapping[F] { self: StarWarsData[F] =>
)

// #elaborator
override val selectElaborator = new SelectElaborator(Map(
QueryType -> {
// The hero selector takes an Episode argument and yields a single value. We use the
// Unique operator to pick out the target using the FieldEquals predicate.
case Select("hero", List(Binding("episode", TypedEnumValue(e))), child) =>
Episode.values.find(_.toString == e.name).map { episode =>
Select("hero", Nil, Unique(Filter(Eql(CharacterType / "id", Const(hero(episode).id)), child))).success
}.getOrElse(Result.failure(s"Unknown episode '${e.name}'"))

// The character, human and droid selectors all take a single ID argument and yield a
// single value (if any) or null. We use the Unique operator to pick out the target
// using the FieldEquals predicate.
case Select(f@("character" | "human" | "droid"), List(Binding("id", IDValue(id))), child) =>
Select(f, Nil, Unique(Filter(Eql(CharacterType / "id", Const(id)), child))).success
}
))
override val selectElaborator = SelectElaborator {
// The hero selector takes an Episode argument and yields a single value. We transform
// the nested child to use the Filter and Unique operators to pick out the target using
// the Eql predicate.
case (QueryType, "hero", List(Binding("episode", EnumValue(e)))) =>
for {
episode <- Elab.liftR(Episode.values.find(_.toString == e).toResult(s"Unknown episode '$e'"))
_ <- Elab.transformChild(child => Unique(Filter(Eql(CharacterType / "id", Const(hero(episode).id)), child)))
} yield ()

// The character, human and droid selectors all take a single ID argument and yield a
// single value (if any) or null. We transform the nested child to use the Unique and
// Filter operators to pick out the target using the Eql predicate.
case (QueryType, "character" | "human" | "droid", List(Binding("id", IDValue(id)))) =>
Elab.transformChild(child => Unique(Filter(Eql(CharacterType / "id", Const(id)), child)))
}
// #elaborator
}

Expand Down
131 changes: 67 additions & 64 deletions demo/src/main/scala/demo/world/WorldMapping.scala
Original file line number Diff line number Diff line change
Expand Up @@ -171,70 +171,73 @@ trait WorldMapping[F[_]] extends DoobieMapping[F] {
)

// #elaborator
override val selectElaborator = new SelectElaborator(Map(

QueryType -> {

case Select("country", List(Binding("code", StringValue(code))), child) =>
Select("country", Nil, Unique(Filter(Eql(CountryType / "code", Const(code)), child))).success

case Select("city", List(Binding("id", IntValue(id))), child) =>
Select("city", Nil, Unique(Filter(Eql(CityType / "id", Const(id)), child))).success

case Select("countries", List(Binding("limit", IntValue(num)), Binding("offset", IntValue(off)), Binding("minPopulation", IntValue(min)), Binding("byPopulation", BooleanValue(byPop))), child) =>
def limit(query: Query): Query =
if (num < 1) query
else Limit(num, query)

def offset(query: Query): Query =
if (off < 1) query
else Offset(off, query)

def order(query: Query): Query = {
if (byPop)
OrderBy(OrderSelections(List(OrderSelection[Int](CountryType / "population"))), query)
else if (num > 0 || off > 0)
OrderBy(OrderSelections(List(OrderSelection[String](CountryType / "code"))), query)
else query
}

def filter(query: Query): Query =
if (min == 0) query
else Filter(GtEql(CountryType / "population", Const(min)), query)

Select("countries", Nil, limit(offset(order(filter(child))))).success

case Select("cities", List(Binding("namePattern", StringValue(namePattern))), child) =>
if (namePattern == "%")
Select("cities", Nil, child).success
else
Select("cities", Nil, Filter(Like(CityType / "name", namePattern, true), child)).success

case Select("language", List(Binding("language", StringValue(language))), child) =>
Select("language", Nil, Unique(Filter(Eql(LanguageType / "language", Const(language)), child))).success

case Select("search", List(Binding("minPopulation", IntValue(min)), Binding("indepSince", IntValue(year))), child) =>
Select("search", Nil,
Filter(
And(
Not(Lt(CountryType / "population", Const(min))),
Not(Lt(CountryType / "indepyear", Const(Option(year))))
),
child
)
).success

case Select("search2", List(Binding("indep", BooleanValue(indep)), Binding("limit", IntValue(num))), child) =>
Select("search2", Nil, Limit(num, Filter(IsNull[Int](CountryType / "indepyear", isNull = !indep), child))).success
},
CountryType -> {
case Select("numCities", List(Binding("namePattern", AbsentValue)), Empty) =>
Count("numCities", Select("cities", Nil, Select("name", Nil, Empty))).success

case Select("numCities", List(Binding("namePattern", StringValue(namePattern))), Empty) =>
Count("numCities", Select("cities", Nil, Filter(Like(CityType / "name", namePattern, true), Select("name", Nil, Empty)))).success
}
))
override val selectElaborator = SelectElaborator {
case (QueryType, "country", List(Binding("code", StringValue(code)))) =>
Elab.transformChild(child => Unique(Filter(Eql(CountryType / "code", Const(code)), child)))

case (QueryType, "city", List(Binding("id", IntValue(id)))) =>
Elab.transformChild(child => Unique(Filter(Eql(CityType / "id", Const(id)), child)))

case (
QueryType, "countries",
List(
Binding("limit", IntValue(num)),
Binding("offset", IntValue(off)),
Binding("minPopulation", IntValue(min)),
Binding("byPopulation", BooleanValue(byPop))
)
) =>
def limit(query: Query): Query =
if (num < 1) query
else Limit(num, query)

def offset(query: Query): Query =
if (off < 1) query
else Offset(off, query)

def order(query: Query): Query = {
if (byPop)
OrderBy(OrderSelections(List(OrderSelection[Int](CountryType / "population"))), query)
else if (num > 0 || off > 0)
OrderBy(OrderSelections(List(OrderSelection[String](CountryType / "code"))), query)
else query
}

def filter(query: Query): Query =
if (min == 0) query
else Filter(GtEql(CountryType / "population", Const(min)), query)

Elab.transformChild(child => limit(offset(order(filter(child)))))

case (QueryType, "cities", List(Binding("namePattern", StringValue(namePattern)))) =>
if (namePattern == "%")
Elab.unit
else
Elab.transformChild(child => Filter(Like(CityType / "name", namePattern, true), child))

case (QueryType, "language", List(Binding("language", StringValue(language)))) =>
Elab.transformChild(child => Unique(Filter(Eql(LanguageType / "language", Const(language)), child)))

case (QueryType, "search", List(Binding("minPopulation", IntValue(min)), Binding("indepSince", IntValue(year)))) =>
Elab.transformChild(child =>
Filter(
And(
Not(Lt(CountryType / "population", Const(min))),
Not(Lt(CountryType / "indepyear", Const(Option(year))))
),
child
)
)

case (QueryType, "search2", List(Binding("indep", BooleanValue(indep)), Binding("limit", IntValue(num)))) =>
Elab.transformChild(child => Limit(num, Filter(IsNull[Int](CountryType / "indepyear", isNull = !indep), child)))

case (CountryType, "numCities", List(Binding("namePattern", AbsentValue))) =>
Elab.transformChild(_ => Count(Select("cities", Select("name"))))

case (CountryType, "numCities", List(Binding("namePattern", StringValue(namePattern)))) =>
Elab.transformChild(_ => Count(Select("cities", Filter(Like(CityType / "name", namePattern, true), Select("name")))))
}
// #elaborator
}

Expand Down
39 changes: 20 additions & 19 deletions docs/src/main/paradox/tutorial/db-backed-model.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
# DB Backed Model

In this tutorial we are going to implement GraphQL API of countries and cities of the world using Grackle backed by
a database model, i.e. provide mapping for Grackle to read data from PostgreSQL and return it as result of
GraphQL queries.
In this tutorial we are going to implement a GraphQL API for countries and cities of the world using Grackle backed by
a database, ie. provide a mapping for Grackle to read data from PostgreSQL and return it as result of GraphQL queries.

## Running the demo

Expand Down Expand Up @@ -75,43 +74,45 @@ Grackle represents schemas as a Scala value of type `Schema` which can be constr

## Database mapping

The API is backed by mapping to database tables. Grackle contains ready to use integration
with [doobie](https://tpolecat.github.io/doobie/) for accessing SQL database via JDBC
and with [Skunk](https://tpolecat.github.io/skunk/) for accessing PostgreSQL via its native API. In this example
we will use doobie.
The API is backed by mapping to database tables. Grackle contains ready to use integration with
[doobie](https://tpolecat.github.io/doobie/) for accessing SQL database via JDBC and with
[Skunk](https://tpolecat.github.io/skunk/) for accessing PostgreSQL via its native API. In this example we will use
doobie.

Let's start with defining what tables and columns are available in the database model,

@@snip [WorldMapping.scala](/demo/src/main/scala/demo/world/WorldMapping.scala) { #db_tables }

For each column we need to provide its name and doobie codec. We should also mark if value is nullable.
For each column we need to provide its name and doobie codec of type `Meta`. We should also mark if the value is
nullable.

We define each query as SQL query, as below,
We define the top-level GraphQL fields as `SqlObject` mappings,

@@snip [WorldMapping.scala](/demo/src/main/scala/demo/world/WorldMapping.scala) { #root }

Now, we need to map each type from GraphQL schema using available columns from database,
Now, we need to map each type from the GraphQL schema using columns from the database,

@@snip [WorldMapping.scala](/demo/src/main/scala/demo/world/WorldMapping.scala) { #type_mappings }

Each GraphQL must contain key. It can contain fields from one table, but it can also contain nested types which
are translated to SQL joins using provided conditions. `Join(country.code, city.countrycode)` means joining country
and city tables where `code` in the country table is the same as `countrycode` in the city table.
Each GraphQL type mapping must contain a key. It can contain fields from one table, but it can also contain nested
types which are translated to SQL joins using the provided conditions. `Join(country.code, city.countrycode)` means
joining country and city tables where `code` in the country table is the same as `countrycode` in the city table.

## The query compiler and elaborator

Similar as in [in-memory model]((in-memory-model.html#the-query-compiler-and-elaborator)), we need to define elaborator
to transform query algebra terms into the form that can be then used as source of SQL queries,
Similarly to the [in-memory model]((in-memory-model.html#the-query-compiler-and-elaborator)), we need to define an
elaborator to transform query algebra terms into a form that can be then used to translate query algebra terms to SQL
queries,

@@snip [WorldMapping.scala](/demo/src/main/scala/demo/world/WorldMapping.scala) { #elaborator }

## Putting it all together

To expose GraphQL API with http4s we will use `GraphQLService` and `DemoServer`
from [in-memory example](in-memory-model.html#the-service).
To expose GraphQL API with http4s we will use the `GraphQLService` and `DemoServer`
from the [in-memory example](in-memory-model.html#the-service).

We will use [testcontainers](https://github.com/testcontainers/testcontainers-scala) to run PostgreSQL database
in the background. The final main method, which starts PostgreSQL database in docker container, creates database
We use [testcontainers](https://github.com/testcontainers/testcontainers-scala) to run a PostgreSQL database
in the background. The final main method, which starts a dockerized PostgreSQL database, creates the database
schema, writes initial data and exposes GraphQL API for in-memory and db-backend models is below,

@@snip [main.scala](/demo/src/main/scala/demo/Main.scala) { #main }
75 changes: 40 additions & 35 deletions docs/src/main/paradox/tutorial/in-memory-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,28 +205,24 @@ simplifies or expands the term to bring it into a form which can be executed dir
Grackle's query algebra consists of the following elements,

```scala
sealed trait Query {
case class Select(name: String, args: List[Binding], child: Query = Empty) extends Query
case class Group(queries: List[Query]) extends Query
case class Unique(child: Query) extends Query
case class Filter(pred: Predicate, child: Query) extends Query
case class Component[F[_]](mapping: Mapping[F], join: (Query, Cursor) => Result[Query], child: Query) extends Query
case class Effect[F[_]](handler: EffectHandler[F], child: Query) extends Query
case class Introspect(schema: Schema, child: Query) extends Query
case class Environment(env: Env, child: Query) extends Query
case class Wrap(name: String, child: Query) extends Query
case class Rename(name: String, child: Query) extends Query
case class UntypedNarrow(tpnme: String, child: Query) extends Query
case class Narrow(subtpe: TypeRef, child: Query) extends Query
case class Skip(sense: Boolean, cond: Value, child: Query) extends Query
case class Limit(num: Int, child: Query) extends Query
case class Offset(num: Int, child: Query) extends Query
case class OrderBy(selections: OrderSelections, child: Query) extends Query
case class Count(name: String, child: Query) extends Query
case class TransformCursor(f: Cursor => Result[Cursor], child: Query) extends Query
case object Skipped extends Query
case object Empty extends Query
}
case class UntypedSelect(name: String, alias: Option[String], args: List[Binding], dirs: List[Directive], child: Query)
case class Select(name: String, alias: Option[String], child: Query)
case class Group(queries: List[Query])
case class Unique(child: Query)
case class Filter(pred: Predicate, child: Query)
case class Component[F[_]](mapping: Mapping[F], join: (Query, Cursor) => Result[Query], child: Query)
case class Effect[F[_]](handler: EffectHandler[F], child: Query)
case class Introspect(schema: Schema, child: Query)
case class Environment(env: Env, child: Query)
case class UntypedFragmentSpread(name: String, directives: List[Directive])
case class UntypedInlineFragment(tpnme: Option[String], directives: List[Directive], child: Query)
case class Narrow(subtpe: TypeRef, child: Query)
case class Limit(num: Int, child: Query)
case class Offset(num: Int, child: Query)
case class OrderBy(selections: OrderSelections, child: Query)
case class Count(child: Query)
case class TransformCursor(f: Cursor => Result[Cursor], child: Query)
case object Empty
```

A simple query like this,
Expand All @@ -242,8 +238,8 @@ query {
is first translated into a term in the query algebra of the form,

```scala
Select("character", List(IntBinding("id", 1000)),
Select("name", Nil)
UntypedSelect("character", None, List(IntBinding("id", 1000)), Nil,
UntypedSelect("name", None, Nil, Nil, Empty)
)
```

Expand All @@ -260,23 +256,23 @@ the model) is specific to this model, so we have to provide that semantic via so
Extracting out the case for the `character` selector,

```scala
case Select(f@("character"), List(Binding("id", IDValue(id))), child) =>
Select(f, Nil, Unique(Filter(Eql(CharacterType / "id", Const(id)), child))).success

case (QueryType, "character", List(Binding("id", IDValue(id)))) =>
Elab.transformChild(child => Unique(Filter(Eql(CharacterType / "id", Const(id)), child)))
```

we can see that this transforms the previous term as follows,
the previous term is transformed as follows as follows,

```scala
Select("character", Nil,
Unique(Eql(CharacterType / "id"), Const("1000")), Select("name", Nil))
Select("character", None,
Unique(Eql(CharacterType / "id"), Const("1000")), Select("name", None, Empty))
)
```

Here the argument to the `character` selector has been translated into a predicate which refines the root data of the
model to the single element which satisfies it via `Unique`. The remainder of the query (`Select("name", Nil)`) is
then within the scope of that constraint. We have eliminated something with model-specific semantics (`character(id:
1000)`) in favour of something universal which can be interpreted directly against the model.
Here the original `UntypedSelect` terms have been converted to typed `Select` terms with the argument to the
`character` selector translated into a predicate which refines the root data of the model to the single element which
satisfies it via `Unique`. The remainder of the query (`Select("name", None, Nil)`) is then within the scope of that
constraint. We have eliminated something with model-specific semantics (`character(id: 1000)`) in favour of something
universal which can be interpreted directly against the model.

## The query interpreter and cursor

Expand All @@ -293,7 +289,7 @@ For the Star Wars model the root definitions are of the following form,

@@snip [StarWarsData.scala](/demo/src/main/scala/demo/starwars/StarWarsMapping.scala) { #root }

The first argument of the `GenericRoot` constructor correspond to the top-level selection of the query (see the
The first argument of the `GenericField` constructor corresponds to the top-level selection of the query (see the
schema above) and the second argument is the initial model value for which a `Cursor` will be derived. When the query
is executed, navigation will start with that `Cursor` and the corresponding GraphQL type.

Expand Down Expand Up @@ -337,6 +333,15 @@ object Main extends IOApp {
DemoServer.stream[IO](starWarsGraphQLRoutes).compile.drain
}
}
object Main extends IOApp {
def run(args: List[String]): IO[ExitCode] = {
val starWarsGraphQLRoutes = GraphQLService.routes[IO](
"starwars",
GraphQLService.fromMapping(new StarWarsMapping[IO] with StarWarsData[IO])
)
DemoServer.stream[IO](starWarsGraphQLRoutes).compile.drain
}
}
```

@@snip [main.scala](/demo/src/main/scala/demo/DemoServer.scala) { #server }
Loading

0 comments on commit 4aa9177

Please sign in to comment.