Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Transactor work, add zio-test, and bump zio #95

Closed
wants to merge 12 commits into from

Conversation

russwyte
Copy link

@russwyte russwyte commented Jan 18, 2025

May at least partially address #84

Adds some Transactor refactoring for discussion.

@russwyte
Copy link
Author

russwyte commented Jan 18, 2025

I think this module's tests should only test the functionality of the Transactor... not all of the core functionality. 🤷‍♂️ All we really need to know is that the Transactor produced expected results on success or failure etc. I feel like the scope of the tests is far too broad for this tiny sub module.

@russwyte
Copy link
Author

What is Transactor was a trait with a wrapper type? That would make it simple to create transactors for says Kyo/Cats effect etc...

@russwyte
Copy link
Author

russwyte commented Jan 18, 2025

What I mean is - we could take good advantage of higher kinded types:

trait TransactorOps[T[_]]:
  def connect[A](f: DbCon ?=> A): T[A]
  def transact[A](f: DbTx ?=> A): T[A]

type Plain[A] = A
object Plain:
  given makePlain[A]: Conversion[A, Plain[A]] = a => a: Plain[A]

final case class Transactor(
    private val dataSource: DataSource,
    private val sqlLogger: SqlLogger = SqlLogger.Default,
    private val connectionConfig: Connection => Unit = con => ()
) extends TransactorOps[Plain]:

  def withSqlLogger(sqlLogger: SqlLogger) = copy(sqlLogger = sqlLogger)

  def withConnectionConfig(connectionConfig: Connection => Unit) =
    copy(connectionConfig = connectionConfig)

  def connect[T](f: DbCon ?=> T): T =
    Using.resource(dataSource.getConnection): con =>
      connectionConfig(con)
      f(using DbCon(con, sqlLogger))

  def transact[T](f: DbTx ?=> T): T =
    Using.resource(dataSource.getConnection): con =>
      connectionConfig(con)
      con.setAutoCommit(false)
      try
        val res = f(using DbTx(con, sqlLogger))
        con.commit()
        res
      catch
        case t =>
          con.rollback()
          throw t
end Transactor

@russwyte
Copy link
Author

I added an experimental refactoring to demo a way to enforce operational consistency at a type level between Transactor implementations using the TransactorOps trait. Mostly for discussion 🗨️

@russwyte russwyte changed the title Use zio-test and bump zio Use Transactor tweaks zio-test and bump zio Jan 18, 2025
build.sbt Outdated Show resolved Hide resolved
"dev.zio" %% "zio-test" % "2.1.14" % Test,
"dev.zio" %% "zio-test-sbt" % "2.1.14" % Test,
"dev.zio" %% "zio-test-magnolia" % "2.1.14" % Test,
"org.testcontainers" % "postgresql" % "1.20.4" % Test,
Copy link
Contributor

@guizmaii guizmaii Jan 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Why not keeping testcontainers-scala-postgresql? 🤔

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally could - this is just a pattern I am used to and my approach gives more direct control over the container scope (ZIO scope).

