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

MySQL Java time instances #1791

Closed
wants to merge 3 commits into from
Closed
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.9.3"
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.1"
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
12 changes: 11 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,14 @@ services:
ports:
- 5432:5432
volumes:
- ./init/:/docker-entrypoint-initdb.d/
- ./init/postgres/:/docker-entrypoint-initdb.d/

mysql:
image: mysql:5.7-debian
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: world
ports:
- 3306:3306
volumes:
- ./init/mysql/:/docker-entrypoint-initdb.d/
11 changes: 11 additions & 0 deletions init/mysql/test-table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@

CREATE TABLE IF NOT EXISTS test (
c_integer INTEGER NOT NULL,
c_varchar VARCHAR(1024) NOT NULL,
c_date DATE NOT NULL,
c_datetime DATETIME(6) NOT NULL,
c_time TIME(6) NOT NULL,
c_timestamp TIMESTAMP(6) NOT NULL
);
INSERT INTO test(c_integer, c_varchar, c_date, c_datetime, c_time, c_timestamp)
VALUES (123, 'str', '2019-02-13', '2019-02-13 22:03:21.051', '22:03:21.051', '2019-02-13 22:03:21.051');
File renamed without changes.
51 changes: 29 additions & 22 deletions modules/core/src/main/scala/doobie/hi/connection.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,28 @@

package doobie.hi

import doobie.util.compat.propertiesToScala
import cats.Foldable
import cats.data.Ior
import cats.effect.kernel.syntax.monadCancel._
import cats.syntax.all._
import doobie.enumerated.AutoGeneratedKeys
import doobie.enumerated.Holdability
import doobie.enumerated.ResultSetType
import doobie.enumerated.Nullability
import doobie.enumerated.ResultSetConcurrency
import doobie.enumerated.ResultSetType
import doobie.enumerated.TransactionIsolation
import doobie.enumerated.AutoGeneratedKeys
import doobie.util.{ Read, Write }
import doobie.util.analysis.Analysis
import doobie.util.analysis.ColumnMeta
import doobie.util.analysis.ParameterMeta
import doobie.util.compat.propertiesToScala
import doobie.util.stream.repeatEvalChunks
import doobie.util.{ Get, Put, Read, Write }
import fs2.Stream
import fs2.Stream.{ eval, bracket }

import java.sql.{ Savepoint, PreparedStatement, ResultSet }

import scala.collection.immutable.Map

import cats.Foldable
import cats.syntax.all._
import cats.effect.kernel.syntax.monadCancel._
import fs2.Stream
import fs2.Stream.{ eval, bracket }

/**
* Module of high-level constructors for `ConnectionIO` actions.
* @group Modules
Expand Down Expand Up @@ -92,24 +94,29 @@ object connection {
* readable resultset row type `B`.
*/
def prepareQueryAnalysis[A: Write, B: Read](sql: String): ConnectionIO[Analysis] =
prepareStatement(sql) {
(HPS.getParameterMappings[A], HPS.getColumnMappings[B]) mapN (Analysis(sql, _, _))
}
prepareAnalysis(sql, HPS.getParameterMappings[A], HPS.getColumnMappings[B])

def prepareQueryAnalysis0[B: Read](sql: String): ConnectionIO[Analysis] =
prepareStatement(sql) {
HPS.getColumnMappings[B] map (cm => Analysis(sql, Nil, cm))
}
prepareAnalysis(sql, FPS.pure(Nil), HPS.getColumnMappings[B])

def prepareUpdateAnalysis[A: Write](sql: String): ConnectionIO[Analysis] =
prepareStatement(sql) {
HPS.getParameterMappings[A] map (pm => Analysis(sql, pm, Nil))
}
prepareAnalysis(sql, HPS.getParameterMappings[A], FPS.pure(Nil))

