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

Add MySQL java.time instances #2024

Merged
merged 1 commit into from
Apr 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
8 changes: 8 additions & 0 deletions .mergify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
14 changes: 14 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -157,6 +158,7 @@ lazy val doobie = project.in(file("."))
h2,
`h2-circe`,
hikari,
mysql,
log4cats,
postgres,
`postgres-circe`,
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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

}
11 changes: 11 additions & 0 deletions modules/mysql/src/main/scala/doobie/mysql/package.scala
Original file line number Diff line number Diff line change
@@ -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
}
87 changes: 87 additions & 0 deletions modules/mysql/src/test/scala/doobie/mysql/CheckSuite.scala
Original file line number Diff line number Diff line change
@@ -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]))
}
}
Original file line number Diff line number Diff line change
@@ -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
)
}
83 changes: 83 additions & 0 deletions modules/mysql/src/test/scala/doobie/mysql/TypesSuite.scala
Original file line number Diff line number Diff line change
@@ -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)")
}
Original file line number Diff line number Diff line change
@@ -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(_))
}

}
Loading