diff --git a/README.md b/README.md index 0438161..a5a7c9d 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Yet another database client for Scala. No dependencies, high productivity. * [Splicing Literal Values into Frags](#splicing-literal-values-into-frags) * [Postgres Module](#postgres-module) * [Logging](#logging-sql-queries) +* [Integrations](#integrations) + * [ZIO](#zio) * [Motivation](#motivation) * [Feature List And Database Support](#feature-list) * [Talks and Blogs](#talks-and-blogs) @@ -538,6 +540,23 @@ Setting to TRACE will log SQL queries and their parameters. You can log slow queries by using the `Transactor` class in conjunction with `SqlLogger.logSlowQueries(FiniteDuration)`. See [Customizing Transactions](#customizing-transactions) for an example. You can also implement your own SqlLogger subclass as desired. +## Integrations + +### ZIO + +Magnum provides a fine layer of integration with ZIO. +The `magnum-zio` module provides an implementation of the `connect` and `transact` utils that return a ZIO effect. + +To use the ZIO integration, add the following dependency: +```scala +"com.augustnagro" %% "magnum-zio" % "x.x.x" +``` + +and import these utils in your code with: +```scala +import com.augustnagro.magnum.magzio.* +``` + ## Motivation Historically, database clients on the JVM fall into three categories. diff --git a/build.sbt b/build.sbt index 937f1fb..fd786be 100644 --- a/build.sbt +++ b/build.sbt @@ -43,7 +43,7 @@ val circeVersion = "0.14.10" lazy val root = project .in(file(".")) - .aggregate(magnum, magnumPg) + .aggregate(magnum, magnumPg, magnumZio) lazy val magnum = project .in(file("magnum")) @@ -82,3 +82,14 @@ lazy val magnumPg = project "org.scala-lang.modules" %% "scala-xml" % "2.3.0" % Test ) ) + +lazy val magnumZio = project + .in(file("magnum-zio")) + .dependsOn(magnum % "compile->compile;test->test") + .settings( + Test / fork := true, + publish / skip := false, + libraryDependencies ++= Seq( + "dev.zio" %% "zio" % "2.1.12" % Provided + ) + ) diff --git a/magnum-zio/src/main/scala/com/augustnagro/magnum/magzio/zio.scala b/magnum-zio/src/main/scala/com/augustnagro/magnum/magzio/zio.scala new file mode 100644 index 0000000..3b1a701 --- /dev/null +++ b/magnum-zio/src/main/scala/com/augustnagro/magnum/magzio/zio.scala @@ -0,0 +1,128 @@ +package com.augustnagro.magnum.magzio + +import zio.{Trace, ZIO} +import com.augustnagro.magnum.* + +import java.sql.Connection +import javax.sql.DataSource +import scala.util.control.NonFatal + +/** Executes a given query on a given DataSource + * + * Re-implementation for ZIO of + * [[com.augustnagro.magnum.connect(dataSource: DataSource)]] + * + * Usage: + * {{{ + * import com.augustnagro.magnum.magzio.* + * + * connect(datasource) { cn ?=> repo.findById(id) } + * }}} + */ +def connect[A](dataSource: DataSource)(q: DbCon ?=> A)(implicit + trace: Trace +): ZIO[Any, Throwable, A] = + connect(Transactor(dataSource))(q) + +/** Executes a given query on a given Transactor + * + * Re-implementation for ZIO of + * [[com.augustnagro.magnum.connect(dataSource: DataSource)]] + * + * Usage: + * {{{ + * import com.augustnagro.magnum.magzio.* + * + * connect(transactor) { cn ?=> repo.findById(id) } + * }}} + */ +def connect[A]( + transactor: Transactor +)(q: DbCon ?=> A)(implicit trace: Trace): ZIO[Any, Throwable, A] = + ZIO.blocking { + ZIO.acquireReleaseWith( + acquire = ZIO.attempt(transactor.dataSource.getConnection()) + )(release = + conn => if (conn ne null) ZIO.attempt(conn.close()).orDie else ZIO.unit + ) { cn => + ZIO.attempt { + transactor.connectionConfig(cn) + q(using DbCon(cn, transactor.sqlLogger)) + } + } + } + +/** Executes a given transaction on a given DataSource + * + * Re-implementation for ZIO of + * [[com.augustnagro.magnum.transact(transactor: Transactor)]] + * + * Usage: + * {{{ + * import com.augustnagro.magnum.magzio.* + * + * transact(dataSource) { tx ?=> repo.insertReturning(creator) } + * }}} + */ +def transact[A](dataSource: DataSource)(q: DbTx ?=> A)(implicit + trace: Trace +): ZIO[Any, Throwable, A] = + transact(Transactor(dataSource))(q) + +/** Executes a given transaction on a given DataSource + * + * Re-implementation for ZIO of + * [[com.augustnagro.magnum.transact(transactor: Transactor, connectionConfig: Connection => Unit)]] + * + * Usage: + * {{{ + * import com.augustnagro.magnum.magzio.* + * + * transact(dataSource, ...) { tx ?=> repo.insertReturning(creator) } + * }}} + */ +def transact[A](dataSource: DataSource, connectionConfig: Connection => Unit)( + q: DbTx ?=> A +)(implicit trace: Trace): ZIO[Any, Throwable, A] = + val transactor = + Transactor(dataSource = dataSource, connectionConfig = connectionConfig) + transact(transactor)(q) + +/** Executes a given transaction on a given Transactor + * + * Re-implementation for ZIO of + * [[com.augustnagro.magnum.transact(transactor: Transactor)]] + * + * Usage: + * {{{ + * import com.augustnagro.magnum.magzio.* + * + * transact(transactor) { tx ?=> repo.insertReturning(creator) } + * }}} + */ +def transact[A]( + transactor: Transactor +)(q: DbTx ?=> A)(implicit trace: Trace): ZIO[Any, Throwable, A] = + ZIO.blocking { + ZIO.acquireReleaseWith( + acquire = ZIO.attempt(transactor.dataSource.getConnection()) + )(release = + conn => if (conn ne null) ZIO.attempt(conn.close()).orDie else ZIO.unit + ) { cn => + ZIO.uninterruptible { + ZIO.attempt { + transactor.connectionConfig(cn) + cn.setAutoCommit(false) + try { + val res = q(using DbTx(cn, transactor.sqlLogger)) + cn.commit() + res + } catch { + case NonFatal(t) => + cn.rollback() + throw t + } + } + } + } + } diff --git a/magnum-zio/src/test/scala/com/augustnagro/magnum/magzio/ImmutableRepoZioTests.scala b/magnum-zio/src/test/scala/com/augustnagro/magnum/magzio/ImmutableRepoZioTests.scala new file mode 100644 index 0000000..12aa7e9 --- /dev/null +++ b/magnum-zio/src/test/scala/com/augustnagro/magnum/magzio/ImmutableRepoZioTests.scala @@ -0,0 +1,196 @@ +package com.augustnagro.magnum.magzio + +import com.augustnagro.magnum.* +import munit.FunSuite +import shared.Color +import zio.* + +import java.sql.Connection +import java.time.OffsetDateTime +import scala.util.{Success, Using} + +def immutableRepoZioTests( + suite: FunSuite, + dbType: DbType, + xa: () => Transactor +)(using + munit.Location, + DbCodec[OffsetDateTime] +): Unit = + import suite.* + + val runtime: Runtime[Any] = zio.Runtime.default + + def runIO[A](io: ZIO[Any, Throwable, A]): A = + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run(io).getOrThrow() + } + + @Table(dbType, SqlNameMapper.CamelToSnakeCase) + case class Car( + model: String, + @Id id: Long, + topSpeed: Int, + @SqlName("vin") vinNumber: Option[Int], + color: Color, + created: OffsetDateTime + ) derives DbCodec + + val carRepo = ImmutableRepo[Car, Long] + val car = TableInfo[Car, Car, Long] + + val allCars = Vector( + Car( + model = "McLaren Senna", + id = 1L, + topSpeed = 208, + vinNumber = Some(123), + color = Color.Red, + created = OffsetDateTime.parse("2024-11-24T22:17:30.000000000Z") + ), + Car( + model = "Ferrari F8 Tributo", + id = 2L, + topSpeed = 212, + vinNumber = Some(124), + color = Color.Green, + created = OffsetDateTime.parse("2024-11-24T22:17:31.000000000Z") + ), + Car( + model = "Aston Martin Superleggera", + id = 3L, + topSpeed = 211, + vinNumber = None, + color = Color.Blue, + created = OffsetDateTime.parse("2024-11-24T22:17:32.000000000Z") + ) + ) + + test("count"): + val count = + runIO: + magzio.connect(xa()): + carRepo.count + assert(count == 3L) + + test("existsById"): + val (exists3, exists4) = + runIO: + magzio.connect(xa()): + carRepo.existsById(3L) -> carRepo.existsById(4L) + assert(exists3) + assert(!exists4) + + test("findAll"): + val cars = + runIO: + magzio.connect(xa()): + carRepo.findAll + assert(cars == allCars) + + test("findById"): + val (exists3, exists4) = + runIO: + magzio.connect(xa()): + carRepo.findById(3L) -> carRepo.findById(4L) + assert(exists3.get == allCars.last) + assert(exists4 == None) + + test("findAllByIds"): + assume(dbType != ClickhouseDbType) + assume(dbType != MySqlDbType) + assume(dbType != OracleDbType) + assume(dbType != SqliteDbType) + val ids = + runIO: + magzio.connect(xa()): + carRepo.findAllById(Vector(1L, 3L)).map(_.id) + assert(ids == Vector(1L, 3L)) + + test("serializable transaction"): + val count = + runIO: + magzio.transact(xa().copy(connectionConfig = withSerializable)): + carRepo.count + assert(count == 3L) + + def withSerializable(con: Connection): Unit = + con.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE) + + test("select query"): + val minSpeed: Int = 210 + val query = + sql"select ${car.all} from $car where ${car.topSpeed} > $minSpeed" + .query[Car] + val result = + runIO: + magzio.connect(xa()): + query.run() + assertNoDiff( + query.frag.sqlString, + "select model, id, top_speed, vin, color, created from car where top_speed > ?" + ) + assert(query.frag.params == Vector(minSpeed)) + assert(result == allCars.tail) + + test("select query with aliasing"): + val minSpeed = 210 + val cAlias = car.alias("c") + val query = + sql"select ${cAlias.all} from $cAlias where ${cAlias.topSpeed} > $minSpeed" + .query[Car] + val result = + runIO: + magzio.connect(xa()): + query.run() + assertNoDiff( + query.frag.sqlString, + "select c.model, c.id, c.top_speed, c.vin, c.color, c.created from car c where c.top_speed > ?" + ) + assert(query.frag.params == Vector(minSpeed)) + assert(result == allCars.tail) + + test("select via option"): + val vin = Some(124) + val cars = + runIO: + magzio.connect(xa()): + sql"select * from car where vin = $vin" + .query[Car] + .run() + assert(cars == allCars.filter(_.vinNumber == vin)) + + test("tuple select"): + val tuples = + runIO: + magzio.connect(xa()): + sql"select model, color from car where id = 2" + .query[(String, Color)] + .run() + assert(tuples == Vector(allCars(1).model -> allCars(1).color)) + + test("reads null int as None and not Some(0)"): + val maybeCar = + runIO: + magzio.connect(xa()): + carRepo.findById(3L) + assert(maybeCar.get.vinNumber == None) + + test("created timestamps should match"): + val allCars = + runIO: + magzio.connect(xa()): + carRepo.findAll + assert(allCars.map(_.created) == allCars.map(_.created)) + + test(".query iterator"): + val carsCount = + runIO: + magzio.connect(xa()): + Using.Manager(implicit use => + val it = sql"SELECT * FROM car".query[Car].iterator() + it.map(_.id).size + ) + assert(carsCount == Success(3)) + +end immutableRepoZioTests diff --git a/magnum-zio/src/test/scala/com/augustnagro/magnum/magzio/PgZioTests.scala b/magnum-zio/src/test/scala/com/augustnagro/magnum/magzio/PgZioTests.scala new file mode 100644 index 0000000..61406f3 --- /dev/null +++ b/magnum-zio/src/test/scala/com/augustnagro/magnum/magzio/PgZioTests.scala @@ -0,0 +1,48 @@ +package com.augustnagro.magnum.magzio + +import com.augustnagro.magnum.* +import com.dimafeng.testcontainers.PostgreSQLContainer +import com.dimafeng.testcontainers.munit.fixtures.TestContainersFixtures +import munit.{AnyFixture, FunSuite, Location} +import org.postgresql.ds.PGSimpleDataSource +import org.testcontainers.utility.DockerImageName + +import java.nio.file.{Files, Path} +import scala.util.Using +import scala.util.Using.Manager + +class PgZioTests extends FunSuite, TestContainersFixtures: + + immutableRepoZioTests(this, PostgresDbType, xa) + + val pgContainer = ForAllContainerFixture( + PostgreSQLContainer + .Def(dockerImageName = DockerImageName.parse("postgres:17.0")) + .createContainer() + ) + + override def munitFixtures: Seq[AnyFixture[_]] = + super.munitFixtures :+ pgContainer + + def xa(): Transactor = + val ds = PGSimpleDataSource() + val pg = pgContainer() + ds.setUrl(pg.jdbcUrl) + ds.setUser(pg.username) + ds.setPassword(pg.password) + val tableDDLs = Vector( + "/pg/car.sql", + "/pg/person.sql", + "/pg/my-user.sql", + "/pg/no-id.sql", + "/pg/big-dec.sql" + ).map(p => Files.readString(Path.of(getClass.getResource(p).toURI))) + + Manager(use => + val con = use(ds.getConnection) + val stmt = use(con.createStatement) + for ddl <- tableDDLs do stmt.execute(ddl) + ).get + Transactor(ds) + end xa +end PgZioTests