diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41818d5bb..64868617b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,11 +80,11 @@ jobs: - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: mkdir -p modules/weaver/target modules/scalatest/target modules/refined/target modules/postgres/target modules/log4cats/target modules/postgres-circe/target modules/h2/target modules/hikari/target modules/munit/target modules/h2-circe/target modules/core/target modules/specs2/target modules/free/target project/target + run: mkdir -p modules/weaver/target modules/scalatest/target modules/refined/target modules/postgres/target modules/log4cats/target modules/postgres-circe/target modules/h2/target modules/hikari/target modules/munit/target modules/h2-circe/target modules/mysql/target modules/core/target modules/specs2/target modules/free/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: tar cf targets.tar modules/weaver/target modules/scalatest/target modules/refined/target modules/postgres/target modules/log4cats/target modules/postgres-circe/target modules/h2/target modules/hikari/target modules/munit/target modules/h2-circe/target modules/core/target modules/specs2/target modules/free/target project/target + run: tar cf targets.tar modules/weaver/target modules/scalatest/target modules/refined/target modules/postgres/target modules/log4cats/target modules/postgres-circe/target modules/h2/target modules/hikari/target modules/munit/target modules/h2-circe/target modules/mysql/target modules/core/target modules/specs2/target modules/free/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') diff --git a/.mergify.yml b/.mergify.yml index f94de2c34..dada12592 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -95,6 +95,14 @@ pull_request_rules: add: - munit remove: [] +- name: Label mysql PRs + conditions: + - files~=^modules/mysql/ + actions: + label: + add: + - mysql + remove: [] - name: Label postgres PRs conditions: - files~=^modules/postgres/ diff --git a/build.sbt b/build.sbt index 061765ac8..11bd6bb49 100644 --- a/build.sbt +++ b/build.sbt @@ -11,6 +11,7 @@ lazy val fs2Version = "3.10.2" lazy val h2Version = "1.4.200" lazy val hikariVersion = "5.1.0" // N.B. Hikari v4 introduces a breaking change via slf4j v2 lazy val kindProjectorVersion = "0.11.2" +lazy val mysqlVersion = "8.0.31" lazy val log4catsVersion = "2.6.0" lazy val postGisVersion = "2023.1.0" lazy val postgresVersion = "42.7.3" @@ -157,6 +158,7 @@ lazy val doobie = project.in(file(".")) h2, `h2-circe`, hikari, + mysql, log4cats, postgres, `postgres-circe`, @@ -283,6 +285,18 @@ lazy val example = project ) ) +lazy val mysql = project + .in(file("modules/mysql")) + .enablePlugins(AutomateHeaderPlugin) + .dependsOn(core % "compile->compile;test->test") + .settings(doobieSettings) + .settings( + name := "doobie-mysql", + libraryDependencies ++= Seq( + "com.mysql" % "mysql-connector-j" % mysqlVersion, + ), + ) + lazy val postgres = project .in(file("modules/postgres")) .enablePlugins(AutomateHeaderPlugin) diff --git a/modules/mysql/src/main/scala/doobie/mysql/MysqlJavaTimeInstances.scala b/modules/mysql/src/main/scala/doobie/mysql/MysqlJavaTimeInstances.scala new file mode 100644 index 000000000..65d7edd58 --- /dev/null +++ b/modules/mysql/src/main/scala/doobie/mysql/MysqlJavaTimeInstances.scala @@ -0,0 +1,63 @@ +// Copyright (c) 2013-2020 Rob Norris and Contributors +// This software is licensed under the MIT License (MIT). +// For more information see LICENSE or https://opensource.org/licenses/MIT + +package doobie.mysql + +import doobie.Meta +import doobie.enumerated.JdbcType +import doobie.util.meta.MetaConstructors.Basic + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.OffsetDateTime +import java.time.ZoneOffset + +/** + * Instances for JSR-310 date time types. + * + * Note that to ensure instants are preserved you may need to use one of the solutions described + * in [[https://docs.oracle.com/cd/E17952_01/connector-j-8.0-en/connector-j-time-instants.html]]. + * + * OffsetTime instance is not supported as there is no semantically equivalent + * type on the MySQL side. + */ +trait MysqlJavaTimeInstances { + + implicit val JavaTimeOffsetDateTimeMeta: Meta[OffsetDateTime] = + Basic.oneObject( + JdbcType.Timestamp, + Some("TIMESTAMP"), + classOf[OffsetDateTime], + ) + + implicit val JavaTimeInstantMeta: Meta[Instant] = + JavaTimeOffsetDateTimeMeta.timap(_.toInstant)(OffsetDateTime.ofInstant(_, ZoneOffset.UTC)) + + implicit val JavaTimeLocalDateTimeMeta: Meta[LocalDateTime] = + Basic.oneObject( + jdbcType = JdbcType.Timestamp, + Some("DATETIME"), + clazz = classOf[LocalDateTime], + ) + + implicit val JavaTimeLocalDateMeta: Meta[LocalDate] = + Basic.oneObject( + jdbcType = JdbcType.Date, + checkedVendorType = None, + clazz = classOf[LocalDate] + ) + + implicit val JavaTimeLocalTimeMeta: Meta[LocalTime] = + Basic.oneObject( + jdbcType = JdbcType.Time, + checkedVendorType = None, + clazz = classOf[LocalTime] + ) + + implicit val JavaTimeZoneId: Meta[java.time.ZoneId] = + doobie.implicits.javatimedrivernative.JavaTimeZoneId + +} diff --git a/modules/mysql/src/main/scala/doobie/mysql/package.scala b/modules/mysql/src/main/scala/doobie/mysql/package.scala new file mode 100644 index 000000000..db940ef5d --- /dev/null +++ b/modules/mysql/src/main/scala/doobie/mysql/package.scala @@ -0,0 +1,11 @@ +// Copyright (c) 2013-2020 Rob Norris and Contributors +// This software is licensed under the MIT License (MIT). +// For more information see LICENSE or https://opensource.org/licenses/MIT + +package doobie + +package object mysql { + + object implicits + extends MysqlJavaTimeInstances +} diff --git a/modules/mysql/src/test/scala/doobie/mysql/CheckSuite.scala b/modules/mysql/src/test/scala/doobie/mysql/CheckSuite.scala new file mode 100644 index 000000000..6388cebc1 --- /dev/null +++ b/modules/mysql/src/test/scala/doobie/mysql/CheckSuite.scala @@ -0,0 +1,87 @@ +// Copyright (c) 2013-2020 Rob Norris and Contributors +// This software is licensed under the MIT License (MIT). +// For more information see LICENSE or https://opensource.org/licenses/MIT + +package doobie.mysql + +import java.time.{LocalDate, LocalDateTime, LocalTime, OffsetDateTime} + +import doobie._ +import doobie.implicits._ +import doobie.mysql.implicits._ +import doobie.util.analysis.{ColumnTypeError, ParameterTypeError} + +class CheckSuite extends munit.FunSuite { + import cats.effect.unsafe.implicits.global + import MySQLTestTransactor.xa + + // note selecting from a table because a value cannot be cast to a timestamp + // and casting returns a nullable column + + test("OffsetDateTime Read typechecks") { + val t = OffsetDateTime.parse("2019-02-13T22:03:21.000+08:00") + successRead[OffsetDateTime](sql"SELECT c_timestamp FROM test LIMIT 1") + + failedRead[OffsetDateTime](sql"SELECT '2019-02-13 22:03:21.051'") +// failedWrite[OffsetDateTime](t, "VARCHAR") + failedRead[OffsetDateTime](sql"SELECT c_date FROM test LIMIT 1") +// failedWrite[OffsetDateTime](t, "DATE") + failedRead[OffsetDateTime](sql"SELECT c_time FROM test LIMIT 1") +// failedWrite[OffsetDateTime](t, "TIME") + failedRead[OffsetDateTime](sql"SELECT c_datetime FROM test LIMIT 1") +// failedWrite[OffsetDateTime](t, "DATETIME") + failedRead[OffsetDateTime](sql"SELECT c_integer FROM test LIMIT 1") +// failedWrite[OffsetDateTime](t, "INT") + } + + test("LocalDateTime Read typechecks") { + successRead[LocalDateTime](sql"SELECT c_datetime FROM test LIMIT 1") + + failedRead[LocalDateTime](sql"SELECT '2019-02-13 22:03:21.051'") + failedRead[LocalDateTime](sql"SELECT c_date FROM test LIMIT 1") + failedRead[LocalDateTime](sql"SELECT c_time FROM test LIMIT 1") + failedRead[LocalDateTime](sql"SELECT c_timestamp FROM test LIMIT 1") + failedRead[LocalDateTime](sql"SELECT 123") + } + + test("LocalDate Read typechecks") { + successRead[LocalDate](sql"SELECT c_date FROM test LIMIT 1") + + failedRead[LocalDate](sql"SELECT '2019-02-13'") + failedRead[LocalDate](sql"SELECT c_time FROM test LIMIT 1") + failedRead[LocalDate](sql"SELECT c_datetime FROM test LIMIT 1") + failedRead[LocalDate](sql"SELECT c_timestamp FROM test LIMIT 1") + failedRead[LocalDate](sql"SELECT 123") + } + + test("LocalTime Read typechecks") { + successRead[LocalTime](sql"SELECT c_time FROM test LIMIT 1") + + failedRead[LocalTime](sql"SELECT c_date FROM test LIMIT 1") + failedRead[LocalTime](sql"SELECT c_datetime FROM test LIMIT 1") + failedRead[LocalTime](sql"SELECT c_timestamp FROM test LIMIT 1") + failedRead[LocalTime](sql"SELECT '22:03:21'") + failedRead[LocalTime](sql"SELECT 123") + } + + private def successRead[A: Read](frag: Fragment): Unit = { + val analysisResult = frag.query[A].analysis.transact(xa).unsafeRunSync() + assertEquals(analysisResult.columnAlignmentErrors, Nil) + + val result = frag.query[A].unique.transact(xa).attempt.unsafeRunSync() + assert(result.isRight) + } + + private def failedRead[A: Read](frag: Fragment): Unit = { + val analysisResult = frag.query[A].analysis.transact(xa).unsafeRunSync() + val errorClasses = analysisResult.columnAlignmentErrors.map(_.getClass) + assertEquals(errorClasses, List(classOf[ColumnTypeError])) + } + + private def failedWrite[A: Put](value: A, dbType: String): Unit = { + val frag = sql"SELECT $value :: " ++ Fragment.const(dbType) + val analysisResult = frag.update.analysis.transact(xa).unsafeRunSync() + val errorClasses = analysisResult.parameterAlignmentErrors.map(_.getClass) + assertEquals(errorClasses, List(classOf[ParameterTypeError])) + } +} diff --git a/modules/mysql/src/test/scala/doobie/mysql/MySQLTestTransactor.scala b/modules/mysql/src/test/scala/doobie/mysql/MySQLTestTransactor.scala new file mode 100644 index 000000000..3f865ec55 --- /dev/null +++ b/modules/mysql/src/test/scala/doobie/mysql/MySQLTestTransactor.scala @@ -0,0 +1,19 @@ +// Copyright (c) 2013-2020 Rob Norris and Contributors +// This software is licensed under the MIT License (MIT). +// For more information see LICENSE or https://opensource.org/licenses/MIT + +package doobie.mysql + +import cats.effect.IO +import doobie.Transactor + +object MySQLTestTransactor { + + val xa = Transactor.fromDriverManager[IO]( + "com.mysql.cj.jdbc.Driver", + // args from solution 2a https://docs.oracle.com/cd/E17952_01/connector-j-8.0-en/connector-j-time-instants.html + "jdbc:mysql://localhost:3306/world?preserveInstants=true&connectionTimeZone=SERVER", + "root", "password", + logHandler = None + ) +} diff --git a/modules/mysql/src/test/scala/doobie/mysql/TypesSuite.scala b/modules/mysql/src/test/scala/doobie/mysql/TypesSuite.scala new file mode 100644 index 000000000..76a359e14 --- /dev/null +++ b/modules/mysql/src/test/scala/doobie/mysql/TypesSuite.scala @@ -0,0 +1,83 @@ +// Copyright (c) 2013-2020 Rob Norris and Contributors +// This software is licensed under the MIT License (MIT). +// For more information see LICENSE or https://opensource.org/licenses/MIT + +package doobie.mysql + +import java.time.ZoneOffset + +import doobie._ +import doobie.implicits._ +import doobie.mysql.implicits._ +import doobie.mysql.util.arbitraries.SQLArbitraries._ +import doobie.mysql.util.arbitraries.TimeArbitraries._ +import org.scalacheck.Arbitrary +import org.scalacheck.Prop.forAll + +class TypesSuite extends munit.ScalaCheckSuite { + import cats.effect.unsafe.implicits.global + import MySQLTestTransactor.xa + + def inOut[A: Get : Put](col: String, a: A): ConnectionIO[A] = for { + _ <- Update0(s"CREATE TEMPORARY TABLE test (value $col NOT NULL)", None).run + _ <- Update[A](s"INSERT INTO test VALUES (?)", None).run(a) + a0 <- Query0[A](s"SELECT value FROM test", None).unique + } yield a0 + + def inOutOpt[A: Get : Put](col: String, a: Option[A]): ConnectionIO[Option[A]] = + for { + _ <- Update0(s"CREATE TEMPORARY TABLE test (value $col)", None).run + _ <- Update[Option[A]](s"INSERT INTO test VALUES (?)", None).run(a) + a0 <- Query0[Option[A]](s"SELECT value FROM test", None).unique + } yield a0 + + private def testInOut[A](col: String)(implicit m: Get[A], p: Put[A], arbitrary: Arbitrary[A]): Unit = { + testInOutCustomize(col ) + } + + private def testInOutCustomize[A]( + col: String, + skipNone: Boolean = false, + expected: A => A = identity[A](_) + )(implicit m: Get[A], p: Put[A], arbitrary: Arbitrary[A]): Unit = { + val gen = arbitrary.arbitrary + + test(s"Mapping for $col as ${m.typeStack} - write+read $col as ${m.typeStack}") { + forAll(gen) { (t: A) => + val actual = inOut(col, t).transact(xa).attempt.unsafeRunSync() + assertEquals(actual.map(expected(_)), Right(expected(t))) + } + } + test(s"Mapping for $col as ${m.typeStack} - write+read $col as Option[${m.typeStack}] (Some)") { + forAll(gen) { (t: A) => + val actual = inOutOpt[A](col, Some(t)).transact(xa).attempt.unsafeRunSync() + assertEquals(actual.map(_.map(expected(_))), Right(Some(expected(t)))) + } + } + if (!skipNone) { + test(s"Mapping for $col as ${m.typeStack} - write+read $col as Option[${m.typeStack}] (None)") { + assertEquals(inOutOpt[A](col, None).transact(xa).attempt.unsafeRunSync(), Right(None)) + } + } + } + + + testInOutCustomize[java.time.OffsetDateTime]( + "timestamp(6)", + skipNone = true, // returns the current timestamp, lol + _.withOffsetSameInstant(ZoneOffset.UTC) + ) + testInOutCustomize[java.time.Instant]( + "timestamp(6)", + skipNone = true, // returns the current timestamp, lol + ) + + testInOut[java.sql.Timestamp]("datetime(6)") + testInOut[java.time.LocalDateTime]("datetime(6)") + + testInOut[java.sql.Date]("date") + testInOut[java.time.LocalDate]("date") + + testInOut[java.sql.Time]("time") + testInOut[java.time.LocalTime]("time(6)") +} diff --git a/modules/mysql/src/test/scala/doobie/mysql/util/arbitraries/SQLArbitraries.scala b/modules/mysql/src/test/scala/doobie/mysql/util/arbitraries/SQLArbitraries.scala new file mode 100644 index 000000000..6ec6cfba2 --- /dev/null +++ b/modules/mysql/src/test/scala/doobie/mysql/util/arbitraries/SQLArbitraries.scala @@ -0,0 +1,27 @@ +// Copyright (c) 2013-2020 Rob Norris and Contributors +// This software is licensed under the MIT License (MIT). +// For more information see LICENSE or https://opensource.org/licenses/MIT + +package doobie.mysql.util.arbitraries + +import java.sql.Date +import java.sql.Time +import java.sql.Timestamp + +import org.scalacheck.Arbitrary + +object SQLArbitraries { + + implicit val arbitraryTime: Arbitrary[Time] = Arbitrary { + TimeArbitraries.arbitraryLocalTime.arbitrary.map(Time.valueOf(_)) + } + + implicit val arbitraryDate: Arbitrary[Date] = Arbitrary { + TimeArbitraries.arbitraryLocalDate.arbitrary.map(Date.valueOf(_)) + } + + implicit val arbitraryTimestamp: Arbitrary[Timestamp] = Arbitrary { + TimeArbitraries.arbitraryLocalDateTime.arbitrary.map(Timestamp.valueOf(_)) + } + +} diff --git a/modules/mysql/src/test/scala/doobie/mysql/util/arbitraries/TimeArbitraries.scala b/modules/mysql/src/test/scala/doobie/mysql/util/arbitraries/TimeArbitraries.scala new file mode 100644 index 000000000..72f0f2ce5 --- /dev/null +++ b/modules/mysql/src/test/scala/doobie/mysql/util/arbitraries/TimeArbitraries.scala @@ -0,0 +1,61 @@ +// Copyright (c) 2013-2020 Rob Norris and Contributors +// This software is licensed under the MIT License (MIT). +// For more information see LICENSE or https://opensource.org/licenses/MIT + +package doobie.mysql.util.arbitraries + +import java.time._ + +import doobie.util.arbitraries.GenHelpers +import org.scalacheck.Arbitrary +import org.scalacheck.Gen + +// https://dev.mysql.com/doc/refman/5.7/en/datetime.html +object TimeArbitraries { + + // max resolution is 1 microsecond + private def micros(nanos: Long) = Math.floorDiv(nanos, 1000) + + // for Scala 2.12 + private implicit val orderingLocalDate: Ordering[LocalDate] = new Ordering[LocalDate] { + override def compare(x: LocalDate, y: LocalDate): Int = x compareTo y + } + + // 1000-01-01 to 9999-12-31 + implicit val arbitraryLocalDate: Arbitrary[LocalDate] = Arbitrary { + GenHelpers.chooseT(LocalDate.of(1000, 1, 1), LocalDate.of(9999, 12, 31), LocalDate.of(1970, 1, 1)) + } + + // 00:00:00.000000 to 23:59:59.999999 + implicit val arbitraryLocalTime: Arbitrary[LocalTime] = Arbitrary { + val min = micros(LocalTime.MIN.toNanoOfDay) + val max = micros(LocalTime.MAX.toNanoOfDay) + val noon = micros(LocalTime.NOON.toNanoOfDay) + Gen.chooseNum(min, max, noon).map(micros => LocalTime.ofNanoOfDay(micros * 1000)) + } + + // '1000-01-01 00:00:00.000000' to '9999-12-31 23:59:59.999999' + implicit val arbitraryLocalDateTime: Arbitrary[LocalDateTime] = Arbitrary { + for { + date <- arbitraryLocalDate.arbitrary + time <- arbitraryLocalTime.arbitrary + } yield LocalDateTime.of(date, time) + } + + // '1970-01-01 00:00:01.000000' to '2038-01-19 03:14:07.999999 + implicit val arbitraryInstant: Arbitrary[Instant] = Arbitrary { + val min = 1 * 1000000L + 0 + val max = 2147483647 * 1000000L + 999999 + + Gen.chooseNum(min, max).map { micros => + Instant.ofEpochSecond(micros / 1000000, micros % 1000000 * 1000) + } + } + + implicit val arbitraryOffsetDateTime: Arbitrary[OffsetDateTime] = Arbitrary { + for { + instant <- arbitraryInstant.arbitrary + offset <- Arbitrary.arbitrary[ZoneOffset] + } yield instant.atOffset(offset) + } +}