From cfc719fbe4853239d6cecfa39c622147a5e5bc12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Renoux?= Date: Sun, 24 Mar 2024 15:15:16 +0100 Subject: [PATCH] Support multiple databases (#52) --- README.md | 38 ++++++++- .../gaelrenoux/tranzactio/anorm/package.scala | 6 ++ .../gaelrenoux/tranzactio/DatabaseTBase.scala | 34 ++++++++ .../gaelrenoux/tranzactio/Wrapper.scala | 8 +- .../tranzactio/doobie/package.scala | 11 ++- .../src/test/scala/samples/SamplesSpec.scala | 24 +++++- .../anorm/LayeredAppMultipleDatabases.scala | 79 +++++++++++++++++++ .../doobie/LayeredAppMultipleDatabases.scala | 78 ++++++++++++++++++ 8 files changed, 268 insertions(+), 10 deletions(-) create mode 100644 core/src/main/scala/io/github/gaelrenoux/tranzactio/DatabaseTBase.scala create mode 100644 examples/src/test/scala/samples/anorm/LayeredAppMultipleDatabases.scala create mode 100644 examples/src/test/scala/samples/doobie/LayeredAppMultipleDatabases.scala diff --git a/README.md b/README.md index f039b0d..ce827cf 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ import io.github.gaelrenoux.tranzactio.doobie._ import javax.sql.DataSource import zio._ -val dbLayer: ZLayer[Has[DataSource], Nothing, Database] = Database.fromDatasource +val dbLayer: ZLayer[DataSource, Nothing, Database] = Database.fromDatasource ``` @@ -290,7 +290,7 @@ Database.transaction(???) // es is passed implicitly to the method ```scala val es: ErrorStrategies = ErrorStrategies.retryForeverFixed(10.seconds) -val dbLayerFromDatasource: ZLayer[Has[DataSource], Nothing, Database] = Database.fromDatasource(es) +val dbLayerFromDatasource: ZLayer[DataSource, Nothing, Database] = Database.fromDatasource(es) ``` #### Defining an ErrorStrategies instance @@ -329,6 +329,36 @@ val es: ErrorStrategies = +### Multiple Databases + +Some applications use multiple databases. +In that case, it is necessary to have them be different types in the ZIO environment. + +Tranzactio offers a typed database class, called `DatabaseT[_]`. +You only need to provide a different marker type for each database you use. + +```scala +import io.github.gaelrenoux.tranzactio.doobie._ +import javax.sql.DataSource +import zio._ + +trait Db1 +trait Db2 + +val db1Layer: ZLayer[Any, Nothing, DatabaseT[Db1]] = datasource1Layer >>> Database[Db1].fromDatasource +val db2Layer: ZLayer[Any, Nothing, DatabaseT[Db2]] = datasource2Layer >>> Database[Db2].fromDatasource + +val queries1: ZIO[Connection, DbException, List[String]] = ??? +val zio1: ZIO[DatabaseT[Db1], DbException, List[Person]] = DatabaseT[Db1].transactionOrWiden(queries1) +val zio2: ZIO[DatabaseT[Db2], DbException, List[Person]] = DatabaseT[Db2].transactionOrWiden(queries1) +``` + +When creating the layers for the datasources, don't forget to use `.fresh` when you have sub-layers of the same type on both sides! + +You can see a full example in the `examples` submodule (in `LayeredAppMultipleDatabases`). + + + ### Single-connection Database In some cases, you might want to have a `Database` module representing a single connection. @@ -382,7 +412,7 @@ import zio._ import zio.interop.catz._ implicit val doobieContext: DbContext = DbContext(logHandler = LogHandler.jdkLogHandler[Task]) -val dbLayer: ZLayer[Has[DataSource], Nothing, Database] = Database.fromDatasource +val dbLayer: ZLayer[DataSource, Nothing, Database] = Database.fromDatasource ``` @@ -403,7 +433,7 @@ See https://github.com/zio/interop-cats/issues/669 for more details about this i -### When will tranzactio work with ? +### When will tranzactio work with …? I want to add wrappers around more database access libraries. Anorm was the second one I did, next should probably be Quill (based on the popularity of the project on GitHub), diff --git a/anorm/src/main/scala/io/github/gaelrenoux/tranzactio/anorm/package.scala b/anorm/src/main/scala/io/github/gaelrenoux/tranzactio/anorm/package.scala index 0993010..9d3e516 100644 --- a/anorm/src/main/scala/io/github/gaelrenoux/tranzactio/anorm/package.scala +++ b/anorm/src/main/scala/io/github/gaelrenoux/tranzactio/anorm/package.scala @@ -41,4 +41,10 @@ package object anorm extends Wrapper { override final def connectionFromJdbc(connection: => JdbcConnection)(implicit trace: Trace): ZIO[Any, Nothing, Connection] = ZIO.succeed(connection) } + + override final type DatabaseT[M] = DatabaseTBase[M, Connection] + + object DatabaseT extends DatabaseTBase.Companion[Connection, DbContext] { + def apply[M: Tag]: Module[M] = new Module[M](Database) + } } diff --git a/core/src/main/scala/io/github/gaelrenoux/tranzactio/DatabaseTBase.scala b/core/src/main/scala/io/github/gaelrenoux/tranzactio/DatabaseTBase.scala new file mode 100644 index 0000000..6c73a78 --- /dev/null +++ b/core/src/main/scala/io/github/gaelrenoux/tranzactio/DatabaseTBase.scala @@ -0,0 +1,34 @@ +package io.github.gaelrenoux.tranzactio + +import zio.{Trace, ZEnvironment, ZIO, ZLayer, Tag} + +/** + * This is a typed database, to use when you have multiple databases in your application. Simply provide a marker type, + * and ZIO will be able to differentiate between multiple DatabaseT[_] types in your environment. + * @tparam M Marker type, no instances. */ +class DatabaseTBase[M: Tag, Connection](underlying: DatabaseOps.ServiceOps[Connection]) extends DatabaseOps.ServiceOps[Connection] { + + override def transaction[R, E, A](task: => ZIO[Connection with R, E, A], commitOnFailure: => Boolean) + (implicit errorStrategies: ErrorStrategiesRef, trace: Trace): ZIO[R with Any, Either[DbException, E], A] = + underlying.transaction[R, E, A](task, commitOnFailure = commitOnFailure) + + + override def autoCommit[R, E, A](task: => ZIO[Connection with R, E, A]) + (implicit errorStrategies: ErrorStrategiesRef, trace: Trace): ZIO[R with Any, Either[DbException, E], A] = + underlying.autoCommit[R, E, A](task) + +} + +object DatabaseTBase { + trait Companion[Connection, DbContext] { + type Module[M] = DatabaseTBase.Module[M, Connection, DbContext] + + def apply[M: Tag]: Module[M] + } + + class Module[M: Tag, Connection: Tag, DbContext](underlying: DatabaseModuleBase[Connection, DatabaseOps.ServiceOps[Connection], DbContext]) + extends DatabaseModuleBase[Connection, DatabaseTBase[M, Connection], DbContext] { + override def fromConnectionSource(implicit dbContext: DbContext, trace: Trace): ZLayer[ConnectionSource, Nothing, DatabaseTBase[M, Connection]] = + underlying.fromConnectionSource.map(env => ZEnvironment(new DatabaseTBase[M, Connection](env.get))) + } +} diff --git a/core/src/main/scala/io/github/gaelrenoux/tranzactio/Wrapper.scala b/core/src/main/scala/io/github/gaelrenoux/tranzactio/Wrapper.scala index b3965fa..4d9a0ba 100644 --- a/core/src/main/scala/io/github/gaelrenoux/tranzactio/Wrapper.scala +++ b/core/src/main/scala/io/github/gaelrenoux/tranzactio/Wrapper.scala @@ -1,6 +1,6 @@ package io.github.gaelrenoux.tranzactio -import zio.Trace +import zio.{Tag, Trace} /** A specific wrapper package for one specific library (e.g. Doobie). */ @@ -31,4 +31,10 @@ trait Wrapper { /** Wraps a library-specific query into a TranzactIO. */ def tzio[A](q: => Query[A])(implicit trace: Trace): TranzactIO[A] + /** Parameterized type for databases, allowing for multiple databases to be handled */ + type DatabaseT[M] + + val DatabaseT: DatabaseTBase.Companion[Connection, DbContext] // scalastyle:ignore field.name + + } diff --git a/doobie/src/main/scala/io/github/gaelrenoux/tranzactio/doobie/package.scala b/doobie/src/main/scala/io/github/gaelrenoux/tranzactio/doobie/package.scala index 67387d7..2727baf 100644 --- a/doobie/src/main/scala/io/github/gaelrenoux/tranzactio/doobie/package.scala +++ b/doobie/src/main/scala/io/github/gaelrenoux/tranzactio/doobie/package.scala @@ -1,11 +1,10 @@ package io.github.gaelrenoux.tranzactio -import _root_.doobie.free.KleisliInterpreter import _root_.doobie.LogHandler +import _root_.doobie.free.KleisliInterpreter import _root_.doobie.util.transactor.{Strategy, Transactor} import cats.effect.Resource -import io.github.gaelrenoux.tranzactio.test.DatabaseModuleTestOps -import io.github.gaelrenoux.tranzactio.test.NoopJdbcConnection +import io.github.gaelrenoux.tranzactio.test.{DatabaseModuleTestOps, NoopJdbcConnection} import zio.interop.catz._ import zio.stream.ZStream import zio.stream.interop.fs2z._ @@ -76,4 +75,10 @@ package object doobie extends Wrapper { implicit val Default: DbContext = DbContext(LogHandler.noop[Task]) } + override final type DatabaseT[M] = DatabaseTBase[M, Connection] + + object DatabaseT extends DatabaseTBase.Companion[Connection, DbContext] { + def apply[M: Tag]: Module[M] = new Module[M](Database) + } + } diff --git a/examples/src/test/scala/samples/SamplesSpec.scala b/examples/src/test/scala/samples/SamplesSpec.scala index 5c4480a..92bfc7c 100644 --- a/examples/src/test/scala/samples/SamplesSpec.scala +++ b/examples/src/test/scala/samples/SamplesSpec.scala @@ -13,12 +13,14 @@ object SamplesSpec extends ZIOSpecDefault { suite("SamplesSpec")( testApp("Doobie", doobie.LayeredApp), testApp("Doobie-Streaming", doobie.LayeredAppStreaming), - testApp("Anorm", anorm.LayeredApp) + testAppMultipleDatabases("Doobie", doobie.LayeredAppMultipleDatabases), + testApp("Anorm", anorm.LayeredApp), + testAppMultipleDatabases("Anorm", anorm.LayeredAppMultipleDatabases) ) private def testApp(name: String, app: ZIOAppDefault): MySpec = - test(s"$name LayeredApp prints its progress then the trio") { + test(s"$name App prints its progress then the trio") { for { _ <- app.run.provide(ignoredAppArgs ++ Scope.default) output <- TestConsole.output @@ -31,4 +33,22 @@ object SamplesSpec extends ZIOSpecDefault { )) } + private def testAppMultipleDatabases(name: String, app: ZIOAppDefault): MySpec = + + test(s"$name App prints its progress for the trio, then its progress for the mentor, then prints the full team") { + for { + _ <- app.run.provide(ignoredAppArgs ++ Scope.default) + output <- TestConsole.output + } yield assertTrue(output == Vector( + "Starting the app\n", + "Creating the table\n", + "Inserting the trio\n", + "Reading the trio\n", + "Creating the table\n", + "Inserting the mentor\n", + "Reading the mentor\n", + "Buffy Summers, Willow Rosenberg, Alexander Harris, Rupert Giles\n" + )) + } + } diff --git a/examples/src/test/scala/samples/anorm/LayeredAppMultipleDatabases.scala b/examples/src/test/scala/samples/anorm/LayeredAppMultipleDatabases.scala new file mode 100644 index 0000000..24c7c7a --- /dev/null +++ b/examples/src/test/scala/samples/anorm/LayeredAppMultipleDatabases.scala @@ -0,0 +1,79 @@ +package samples.anorm + +import io.github.gaelrenoux.tranzactio.{DbException, ErrorStrategies} +import io.github.gaelrenoux.tranzactio.anorm._ +import samples.{Conf, ConnectionPool, Person} +import zio._ + +import javax.sql.DataSource + +/** A sample app where all modules are linked through ZLayer. Should run as is (make sure you have com.h2database:h2 in + * your dependencies). */ +object LayeredAppMultipleDatabases extends zio.ZIOAppDefault { + + /** Marker trait for the first DB */ + trait Db1 + + /** Marker trait for the second DB */ + trait Db2 + + private val database1: ZLayer[Any, Throwable, DatabaseT[Db1]] = { + // Fresh calls are required so that the confs and datasource aren't conflated with the other layer's + val conf = Conf.live("samble-anorm-app-1").fresh + val dbRecoveryConf: ZLayer[Any, Nothing, ErrorStrategies] = conf >>> ZLayer.fromFunction((_: Conf).dbRecovery).fresh + val datasource: ZLayer[Any, Throwable, DataSource] = conf >>> ConnectionPool.live.fresh + (datasource ++ dbRecoveryConf) >>> DatabaseT[Db1].fromDatasourceAndErrorStrategies + } + + private val database2: ZLayer[Any, Throwable, DatabaseT[Db2]] = { + // Fresh calls are required so that the confs and datasource aren't conflated with the other layer's + val conf = Conf.live("samble-anorm-app-2").fresh + val dbRecoveryConf: ZLayer[Any, Nothing, ErrorStrategies] = conf >>> ZLayer.fromFunction((_: Conf).dbRecovery).fresh + val datasource: ZLayer[Any, Throwable, DataSource] = conf >>> ConnectionPool.live.fresh + (datasource ++ dbRecoveryConf) >>> DatabaseT[Db2].fromDatasourceAndErrorStrategies + } + + private val personQueries = PersonQueries.live + + type AppEnv = DatabaseT[Db1] with DatabaseT[Db2] with PersonQueries + private val appEnv = database1 ++ database2 ++ personQueries + + override def run: ZIO[ZIOAppArgs with Scope, Any, Any] = + for { + _ <- Console.printLine("Starting the app") + team <- myApp().provideLayer(appEnv) + _ <- Console.printLine(team.mkString(", ")) + } yield ExitCode(0) + + /** Main code for the application. Results in a big ZIO depending on the AppEnv. */ + def myApp(): ZIO[PersonQueries with DatabaseT[Db2] with DatabaseT[Db1], DbException, List[Person]] = { + val queries1: ZIO[PersonQueries with Connection, DbException, List[Person]] = for { + _ <- Console.printLine("Creating the table").orDie + _ <- PersonQueries.setup + _ <- Console.printLine("Inserting the trio").orDie + _ <- PersonQueries.insert(Person("Buffy", "Summers")) + _ <- PersonQueries.insert(Person("Willow", "Rosenberg")) + _ <- PersonQueries.insert(Person("Alexander", "Harris")) + _ <- Console.printLine("Reading the trio").orDie + trio <- PersonQueries.list + } yield trio + + val queries2: ZIO[PersonQueries with Connection, DbException, List[Person]] = for { + _ <- Console.printLine("Creating the table").orDie + _ <- PersonQueries.setup + _ <- Console.printLine("Inserting the mentor").orDie + _ <- PersonQueries.insert(Person("Rupert", "Giles")) + _ <- Console.printLine("Reading the mentor").orDie + mentor <- PersonQueries.list + } yield mentor + + val zTrio: ZIO[PersonQueries with DatabaseT[Db1], DbException, List[Person]] = DatabaseT[Db1].transactionOrWiden(queries1) + val zMentor: ZIO[PersonQueries with DatabaseT[Db2], DbException, List[Person]] = DatabaseT[Db2].transactionOrWiden(queries2) + + for { + trio <- zTrio + mentor <- zMentor + } yield trio ++ mentor + } + +} diff --git a/examples/src/test/scala/samples/doobie/LayeredAppMultipleDatabases.scala b/examples/src/test/scala/samples/doobie/LayeredAppMultipleDatabases.scala new file mode 100644 index 0000000..8ac708c --- /dev/null +++ b/examples/src/test/scala/samples/doobie/LayeredAppMultipleDatabases.scala @@ -0,0 +1,78 @@ +package samples.doobie + +import io.github.gaelrenoux.tranzactio.{DbException, ErrorStrategies} +import io.github.gaelrenoux.tranzactio.doobie._ +import samples.{Conf, ConnectionPool, Person} +import zio._ + +import javax.sql.DataSource + +/** A sample app with two databases for different set of queries. */ +object LayeredAppMultipleDatabases extends zio.ZIOAppDefault { + + /** Marker trait for the first DB */ + trait Db1 + + /** Marker trait for the second DB */ + trait Db2 + + private val database1: ZLayer[Any, Throwable, DatabaseT[Db1]] = { + // Fresh calls are required so that the confs and datasource aren't conflated with the other layer's + val conf = Conf.live("samble-doobie-app-1").fresh + val dbRecoveryConf: ZLayer[Any, Nothing, ErrorStrategies] = conf >>> ZLayer.fromFunction((_: Conf).dbRecovery).fresh + val datasource: ZLayer[Any, Throwable, DataSource] = conf >>> ConnectionPool.live.fresh + (datasource ++ dbRecoveryConf) >>> DatabaseT[Db1].fromDatasourceAndErrorStrategies + } + + private val database2: ZLayer[Any, Throwable, DatabaseT[Db2]] = { + // Fresh calls are required so that the confs and datasource aren't conflated with the other layer's + val conf = Conf.live("samble-doobie-app-2").fresh + val dbRecoveryConf: ZLayer[Any, Nothing, ErrorStrategies] = conf >>> ZLayer.fromFunction((_: Conf).dbRecovery).fresh + val datasource: ZLayer[Any, Throwable, DataSource] = conf >>> ConnectionPool.live.fresh + (datasource ++ dbRecoveryConf) >>> DatabaseT[Db2].fromDatasourceAndErrorStrategies + } + + private val personQueries = PersonQueries.live + + type AppEnv = DatabaseT[Db1] with DatabaseT[Db2] with PersonQueries + private val appEnv = database1 ++ database2 ++ personQueries + + override def run: ZIO[ZIOAppArgs with Scope, Any, Any] = + for { + _ <- Console.printLine("Starting the app") + team <- myApp().provideLayer(appEnv) + _ <- Console.printLine(team.mkString(", ")) + } yield ExitCode(0) + + /** Main code for the application. Results in a big ZIO depending on the AppEnv. */ + def myApp(): ZIO[PersonQueries with DatabaseT[Db2] with DatabaseT[Db1], DbException, List[Person]] = { + val queries1: ZIO[PersonQueries with Connection, DbException, List[Person]] = for { + _ <- Console.printLine("Creating the table").orDie + _ <- PersonQueries.setup + _ <- Console.printLine("Inserting the trio").orDie + _ <- PersonQueries.insert(Person("Buffy", "Summers")) + _ <- PersonQueries.insert(Person("Willow", "Rosenberg")) + _ <- PersonQueries.insert(Person("Alexander", "Harris")) + _ <- Console.printLine("Reading the trio").orDie + trio <- PersonQueries.list + } yield trio + + val queries2: ZIO[PersonQueries with Connection, DbException, List[Person]] = for { + _ <- Console.printLine("Creating the table").orDie + _ <- PersonQueries.setup + _ <- Console.printLine("Inserting the mentor").orDie + _ <- PersonQueries.insert(Person("Rupert", "Giles")) + _ <- Console.printLine("Reading the mentor").orDie + mentor <- PersonQueries.list + } yield mentor + + val zTrio: ZIO[PersonQueries with DatabaseT[Db1], DbException, List[Person]] = DatabaseT[Db1].transactionOrWiden(queries1) + val zMentor: ZIO[PersonQueries with DatabaseT[Db2], DbException, List[Person]] = DatabaseT[Db2].transactionOrWiden(queries2) + + for { + trio <- zTrio + mentor <- zMentor + } yield trio ++ mentor + } + +}