connectionConfig: Connection => Unit,
semaphore: Option[Semaphore]
):
final case class Transactor(
Copy link
Contributor

@guizmaii guizmaii Jan 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 IMO case class represents data structures. This isn't a data structure so we don't need case here

Suggested change
final case class Transactor(
final class Transactor(

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In ZIO most services are case classes in zio libraries. Services sort of are data structures. If you look in the Zionomicon and the ZIO layer documentation you will see good reasons for them to be case classes. Still it is not strictly required. One advantage is maintenance. Case class provides copy which is more ergonomic than needing to call a full constructor. Still this is not that important to me.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I am thinking for a long time to change this documentation. I should have done it before.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO This is not a big issue at all - I am happy to use ordinary classes in cases like this.


def connect[A](f: DbCon ?=> A)(using Trace): Task[A] =
Copy link
Contributor

@guizmaii guizmaii Jan 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 I think the need to remove the (using Trace) parameter here shows the limit of the common trait idea. I don't really agree with the idea. I think leaving each integration free of bringing the best integration to its environment without constraints is the best approach.

Why would we need this trait? It's not like, as a Magnum user, you'd use it anyway to abstract over any Transator implementation.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a library author you would use it to generate a test kit - such that the tests are written to the trait and not to the impls - that makes it easier to plug the impls into the core tests without rewriting them.
To the library user they need not even know about the trait - notice the library still works just as it did before - a user need not care. But library maintainers often just want one set of core tests.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I look at most ZIO services - I don't see (using trace) anywhere on service methods - I am not sure we need it at all. If we find we do - I can think of a couple of ways to support it.

connectionConfig,
semaphore
)
copy(sqlLogger = sqlLogger)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a benefit of services as case classes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indeed but I still prefer it not to be a case class

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No worries - I can respect that.

Comment on lines +17 to +20
private val dataSource: DataSource,
private val sqlLogger: SqlLogger,
private val connectionConfig: Connection => Unit,
private val semaphore: Option[Semaphore]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed these should be private 🙂

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines 76 to 105
* Number of threads in your connection pool. This helps magzio be more
* memory efficient by limiting the number of blocking pool threads used.
* Not needed if using the ZIO virtual-thread based blocking executor
* @return
* Transactor UIO
* The transactor layer
*/
def layer(
dataSource: DataSource,
sqlLogger: SqlLogger,
connectionConfig: Connection => Unit,
maxBlockingThreads: Option[Int]
): ULayer[Transactor] =
ZLayer.fromZIO {
ZIO
.fromOption(maxBlockingThreads)
.flatMap(threads => Semaphore.make(threads))
.unsome
.map(semaphoreOpt =>
new Transactor(
dataSource,
sqlLogger,
connectionConfig,
semaphoreOpt
)
)
def configured(
sqlLogger: SqlLogger = SqlLogger.Default,
connectionConfig: Connection => Unit = noOpConnectionConfig,
maxBlockingThreads: Int = -1
) =
val semaphore = ZLayer {
if maxBlockingThreads < 1 then ZIO.succeed(None)
else Semaphore.make(maxBlockingThreads).map(Some(_))
}
defaultLogger ++ defaultConnectionConfig ++ semaphore >>> layer

/** Construct a Transactor
*
* @param dataSource
* Datasource to be used
* @param sqlLogger
* Logging configuration
* @param connectionConfig
* Customize the underlying JDBC Connections
* @return
* Transactor UIO
*/
def layer(
dataSource: DataSource,
sqlLogger: SqlLogger,
connectionConfig: Connection => Unit
): ULayer[Transactor] =
layer(
dataSource,
sqlLogger,
connectionConfig,
None
)

/** Construct a Transactor
*
* @param dataSource
* Datasource to be used
* @param sqlLogger
* Logging configuration
* @return
* Transactor UIO
*/
def layer(dataSource: DataSource, sqlLogger: SqlLogger): ULayer[Transactor] =
layer(dataSource, sqlLogger, noOpConnectionConfig, None)

/** Construct a Transactor
*
* @param dataSource
* Datasource to be used
* @return
* Transactor UIO
*/
def layer(dataSource: DataSource): ULayer[Transactor] =
layer(dataSource, SqlLogger.Default, noOpConnectionConfig, None)

/** Construct a Transactor
*
* @param dataSource
* Datasource to be used
* @param connectionConfig
* Customize the underlying JDBC Connections
* @return
* Transactor UIO
/** The default transactor layer with all dependencies satisfied except for
* the DataSource.
*/
def layer(
dataSource: DataSource,
connectionConfig: Connection => Unit
): ULayer[Transactor] =
layer(dataSource, SqlLogger.Default, connectionConfig, None)
val default: ZLayer[DataSource, Nothing, Transactor] =
defaultLogger ++ defaultConnectionConfig ++ defaultSemaphore >>> layer
Copy link
Contributor

@guizmaii guizmaii Jan 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 I don't understand these changes.
I'd prefer we keep as it was.

1 issue amongst others in your code: the configured function takes a sqlLogger: SqlLogger = SqlLogger.Default, parameter but never use it

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me refine that a bit - this was sort of a half baked concept.... :)

@guizmaii
Copy link
Contributor

@russwyte Gave some feedback. IMO, it'd be better if you separate the migration to zio-test and the trait idea into 2 separate PRs

@russwyte
Copy link
Author

russwyte commented Jan 19, 2025

I tend to agree that this PR is over-broad. 😅

I am happy to close this PR as I mostly just wanted to learn by example and discussion.

A couple of notes on why I started this weekend coding blitz and some learning I got from it.

  • The idea of a test kit @AugustNagro had is a great one. The trait in this PR came about in trying to imagine a way to create such a test kit without impacting the library user at all. The library users see exactly the same API as before - but library maintainers have more universally applicable Transactor trait for tests - or even conversion from one typeclass to another.
  • IMO Right now the in ZIO module (excellent work @guizmaii) the tests seem to me way too heavy and duplicative - it should only test the core methods connect and transact - other tests should be done across transactors using a core testkit that completely exercises the core API.

Thanks for this opportunity to learn! Great work on this so far!

@russwyte russwyte changed the title Use Transactor tweaks zio-test and bump zio Transactor work, add zio-test, and bump zio Jan 19, 2025
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@guizmaii @AugustNagro This what I was trying to facilitate with the trait. I needed only write the specs once to test them against both transactors.

image

@russwyte
Copy link
Author

russwyte commented Jan 20, 2025

Soon I intend to refine my idea into a new "test" (testkit) module that tests everything (like the root project) against any TransactorOps type class. This could make this library widely accessible - from those who want "pure" Scala to those who use any sort of effect or typeclass F[_]

@russwyte
Copy link
Author

Closing in favor of a more focused testing PR to come later

@russwyte russwyte closed this Jan 20, 2025
@AugustNagro
Copy link
Owner

Very interesting @russwyte and that's an awesome end result. I would agree with Jules to the extent that we should try to see if we can scrap the test boilerplate while keeping the Transactor definition the same, since

  1. We shouldn't have to change impl just to simplify tests
  2. It makes sense to have Transactor be a trait if we expect users will Mock it, but users should mock at the respository level instead (or not mock at all if possible)

However, to reduce the scope of your first contribution, can you create a PR with just the change to zio-test? That would be very helpful in and of itself.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants