From c154d6c61bc375a170ade06b01a63f061480512f Mon Sep 17 00:00:00 2001 From: Marcin Wisnicki Date: Sun, 19 Jul 2026 23:03:47 -0400 Subject: [PATCH 1/3] Return Long from Update run/updateMany via executeLargeUpdate Update#run, Update0#run, updateMany, and their *AlteringExecution variants now return ConnectionIO[Long] instead of ConnectionIO[Int], using JDBC's executeLargeUpdate/executeLargeBatch. This avoids overflow when more than 2^31 rows are affected. This is a breaking change targeted for 1.0. Fixes #2479 Co-Authored-By: Claude --- .../src/main/scala/doobie/util/update.scala | 32 +++++++++---------- .../src/test/scala/doobie/issue/706.scala | 4 +-- .../test/scala/doobie/util/UpdateSuite.scala | 6 ++-- .../test/scala/doobie/util/WriteSuite.scala | 4 +-- .../docs/src/main/mdoc/docs/07-Updating.md | 4 +-- .../src/main/mdoc/docs/12-Custom-Mappings.md | 2 +- .../src/main/scala-2/example/Orm.scala | 8 ++--- .../src/main/scala/example/FirstExample.scala | 4 +-- .../scala/example/PostgresCopyInCsv.scala | 2 +- 9 files changed, 33 insertions(+), 33 deletions(-) diff --git a/modules/core/src/main/scala/doobie/util/update.scala b/modules/core/src/main/scala/doobie/util/update.scala index 85a3f019c..ef0f9cac9 100644 --- a/modules/core/src/main/scala/doobie/util/update.scala +++ b/modules/core/src/main/scala/doobie/util/update.scala @@ -82,22 +82,22 @@ object update { /** Construct a program to execute the update and yield a count of affected rows, given the writable argument `a`. * @group Execution */ - def run(a: A): ConnectionIO[Int] = + def run(a: A): ConnectionIO[Long] = IHC.executeWithoutResultSet(prepareExecutionForRun(a), loggingForRun(a)) /** Just like `run` but allowing to alter `PreparedExecutionWithoutProcessStep`. */ def runAlteringExecution( a: A, - fn: PreparedExecutionWithoutProcessStep[Int] => PreparedExecutionWithoutProcessStep[Int] - ): ConnectionIO[Int] = + fn: PreparedExecutionWithoutProcessStep[Long] => PreparedExecutionWithoutProcessStep[Long] + ): ConnectionIO[Long] = IHC.executeWithoutResultSet(fn(prepareExecutionForRun(a)), loggingForRun(a)) - private def prepareExecutionForRun(a: A): PreparedExecutionWithoutProcessStep[Int] = + private def prepareExecutionForRun(a: A): PreparedExecutionWithoutProcessStep[Long] = PreparedExecutionWithoutProcessStep( create = IFC.prepareStatement(sql), prep = IHPS.set(a), - exec = IFPS.executeUpdate + exec = IFPS.executeLargeUpdate ) private def loggingForRun(a: A): LoggingInfo = @@ -115,22 +115,22 @@ object update { * information * @group Execution */ - def updateMany[F[_]: Foldable](fa: F[A]): ConnectionIO[Int] = + def updateMany[F[_]: Foldable](fa: F[A]): ConnectionIO[Long] = IHC.executeWithoutResultSet(prepareExecutionForUpdateMany(fa), loggingInfoForUpdateMany(fa)) /** Just like `updateMany` but allowing to alter `PreparedExecutionWithoutProcessStep`. */ def updateManyAlteringExecution[F[_]: Foldable]( fa: F[A], - fn: PreparedExecutionWithoutProcessStep[Int] => PreparedExecutionWithoutProcessStep[Int] - ): ConnectionIO[Int] = + fn: PreparedExecutionWithoutProcessStep[Long] => PreparedExecutionWithoutProcessStep[Long] + ): ConnectionIO[Long] = IHC.executeWithoutResultSet(fn(prepareExecutionForUpdateMany(fa)), loggingInfoForUpdateMany(fa)) - private def prepareExecutionForUpdateMany[F[_]: Foldable](fa: F[A]): PreparedExecutionWithoutProcessStep[Int] = + private def prepareExecutionForUpdateMany[F[_]: Foldable](fa: F[A]): PreparedExecutionWithoutProcessStep[Long] = PreparedExecutionWithoutProcessStep( create = IFC.prepareStatement(sql), prep = fa.foldMap(a => IHPS.set(a) *> IFPS.addBatch), - exec = IFPS.executeBatch.map(updateCounts => updateCounts.foldLeft(0)((acc, n) => acc + n.max(0))) + exec = IFPS.executeLargeBatch.map(updateCounts => updateCounts.foldLeft(0L)((acc, n) => acc + n.max(0L))) ) private def loggingInfoForUpdateMany[F[_]: Foldable](fa: F[A]) = @@ -240,10 +240,10 @@ object update { override def toFragment: Fragment = u.toFragment(a) override def analysis: ConnectionIO[Analysis] = u.analysis override def outputAnalysis: ConnectionIO[Analysis] = u.outputAnalysis - override def run: ConnectionIO[Int] = u.run(a) + override def run: ConnectionIO[Long] = u.run(a) override def runAlteringExecution( - fn: PreparedExecutionWithoutProcessStep[Int] => PreparedExecutionWithoutProcessStep[Int] - ): ConnectionIO[Int] = + fn: PreparedExecutionWithoutProcessStep[Long] => PreparedExecutionWithoutProcessStep[Long] + ): ConnectionIO[Long] = u.runAlteringExecution(a, fn) override def withGeneratedKeysWithChunkSize[K: Read](columns: String*)(chunkSize: Int) : Stream[ConnectionIO, K] = @@ -326,11 +326,11 @@ object update { /** Program to execute the update and yield a count of affected rows. * @group Execution */ - def run: ConnectionIO[Int] + def run: ConnectionIO[Long] def runAlteringExecution( - fn: PreparedExecutionWithoutProcessStep[Int] => PreparedExecutionWithoutProcessStep[Int] - ): ConnectionIO[Int] + fn: PreparedExecutionWithoutProcessStep[Long] => PreparedExecutionWithoutProcessStep[Long] + ): ConnectionIO[Long] /** Construct a stream that performs the update, yielding generated keys of readable type `K`, identified by the * specified columns. Note that not all drivers support generated keys, and some support only a single key column. diff --git a/modules/core/src/test/scala/doobie/issue/706.scala b/modules/core/src/test/scala/doobie/issue/706.scala index 04c67500c..8414d1ece 100644 --- a/modules/core/src/test/scala/doobie/issue/706.scala +++ b/modules/core/src/test/scala/doobie/issue/706.scala @@ -27,13 +27,13 @@ class `706` extends munit.ScalaCheckSuite { val setup: ConnectionIO[Unit] = sql"CREATE TABLE IF NOT EXISTS test (test_value INTEGER)".update.run.void - def insert[F[_]: Foldable, A: Write](as: F[A]): ConnectionIO[Int] = + def insert[F[_]: Foldable, A: Write](as: F[A]): ConnectionIO[Long] = Update[A]("INSERT INTO test VALUES (?)").updateMany(as) test("updateMany should work correctly for valid inputs") { forAll { (ns: List[Int]) => val prog = setup *> insert(ns) - assertEquals(prog.transact(xa).unsafeRunSync(), ns.length) + assertEquals(prog.transact(xa).unsafeRunSync(), ns.length.toLong) } } diff --git a/modules/core/src/test/scala/doobie/util/UpdateSuite.scala b/modules/core/src/test/scala/doobie/util/UpdateSuite.scala index 93a60ed6c..ddce22486 100644 --- a/modules/core/src/test/scala/doobie/util/UpdateSuite.scala +++ b/modules/core/src/test/scala/doobie/util/UpdateSuite.scala @@ -28,7 +28,7 @@ class UpdateSuite extends CatsEffectSuite { 1, pe => pe.copy(exec = IFPS.delay { didRun = true } *> pe.exec)) } yield { - assertEquals(res, 1) + assertEquals(res, 1L) }) .transact(xa) .flatMap { _ => @@ -44,7 +44,7 @@ class UpdateSuite extends CatsEffectSuite { List(2, 4, 6, 8), pe => pe.copy(exec = IFPS.delay { didRun = true } *> pe.exec)) } yield { - assertEquals(res, 4) + assertEquals(res, 4L) }) .transact(xa) .flatMap { _ => @@ -77,7 +77,7 @@ class UpdateSuite extends CatsEffectSuite { res <- Update[Int]("insert into t1 (a) values (?)").toUpdate0(1).runAlteringExecution(pe => pe.copy(exec = IFPS.delay { didRun = true } *> pe.exec)) } yield { - assertEquals(res, 1) + assertEquals(res, 1L) }) .transact(xa) .flatMap { _ => diff --git a/modules/core/src/test/scala/doobie/util/WriteSuite.scala b/modules/core/src/test/scala/doobie/util/WriteSuite.scala index ede432ce9..0ae37894a 100644 --- a/modules/core/src/test/scala/doobie/util/WriteSuite.scala +++ b/modules/core/src/test/scala/doobie/util/WriteSuite.scala @@ -221,7 +221,7 @@ class WriteSuite extends munit.CatsEffectSuite with WriteSuitePlatform { implicit val wwscc: Write[WrappedSimpleCaseClass] = wscc.contramap(_.sc) // Testing contramap doesn't break typechecking - val createTable: ConnectionIO[Int] = sql"create temp table tab(c1 int, c2 varchar not null, c3 varchar)".update.run + val createTable: ConnectionIO[Long] = sql"create temp table tab(c1 int, c2 varchar not null, c3 varchar)".update.run val insertSimpleSql = "INSERT INTO tab VALUES (?,?,?)" val insertComplexSql = "INSERT INTO tab VALUES (?,?,?,?,?,?,?,?)" @@ -275,7 +275,7 @@ class WriteSuite extends munit.CatsEffectSuite with WriteSuitePlatform { ): IO[Unit] = Query[A, Tup]("SELECT ?, ?, ?").unique(in).transact(xa).assertEquals(expectedOut) - private def testNullPut(input: (String, Option[String])): IO[Int] = { + private def testNullPut(input: (String, Option[String])): IO[Long] = { import org.typelevel.doobie.implicits.* (for { diff --git a/modules/docs/src/main/mdoc/docs/07-Updating.md b/modules/docs/src/main/mdoc/docs/07-Updating.md index e22383403..98c9a7f7c 100644 --- a/modules/docs/src/main/mdoc/docs/07-Updating.md +++ b/modules/docs/src/main/mdoc/docs/07-Updating.md @@ -42,7 +42,7 @@ implicit val mdocColors: org.typelevel.doobie.util.Colors = org.typelevel.doobie It is uncommon to define database structures at runtime, but **doobie** handles it just fine and treats such operations like any other kind of update. And it happens to be useful here! -Let's create a new table, which we will use for the examples to follow. This looks a lot like our prior usage of the `sql` interpolator, but this time we're using `update` rather than `query`. The `.run` method gives a `ConnectionIO[Int]` that yields the total number of rows modified, and the YOLO-mode `.quick` gives a `IO[Unit]` that prints out the row count. +Let's create a new table, which we will use for the examples to follow. This looks a lot like our prior usage of the `sql` interpolator, but this time we're using `update` rather than `query`. The `.run` method gives a `ConnectionIO[Long]` that yields the total number of rows modified, and the YOLO-mode `.quick` gives a `IO[Unit]` that prints out the row count. ```scala mdoc:silent val drop = @@ -196,7 +196,7 @@ By using an `Update` directly we can apply *many* sets of arguments to the same ```scala mdoc:silent type PersonInfo = (String, Option[Short]) -def insertMany(ps: List[PersonInfo]): ConnectionIO[Int] = { +def insertMany(ps: List[PersonInfo]): ConnectionIO[Long] = { val sql = "insert into person (name, age) values (?, ?)" Update[PersonInfo](sql).updateMany(ps) } diff --git a/modules/docs/src/main/mdoc/docs/12-Custom-Mappings.md b/modules/docs/src/main/mdoc/docs/12-Custom-Mappings.md index a9681b1da..46b58ca76 100644 --- a/modules/docs/src/main/mdoc/docs/12-Custom-Mappings.md +++ b/modules/docs/src/main/mdoc/docs/12-Custom-Mappings.md @@ -22,7 +22,7 @@ import org.postgresql.util.PGobject Your first evidence that you need a new type mapping will likely be a type error. There are two common cases. The first case appears when you try to use an unmapped type as a statement parameter. ```scala mdoc:fail -def nope(msg: String, ex: Exception): ConnectionIO[Int] = +def nope(msg: String, ex: Exception): ConnectionIO[Long] = sql"INSERT INTO log (message, detail) VALUES ($msg, $ex)".update.run ``` diff --git a/modules/example/src/main/scala-2/example/Orm.scala b/modules/example/src/main/scala-2/example/Orm.scala index e690ab59e..9a1fdb056 100644 --- a/modules/example/src/main/scala-2/example/Orm.scala +++ b/modules/example/src/main/scala-2/example/Orm.scala @@ -27,8 +27,8 @@ object Orm extends IOApp { def insert(a: A): ConnectionIO[Key] def find(k: Key): ConnectionIO[Option[A]] def findAll: Stream[ConnectionIO, A] - def update(k: Key, a: A): ConnectionIO[Int] - def delete(k: Key): ConnectionIO[Int] + def update(k: Key, a: A): ConnectionIO[Long] + def delete(k: Key): ConnectionIO[Long] } object Dao { @@ -74,14 +74,14 @@ object Orm extends IOApp { FROM $table """).stream - def update(k: Key, a: A): ConnectionIO[Int] = + def update(k: Key, a: A): ConnectionIO[Long] = Update[(A, Key)](s""" UPDATE $table SET ${cols.map(_ + " = ?").mkString(", ")} WHERE $keyCol = ? """).run((a, k)) - def delete(k: Key): ConnectionIO[Int] = { + def delete(k: Key): ConnectionIO[Long] = { Update[Key](s""" DELETE FROM $table WHERE $keyCol = ? diff --git a/modules/example/src/main/scala/example/FirstExample.scala b/modules/example/src/main/scala/example/FirstExample.scala index 35b19ce51..e39cb5c1d 100644 --- a/modules/example/src/main/scala/example/FirstExample.scala +++ b/modules/example/src/main/scala/example/FirstExample.scala @@ -86,10 +86,10 @@ object FirstExample extends IOApp.Simple { def coffeesLessThan(price: Double): Stream[ConnectionIO, (String, String)] = Queries.coffeesLessThan(price).stream - def insertSuppliers(ss: List[Supplier]): ConnectionIO[Int] = + def insertSuppliers(ss: List[Supplier]): ConnectionIO[Long] = Queries.insertSupplier.updateMany(ss) // bulk insert (!) - def insertCoffees(cs: List[Coffee]): ConnectionIO[Int] = + def insertCoffees(cs: List[Coffee]): ConnectionIO[Long] = Queries.insertCoffee.updateMany(cs) def allCoffees: Stream[ConnectionIO, Coffee] = diff --git a/modules/example/src/main/scala/example/PostgresCopyInCsv.scala b/modules/example/src/main/scala/example/PostgresCopyInCsv.scala index 978fcee68..e12e26d13 100644 --- a/modules/example/src/main/scala/example/PostgresCopyInCsv.scala +++ b/modules/example/src/main/scala/example/PostgresCopyInCsv.scala @@ -41,7 +41,7 @@ object PostgresCopyInCsv extends IOApp.Simple { val byteStream = Stream.emit(csv).through(utf8.encode).covary[IO] // Create a temorary table to hold the input data - val createTable: ConnectionIO[Int] = sql"CREATE TEMP TABLE favorite_foods(name TEXT, food TEXT)".update.run + val createTable: ConnectionIO[Long] = sql"CREATE TEMP TABLE favorite_foods(name TEXT, food TEXT)".update.run def copyIn(is: InputStream): ConnectionIO[Long] = { // construct a CopyManagerIO with the postgres extensions From b2deac1e574eba3a6d7e713e60f45d0bce1d573c Mon Sep 17 00:00:00 2001 From: Marcin Wisnicki Date: Sun, 19 Jul 2026 23:17:00 -0400 Subject: [PATCH 2/3] Fix unused import after Int->Long change yolo.scala's Update0YoloOps.quick shows the update count via Show, so switching the count from Int to Long makes cats.instances.int unused (fatal under tpolecatCiMode) and requires cats.instances.long instead. Co-Authored-By: Claude --- modules/core/src/main/scala/doobie/util/yolo.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/core/src/main/scala/doobie/util/yolo.scala b/modules/core/src/main/scala/doobie/util/yolo.scala index 5f8ad7047..c33f7abec 100644 --- a/modules/core/src/main/scala/doobie/util/yolo.scala +++ b/modules/core/src/main/scala/doobie/util/yolo.scala @@ -5,7 +5,7 @@ package org.typelevel.doobie.util import cats.effect.kernel.Async -import cats.instances.int.* +import cats.instances.long.* import cats.instances.string.* import cats.syntax.show.* import org.typelevel.doobie.free.connection.{ConnectionIO, delay} From 4a81e2cce16aca5a28539103653ec83b7a55c25f Mon Sep 17 00:00:00 2001 From: Marcin Wisnicki Date: Mon, 20 Jul 2026 01:13:42 -0400 Subject: [PATCH 3/3] Update otel4s trace expectations for executeLargeUpdate/Batch Update#run and updateMany now invoke executeLargeUpdate/executeLargeBatch, so TracedInterpreter emits spans named executeLargeUpdate/executeLargeBatch (the interpreter already overrode these). Update TracedTransactorSuite's expected span names and db.operation.name attributes to match. Co-Authored-By: Claude --- .../doobie/otel4s/TracedTransactorSuite.scala | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/modules/otel4s/src/test/scala/doobie/otel4s/TracedTransactorSuite.scala b/modules/otel4s/src/test/scala/doobie/otel4s/TracedTransactorSuite.scala index 608b33a32..ad2529991 100644 --- a/modules/otel4s/src/test/scala/doobie/otel4s/TracedTransactorSuite.scala +++ b/modules/otel4s/src/test/scala/doobie/otel4s/TracedTransactorSuite.scala @@ -170,18 +170,18 @@ class TracedTransactorSuite extends munit.CatsEffectSuite { testkitTest("record db.operation.batch.size for batch operations") { testkit => val expected = expectedSpans( span( - name = "executeUpdate", + name = "executeLargeUpdate", attributes = Attributes( DbAttributes.DbQueryText("CREATE LOCAL TEMPORARY TABLE TEST_BATCH (int_value INT)"), - DbAttributes.DbOperationName("executeUpdate") + DbAttributes.DbOperationName("executeLargeUpdate") ) ), span( - name = "executeBatch", + name = "executeLargeBatch", attributes = Attributes( DbAttributes.DbQueryText("insert into TEST_BATCH (int_value) values (?)"), DbAttributes.DbOperationBatchSize(3L), - DbAttributes.DbOperationName("executeBatch") + DbAttributes.DbOperationName("executeLargeBatch") ) ) ) @@ -269,7 +269,7 @@ class TracedTransactorSuite extends munit.CatsEffectSuite { span( name = summary, attributes = Attributes( - DbAttributes.DbOperationName("executeUpdate"), + DbAttributes.DbOperationName("executeLargeUpdate"), DbAttributes.DbQuerySummary(summary) ) ) @@ -294,9 +294,9 @@ class TracedTransactorSuite extends munit.CatsEffectSuite { ) ++ attrs ), span( - name = "executeUpdate", + name = "executeLargeUpdate", attributes = Attributes( - DbAttributes.DbOperationName("executeUpdate") + DbAttributes.DbOperationName("executeLargeUpdate") ) ++ attrs ) ) @@ -454,9 +454,9 @@ class TracedTransactorSuite extends munit.CatsEffectSuite { ) ), span( - name = "executeUpdate", + name = "executeLargeUpdate", attributes = Attributes( - DbAttributes.DbOperationName("executeUpdate") + DbAttributes.DbOperationName("executeLargeUpdate") ) ) ) @@ -480,9 +480,9 @@ class TracedTransactorSuite extends munit.CatsEffectSuite { ) ++ attrs ), span( - name = "executeUpdate", + name = "executeLargeUpdate", attributes = Attributes( - DbAttributes.DbOperationName("executeUpdate") + DbAttributes.DbOperationName("executeLargeUpdate") ) ++ attrs ) ) @@ -512,7 +512,7 @@ class TracedTransactorSuite extends munit.CatsEffectSuite { span( name = summary, attributes = Attributes( - DbAttributes.DbOperationName("executeUpdate") + DbAttributes.DbOperationName("executeLargeUpdate") ) ++ attrs ) ) @@ -536,9 +536,9 @@ class TracedTransactorSuite extends munit.CatsEffectSuite { ) ), span( - name = "executeUpdate", + name = "executeLargeUpdate", attributes = Attributes( - DbAttributes.DbOperationName("executeUpdate") + DbAttributes.DbOperationName("executeLargeUpdate") ) ) )