diff --git a/build.sbt b/build.sbt index 5615063..0c22a0e 100644 --- a/build.sbt +++ b/build.sbt @@ -41,7 +41,8 @@ val commonSettings = Seq( scalacOptions in (Test, compile) ~= (_.filterNot( Set( "-Ywarn-unused:imports", - "-Xfatal-warnings" + "-Xfatal-warnings", + "-Yrangepos" ))), resolvers ++= Seq[Resolver]( Resolver.sonatypeRepo("releases") @@ -60,7 +61,10 @@ lazy val core = (project in file("core")) .settings( name := "query-core", libraryDependencies ++= Seq( - Dependencies.cats + Dependencies.acolyte % Test, + Dependencies.anorm % Test, + Dependencies.cats, + Dependencies.specs2 % Test ) ) @@ -72,8 +76,7 @@ lazy val sampleAppExample = (project in file("examples/sample-app")) libraryDependencies ++= Seq( jdbc, Dependencies.anorm, - Dependencies.h2, - Dependencies.scalaTestPlusPlay + Dependencies.h2 ) ) .dependsOn(core) diff --git a/core/src/main/scala/core/database/ComposeWithCompletion.scala b/core/src/main/scala/core/database/ComposeWithCompletion.scala new file mode 100644 index 0000000..96aa80e --- /dev/null +++ b/core/src/main/scala/core/database/ComposeWithCompletion.scala @@ -0,0 +1,48 @@ +package com.zengularity.querymonad.core.database + +import scala.concurrent.{ExecutionContext, Future} +import scala.language.higherKinds + +/** + * Heavily inspired from work done by @cchantep in Acolyte (see acolyte.reactivemongo.ComposeWithCompletion) + */ +trait ComposeWithCompletion[F[_], Out] { + type Outer <: Future[_] + + def apply[In](resource: In, f: In => F[Out])(onComplete: In => Unit)( + implicit ec: ExecutionContext): Outer +} + +object ComposeWithCompletion extends LowPriorityCompose { + + type Aux[F[_], A, B] = ComposeWithCompletion[F, A] { type Outer = Future[B] } + + implicit def futureOut[A]: Aux[Future, A, A] = + new ComposeWithCompletion[Future, A] { + type Outer = Future[A] + + def apply[In](resource: In, f: In => Future[A])(onComplete: In => Unit)( + implicit ec: ExecutionContext): Outer = + f(resource).andThen { + case _ => onComplete(resource) + } + + override val toString = "futureOut" + } + +} + +trait LowPriorityCompose { _: ComposeWithCompletion.type => + + implicit def pureOut[F[_], A]: Aux[F, A, F[A]] = + new ComposeWithCompletion[F, A] { + type Outer = Future[F[A]] + + def apply[In](resource: In, f: In => F[A])(onComplete: In => Unit)( + implicit ec: ExecutionContext): Outer = + Future(f(resource)).andThen { case _ => onComplete(resource) } + + override val toString = "pureOut" + } + +} diff --git a/core/src/main/scala/core/database/QueryRunner.scala b/core/src/main/scala/core/database/QueryRunner.scala index 13c92f0..fc5328c 100644 --- a/core/src/main/scala/core/database/QueryRunner.scala +++ b/core/src/main/scala/core/database/QueryRunner.scala @@ -1,24 +1,43 @@ package com.zengularity.querymonad.core.database -import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.ExecutionContext +import scala.language.higherKinds /** * A class who can run a Query. */ sealed trait QueryRunner[Resource] { - def apply[T](query: Query[Resource, T]): Future[T] + def apply[M[_], T]( + query: QueryT[M, Resource, T] + )( + implicit compose: ComposeWithCompletion[M, T] + ): compose.Outer } object QueryRunner { - private class DefaultRunner[Resource](wr: WithResource[Resource])( - implicit ec: ExecutionContext) - extends QueryRunner[Resource] { - def apply[T](query: Query[Resource, T]): Future[T] = - Future(wr(query.run)) + private class DefaultRunner[Resource]( + wr: WithResource[Resource] + )( + implicit ec: ExecutionContext + ) extends QueryRunner[Resource] { + + def apply[M[_], T]( + query: QueryT[M, Resource, T] + )( + implicit compose: ComposeWithCompletion[M, T] + ): compose.Outer = { + wr { resource => + compose(resource, query.run)(wr.releaseIfNecessary) + } + } + } // Default factory - def apply[Resource](wr: WithResource[Resource])( - implicit ec: ExecutionContext): QueryRunner[Resource] = + def apply[Resource]( + wr: WithResource[Resource] + )( + implicit ec: ExecutionContext + ): QueryRunner[Resource] = new DefaultRunner(wr) } diff --git a/core/src/main/scala/core/database/WithResource.scala b/core/src/main/scala/core/database/WithResource.scala index 4a0a348..1de7905 100644 --- a/core/src/main/scala/core/database/WithResource.scala +++ b/core/src/main/scala/core/database/WithResource.scala @@ -2,4 +2,6 @@ package com.zengularity.querymonad.core.database trait WithResource[Resource] { def apply[A](f: Resource => A): A + + def releaseIfNecessary(resource: Resource): Unit } diff --git a/core/src/main/scala/core/database/package.scala b/core/src/main/scala/core/database/package.scala index 976ce3a..132a65e 100644 --- a/core/src/main/scala/core/database/package.scala +++ b/core/src/main/scala/core/database/package.scala @@ -2,7 +2,9 @@ package com.zengularity.querymonad.core import scala.language.higherKinds -import cats.{Applicative, Id} +import cats.Applicative + +import cats.Id import cats.data.{Reader, ReaderT} package object database { @@ -19,16 +21,28 @@ package object database { type QueryT[F[_], Resource, A] = ReaderT[F, Resource, A] object QueryT { + def apply[M[_], Resource, A]( + run: Resource => M[A] + ): QueryT[M, Resource, A] = + new QueryT(run) + def pure[M[_]: Applicative, Resource, A](a: A) = ReaderT.pure[M, Resource, A](a) - def ask[M[_]: Applicative, Resource] = ReaderT.ask[M, Resource] + def ask[M[_]: Applicative, Resource] = + ReaderT.ask[M, Resource] + + def liftF[M[_], Resource, A](ma: M[A]) = + ReaderT.liftF[M, Resource, A](ma) - def liftF[M[_], Resource, A](ma: M[A]) = ReaderT.liftF[M, Resource, A](ma) + def fromQuery[M[_], Resource, A]( + query: Query[Resource, M[A]] + ): QueryT[M, Resource, A] = + QueryT[M, Resource, A](query.run) } type QueryO[Resource, A] = QueryT[Option, Resource, A] - type QueryE[Resource, A, Err] = + type QueryE[Resource, Err, A] = QueryT[({ type F[T] = Either[Err, T] })#F, Resource, A] } diff --git a/core/src/main/scala/core/module/sql/package.scala b/core/src/main/scala/core/module/sql/package.scala index cc04b4d..bf3c8f8 100644 --- a/core/src/main/scala/core/module/sql/package.scala +++ b/core/src/main/scala/core/module/sql/package.scala @@ -3,14 +3,22 @@ package com.zengularity.querymonad.core.module import java.sql.Connection import scala.concurrent.ExecutionContext +import scala.language.higherKinds + +import cats.Applicative import com.zengularity.querymonad.core.database.{ Query, QueryRunner, + QueryT, + QueryO, + QueryE, WithResource } package object sql { + + // Query aliases type SqlQuery[A] = Query[Connection, A] object SqlQuery { @@ -21,6 +29,29 @@ package object sql { def apply[A](f: Connection => A) = new SqlQuery(f) } + // Query transformer aliases + type SqlQueryT[F[_], A] = QueryT[F, Connection, A] + + object SqlQueryT { + def apply[M[_], A](run: Connection => M[A]) = + QueryT.apply[M, Connection, A](run) + + def pure[M[_]: Applicative, A](a: A) = + QueryT.pure[M, Connection, A](a) + + def ask[M[_]: Applicative] = QueryT.ask[M, Connection] + + def liftF[M[_], A](ma: M[A]) = QueryT.liftF[M, Connection, A](ma) + + def fromQuery[M[_], A](query: SqlQuery[M[A]]) = + QueryT.fromQuery[M, Connection, A](query) + } + + type SqlQueryO[A] = QueryO[Connection, A] + + type SqlQueryE[A, Err] = QueryE[Connection, A, Err] + + // Query runner aliases type WithSqlConnection = WithResource[Connection] type SqlQueryRunner = QueryRunner[Connection] @@ -30,4 +61,5 @@ package object sql { implicit ec: ExecutionContext): SqlQueryRunner = QueryRunner[Connection](wc) } + } diff --git a/core/src/test/scala/module/sql/SqlQueryRunnerSpec.scala b/core/src/test/scala/module/sql/SqlQueryRunnerSpec.scala new file mode 100644 index 0000000..33ceefb --- /dev/null +++ b/core/src/test/scala/module/sql/SqlQueryRunnerSpec.scala @@ -0,0 +1,148 @@ +package com.zengularity.querymonad.test.core.module.sql + +import acolyte.jdbc.{ + AcolyteDSL, + ExecutedParameter, + QueryExecution, + QueryResult => AcolyteQueryResult +} +import org.specs2.concurrent.ExecutionEnv +import org.specs2.mutable.Specification + +import com.zengularity.querymonad.core.module.sql.{ + SqlQuery, + SqlQueryT, + SqlQueryRunner, + WithSqlConnection +} +import com.zengularity.querymonad.test.core.module.sql.models.{ + Material, + Professor +} +import com.zengularity.querymonad.test.core.module.sql.utils.SqlConnectionFactory + +class SqlQueryRunnerSpec(implicit ee: ExecutionEnv) extends Specification { + + "SqlQueryRunner" should { + // execute lift Queries + "return integer value lift in Query using pure" in { + val withSqlConnection: WithSqlConnection = + SqlConnectionFactory.withSqlConnection(AcolyteQueryResult.Nil) + val runner = SqlQueryRunner(withSqlConnection) + val query = SqlQuery.pure(1) + + runner(query) aka "material" must beTypedEqualTo(1).await + } + + "return optional value lift in Query using liftF" in { + val withSqlConnection: WithSqlConnection = + SqlConnectionFactory.withSqlConnection(AcolyteQueryResult.Nil) + val runner = SqlQueryRunner(withSqlConnection) + val query = SqlQueryT.liftF(Seq(1)) + + runner(query) aka "material" must beTypedEqualTo(Seq(1)).await + } + + // execute single query + "retrieve professor with id 1" in { + val withSqlConnection: WithSqlConnection = + SqlConnectionFactory.withSqlConnection(Professor.resultSet) + val runner = SqlQueryRunner(withSqlConnection) + val result = runner(Professor.fetchProfessor(1)).map(_.get) + + result aka "professor" must beTypedEqualTo( + Professor(1, "John Doe", 35, 1)).await + } + + "retrieve material with id 1" in { + val withSqlConnection: WithSqlConnection = + SqlConnectionFactory.withSqlConnection(Material.resultSet) + val runner = SqlQueryRunner(withSqlConnection) + val result = runner(Material.fetchMaterial(1)).map(_.get) + + result aka "material" must beTypedEqualTo( + Material(1, "Computer Science", 20, "Beginner")).await + } + + "not retrieve professor with id 2" in { + val withSqlConnection: WithSqlConnection = + SqlConnectionFactory.withSqlConnection(AcolyteQueryResult.Nil) + val runner = SqlQueryRunner(withSqlConnection) + val query = for { + _ <- SqlQuery.ask + professor <- Professor.fetchProfessor(2) + } yield professor + + runner(query) aka "material" must beNone.await + } + + // execute composed queries into a single transaction + "retrieve professor with id 1 and his material" in { + val handler = AcolyteDSL.handleQuery { + case QueryExecution("SELECT * FROM professors where id = ?", + ExecutedParameter(1) :: Nil) => + Professor.resultSet + case QueryExecution("SELECT * FROM materials where id = ?", + ExecutedParameter(1) :: Nil) => + Material.resultSet + case _ => + AcolyteQueryResult.Nil + } + val withSqlConnection: WithSqlConnection = + SqlConnectionFactory.withSqlConnection(handler) + val runner = SqlQueryRunner(withSqlConnection) + val query = for { + professor <- Professor.fetchProfessor(1).map(_.get) + material <- Material.fetchMaterial(professor.material).map(_.get) + } yield (professor, material) + + runner(query) aka "professor and material" must beTypedEqualTo( + Tuple2(Professor(1, "John Doe", 35, 1), + Material(1, "Computer Science", 20, "Beginner"))).await + } + + "not retrieve professor with id 1 and no material" in { + import cats.instances.option._ + val handler = AcolyteDSL.handleQuery { + case QueryExecution("SELECT * FROM professors where id = {id}", _) => + Professor.resultSet + case _ => + AcolyteQueryResult.Nil + } + val withSqlConnection: WithSqlConnection = + SqlConnectionFactory.withSqlConnection(handler) + val runner = SqlQueryRunner(withSqlConnection) + val query = for { + professor <- SqlQueryT.fromQuery(Professor.fetchProfessor(1)) + material <- SqlQueryT.fromQuery( + Material.fetchMaterial(professor.material)) + } yield (professor, material) + + runner(query) aka "professor and material" must beNone.await + } + + // execute async queries + "retrieve int value fetch in an async context" in { + import scala.concurrent.Future + import anorm.{SQL, SqlParser} + import acolyte.jdbc.RowLists + import acolyte.jdbc.Implicits._ + val queryResult: AcolyteQueryResult = + (RowLists.rowList1(classOf[Int] -> "res").append(5)) + val withSqlConnection: WithSqlConnection = + SqlConnectionFactory.withSqlConnection(queryResult) + val runner = SqlQueryRunner(withSqlConnection) + val query = + SqlQueryT { implicit connection => + Future { + Thread.sleep(900) // to simulate a slow-down + SQL("SELECT 5 as res") + .as(SqlParser.int("res").single) + } + } + + runner(query) aka "result" must beTypedEqualTo(5).await + } + } + +} diff --git a/core/src/test/scala/module/sql/models/Material.scala b/core/src/test/scala/module/sql/models/Material.scala new file mode 100644 index 0000000..9fa29fa --- /dev/null +++ b/core/src/test/scala/module/sql/models/Material.scala @@ -0,0 +1,30 @@ +package com.zengularity.querymonad.test.core.module.sql.models + +import anorm._ +import acolyte.jdbc.Implicits._ +import acolyte.jdbc.{QueryResult => AcolyteQueryResult} +import acolyte.jdbc.RowLists.rowList4 + +import com.zengularity.querymonad.core.module.sql.SqlQuery + +case class Material(id: Int, name: String, numberOfHours: Int, level: String) + +object Material { + val schema = rowList4( + classOf[Int] -> "id", + classOf[String] -> "name", + classOf[Int] -> "numberOfHours", + classOf[String] -> "level" + ) + + val parser = Macro.namedParser[Material] + + val resultSet: AcolyteQueryResult = + Material.schema :+ (1, "Computer Science", 20, "Beginner") + + def fetchMaterial(id: Int): SqlQuery[Option[Material]] = + SqlQuery { implicit connection => + SQL"SELECT * FROM materials where id = $id" + .as(Material.parser.singleOpt) + } +} diff --git a/core/src/test/scala/module/sql/models/Professor.scala b/core/src/test/scala/module/sql/models/Professor.scala new file mode 100644 index 0000000..427004f --- /dev/null +++ b/core/src/test/scala/module/sql/models/Professor.scala @@ -0,0 +1,30 @@ +package com.zengularity.querymonad.test.core.module.sql.models + +import anorm._ +import acolyte.jdbc.Implicits._ +import acolyte.jdbc.{QueryResult => AcolyteQueryResult} +import acolyte.jdbc.RowLists.rowList4 + +import com.zengularity.querymonad.core.module.sql.SqlQuery + +case class Professor(id: Int, name: String, age: Int, material: Int) + +object Professor { + val schema = rowList4( + classOf[Int] -> "id", + classOf[String] -> "name", + classOf[Int] -> "age", + classOf[Int] -> "material" + ) + + val parser = Macro.namedParser[Professor] + + val resultSet: AcolyteQueryResult = + Professor.schema :+ (1, "John Doe", 35, 1) + + def fetchProfessor(id: Int): SqlQuery[Option[Professor]] = + SqlQuery { implicit connection => + SQL"SELECT * FROM professors where id = $id" + .as(Professor.parser.singleOpt) + } +} diff --git a/core/src/test/scala/module/sql/utils/SqlConnectionFactory.scala b/core/src/test/scala/module/sql/utils/SqlConnectionFactory.scala new file mode 100644 index 0000000..8e0f086 --- /dev/null +++ b/core/src/test/scala/module/sql/utils/SqlConnectionFactory.scala @@ -0,0 +1,34 @@ +package com.zengularity.querymonad.test.core.module.sql.utils + +import java.sql.Connection + +import acolyte.jdbc.{ + AcolyteDSL, + QueryResult => AcolyteQueryResult, + ScalaCompositeHandler +} + +import com.zengularity.querymonad.core.module.sql.WithSqlConnection + +object SqlConnectionFactory { + + def withSqlConnection[A <: AcolyteQueryResult]( + resultsSet: A): WithSqlConnection = + new WithSqlConnection { + def apply[B](f: Connection => B): B = + AcolyteDSL.withQueryResult(resultsSet)(f) + + def releaseIfNecessary(connection: Connection): Unit = connection.close() + } + + def withSqlConnection(handler: ScalaCompositeHandler): WithSqlConnection = + new WithSqlConnection { + def apply[B](f: Connection => B): B = { + val con = AcolyteDSL.connection(handler) + f(con) + } + + def releaseIfNecessary(connection: Connection): Unit = connection.close() + } + +} diff --git a/examples/sample-app/app/database/WithPlayTransaction.scala b/examples/sample-app/app/database/WithPlayTransaction.scala index f835f54..4832574 100644 --- a/examples/sample-app/app/database/WithPlayTransaction.scala +++ b/examples/sample-app/app/database/WithPlayTransaction.scala @@ -7,6 +7,11 @@ import play.api.db.Database import com.zengularity.querymonad.core.module.sql.WithSqlConnection class WithPlayTransaction(db: Database) extends WithSqlConnection { - def apply[A](f: Connection => A): A = - db.withTransaction(f) + def apply[A](f: Connection => A): A = { + val connection = db.getConnection(true) + val result = f(connection) + result + } + + def releaseIfNecessary(connection: Connection): Unit = connection.close() } diff --git a/examples/sample-app/app/wiring/AppLoader.scala b/examples/sample-app/app/wiring/AppLoader.scala index b04ac4f..82d620c 100644 --- a/examples/sample-app/app/wiring/AppLoader.scala +++ b/examples/sample-app/app/wiring/AppLoader.scala @@ -1,5 +1,7 @@ package com.zengularity.querymonad.examples.wiring +import scala.concurrent.Future + import anorm._ import play.api.ApplicationLoader.Context import play.api._ @@ -8,7 +10,11 @@ import play.api.mvc.Results._ import play.api.routing.Router import play.api.routing.sird._ -import com.zengularity.querymonad.core.module.sql.{SqlQuery, SqlQueryRunner} +import com.zengularity.querymonad.core.module.sql.{ + SqlQuery, + SqlQueryT, + SqlQueryRunner +} import com.zengularity.querymonad.examples.database.WithPlayTransaction class AppComponents(context: Context) @@ -34,8 +40,14 @@ class AppComponents(context: Context) case GET(p"/sqrt/${double(num)}") => Action.async { - val query = SqlQuery(implicit c => - SQL"select sqrt($num) as result".as(SqlParser.int("result").single)) + val query = + SqlQueryT { implicit c => + Future { + Thread.sleep(2000) // Simumlates a a very slow query + SQL"select sqrt($num) as result".as( + SqlParser.double("result").single) + } + } queryRunner(query).map(r => Ok(r.toString)) } diff --git a/project/Dependencies.scala b/project/Dependencies.scala index ba56d05..5c38fd1 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -1,11 +1,13 @@ import sbt._ object Dependencies { + lazy val acolyte = "org.eu.acolyte" %% "jdbc-scala" % "1.0.47" + lazy val anorm = "org.playframework.anorm" %% "anorm" % "2.6.0" lazy val cats = "org.typelevel" %% "cats-core" % "1.0.1" lazy val h2 = "com.h2database" % "h2" % "1.4.196" - lazy val scalaTestPlusPlay = "org.scalatestplus.play" %% "scalatestplus-play" % "3.1.1" % Test + lazy val specs2 = "org.specs2" %% "specs2-core" % "4.0.2" }