def prepareUpdateAnalysis0(sql: String): ConnectionIO[Analysis] =
prepareStatement(sql) {
Analysis(sql, Nil, Nil).pure[PreparedStatementIO]
prepareAnalysis(sql, FPS.pure(Nil), FPS.pure(Nil))

private def prepareAnalysis(
sql: String,
params: PreparedStatementIO[List[(Put[_], Nullability.NullabilityKnown) Ior ParameterMeta]],
columns: PreparedStatementIO[List[(Get[_], Nullability.NullabilityKnown) Ior ColumnMeta]],
) = {
val mappings = prepareStatement(sql) {
(params, columns).tupled
}
(HC.getMetaData(FDMD.getDriverName), mappings).mapN { case (driver, (p, c)) =>
Analysis(driver, sql, p, c)
}
}


/** @group Statements */
Expand Down
57 changes: 31 additions & 26 deletions modules/core/src/main/scala/doobie/util/analysis.scala
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,9 @@ object analysis {

/** Metadata for the JDBC end of a column/parameter mapping. */
final case class ColumnMeta(jdbcType: JdbcType, vendorTypeName: String, nullability: Nullability, name: String)
object ColumnMeta {
def apply(jdbcType: JdbcType, vendorTypeName: String, nullability: Nullability, name: String): ColumnMeta = {
new ColumnMeta(tweakJdbcType(jdbcType, vendorTypeName), vendorTypeName, nullability, name)
}
}

/** Metadata for the JDBC end of a column/parameter mapping. */
final case class ParameterMeta(jdbcType: JdbcType, vendorTypeName: String, nullability: Nullability, mode: ParameterMode)
object ParameterMeta {
def apply(jdbcType: JdbcType, vendorTypeName: String, nullability: Nullability, mode: ParameterMode): ParameterMeta = {
new ParameterMeta(tweakJdbcType(jdbcType, vendorTypeName), vendorTypeName, nullability, mode)
}
}

private def tweakJdbcType(jdbcType: JdbcType, vendorTypeName: String) = jdbcType match {
// the Postgres driver does not return *WithTimezone types but they are pretty much required for proper analysis
// https://github.com/pgjdbc/pgjdbc/issues/2485
// https://github.com/pgjdbc/pgjdbc/issues/1766
case JdbcType.Time if vendorTypeName.compareToIgnoreCase("timetz") == 0 => JdbcType.TimeWithTimezone
case JdbcType.Timestamp if vendorTypeName.compareToIgnoreCase("timestamptz") == 0 => JdbcType.TimestampWithTimezone
case t => t
}

sealed trait AlignmentError extends Product with Serializable {
def tag: String
Expand Down Expand Up @@ -122,24 +103,33 @@ object analysis {

/** Compatibility analysis for the given statement and aligned mappings. */
final case class Analysis(
driver: String,
sql: String,
parameterAlignment: List[(Put[_], NullabilityKnown) Ior ParameterMeta],
columnAlignment: List[(Get[_], NullabilityKnown) Ior ColumnMeta]) {
columnAlignment: List[(Get[_], NullabilityKnown) Ior ColumnMeta]
) {

private val parameterAlignment_ = parameterAlignment.map(_.map { m =>
m.copy(jdbcType = tweakMetaJdbcType(driver, m.jdbcType, vendorTypeName = m.vendorTypeName))
})
private val columnAlignment_ = columnAlignment.map(_.map { m =>
m.copy(jdbcType = tweakMetaJdbcType(driver, m.jdbcType, vendorTypeName = m.vendorTypeName))
})

def parameterMisalignments: List[ParameterMisalignment] =
parameterAlignment.zipWithIndex.collect {
parameterAlignment_.zipWithIndex.collect {
case (Ior.Left(_), n) => ParameterMisalignment(n + 1, None)
case (Ior.Right(p), n) => ParameterMisalignment(n + 1, Some(p))
}

def parameterTypeErrors: List[ParameterTypeError] =
parameterAlignment.zipWithIndex.collect {
parameterAlignment_.zipWithIndex.collect {
case (Ior.Both((j, n1), p), n) if !j.jdbcTargets.contains_(p.jdbcType) =>
ParameterTypeError(n + 1, j, n1, p.jdbcType, p.vendorTypeName)
}

def columnMisalignments: List[ColumnMisalignment] =
columnAlignment.zipWithIndex.collect {
columnAlignment_.zipWithIndex.collect {
case (Ior.Left(j), n) => ColumnMisalignment(n + 1, Left(j))
case (Ior.Right(p), n) => ColumnMisalignment(n + 1, Right(p))
}
Expand All @@ -159,7 +149,7 @@ object analysis {
}

def nullabilityMisalignments: List[NullabilityMisalignment] =
columnAlignment.zipWithIndex.collect {
columnAlignment_.zipWithIndex.collect {
// We can't do anything helpful with NoNulls .. it means "might not be nullable"
// case (Ior.Both((st, Nullable), ColumnMeta(_, _, NoNulls, col)), n) => NullabilityMisalignment(n + 1, col, st, NoNulls, Nullable)
case (Ior.Both((st, NoNulls), ColumnMeta(_, _, Nullable, col)), n) => NullabilityMisalignment(n + 1, col, st.typeStack.last, Nullable, NoNulls)
Expand All @@ -179,7 +169,7 @@ object analysis {
/** Description of each parameter, paired with its errors. */
lazy val paramDescriptions: List[(String, List[AlignmentError])] = {
val params: Block =
parameterAlignment.zipWithIndex.map {
parameterAlignment_.zipWithIndex.map {
case (Ior.Both((j1, n1), ParameterMeta(j2, s2, _, _)), i) => List(f"P${i+1}%02d", show"${typeName(j1.typeStack.last, n1)}", " → ", j2.show.toUpperCase, show"($s2)")
case (Ior.Left((j1, n1)), i) => List(f"P${i+1}%02d", show"${typeName(j1.typeStack.last, n1)}", " → ", "", "")
case (Ior.Right( ParameterMeta(j2, s2, _, _)), i) => List(f"P${i+1}%02d", "", " → ", j2.show.toUpperCase, show"($s2)")
Expand All @@ -193,7 +183,7 @@ object analysis {
lazy val columnDescriptions: List[(String, List[AlignmentError])] = {
import pretty._
val cols: Block =
columnAlignment.zipWithIndex.map {
columnAlignment_.zipWithIndex.map {
case (Ior.Both((j1, n1), ColumnMeta(j2, s2, n2, m)), i) => List(f"C${i+1}%02d", m, j2.show.toUpperCase, show"(${s2.toString})", formatNullability(n2), " → ", typeName(j1.typeStack.last, n1))
case (Ior.Left((j1, n1)), i) => List(f"C${i+1}%02d", "", "", "", "", " → ", typeName(j1.typeStack.last, n1))
case (Ior.Right( ColumnMeta(j2, s2, n2, m)), i) => List(f"C${i+1}%02d", m, j2.show.toUpperCase, show"(${s2.toString})", formatNullability(n2), " → ", "")
Expand Down Expand Up @@ -225,5 +215,20 @@ object analysis {
case NullableUnknown => "NULL?"
}

private val MySQLDriverName = "MySQL Connector/J"

// tweaks to the types returned by JDBC to improve analysis
private def tweakMetaJdbcType(driver: String, jdbcType: JdbcType, vendorTypeName: String) = jdbcType match {
// the Postgres driver does not return *WithTimezone JDBC types for *tz column types
// https://github.com/pgjdbc/pgjdbc/issues/2485
// https://github.com/pgjdbc/pgjdbc/issues/1766
case JdbcType.Time if vendorTypeName.compareToIgnoreCase("timetz") == 0 => JdbcType.TimeWithTimezone
case JdbcType.Timestamp if vendorTypeName.compareToIgnoreCase("timestamptz") == 0 => JdbcType.TimestampWithTimezone

// MySQL timestamp columns are returned as Timestamp
case JdbcType.Timestamp
if vendorTypeName.compareToIgnoreCase("timestamp") == 0 && driver == MySQLDriverName => JdbcType.TimestampWithTimezone

case t => t
}
}
64 changes: 64 additions & 0 deletions modules/mysql/src/main/scala/doobie/mysql/JavaTimeInstances.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// 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 => JT}
import doobie.util.meta.MetaConstructors

import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.OffsetDateTime
import java.time.OffsetTime
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]].
*/
trait JavaTimeInstances extends MetaConstructors {

implicit val JavaTimeOffsetDateTimeMeta: Meta[OffsetDateTime] =
Basic.oneObject(
JT.TimestampWithTimezone,
List(JT.VarChar, JT.Date, JT.Time, JT.Timestamp),
classOf[OffsetDateTime]
)

implicit val JavaTimeInstantMeta: Meta[Instant] =
JavaTimeOffsetDateTimeMeta.timap(_.toInstant)(OffsetDateTime.ofInstant(_, ZoneOffset.UTC))

implicit val JavaTimeLocalDateTimeMeta: Meta[LocalDateTime] =
Basic.oneObject(
JT.Timestamp,
Copy link
Contributor

Choose a reason for hiding this comment

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

MySQL/MariaDB has only TIMESTAMP which behaves as if WITH TIME ZONE, right? It is the equivalent of Java's Instant.

So that would mean that LocalDateTime is not suitable for TIMESTAMP, right?

Copy link
Contributor Author

@guymers guymers Dec 27, 2022

Choose a reason for hiding this comment

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

For MySQL a DATETIME column = JDBC Timestamp, and a TIMESTAMP column = JDBC Timestamp. I've added a hack like for Postgres to treat a TIMESTAMP column as TimestampWithTimezone and adjusted the instances.

List(JT.VarChar, JT.Date, JT.Time, JT.TimestampWithTimezone),
classOf[LocalDateTime]
)

implicit val JavaTimeLocalDateMeta: Meta[LocalDate] =
Basic.oneObject(
JT.Date,
List(JT.VarChar, JT.Time, JT.Timestamp, JT.TimestampWithTimezone),
classOf[LocalDate]
)

implicit val JavaTimeLocalTimeMeta: Meta[LocalTime] =
Basic.oneObject(
JT.Time,
List(JT.VarChar, JT.Date, JT.Timestamp, JT.TimestampWithTimezone),
classOf[LocalTime]
)

implicit val JavaTimeOffsetTimeMeta: Meta[OffsetTime] =
Basic.oneObject(
JT.TimestampWithTimezone,
List(JT.VarChar, JT.Date, JT.Time, JT.Timestamp),
classOf[OffsetTime]
)
}
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 JavaTimeInstances
}
Loading
Loading