Skip to content

Commit 9942895

Browse files
committed
Add MySQL backend (grackle-doobie-mysql)
1 parent 74fb76a commit 9942895

30 files changed

Lines changed: 6639 additions & 2 deletions

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,11 @@ jobs:
103103

104104
- name: Make target directories
105105
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main')
106-
run: mkdir -p modules/skunk/js/target modules/sql-core/.js/target modules/circe/.jvm/target modules/generic/.jvm/target modules/doobie-pg/target unidocs/target modules/core/.native/target modules/skunk/jvm/target modules/core/.js/target modules/doobie-core/target modules/circe/.js/target modules/skunk/native/target modules/generic/.js/target modules/doobie-oracle/target modules/sql-core/.jvm/target modules/core/.jvm/target modules/sql-pg/native/target modules/doobie-mssql/target modules/sql-pg/js/target modules/doobie-sqlite/target modules/circe/.native/target modules/generic/.native/target modules/sql-pg/jvm/target modules/sql-core/.native/target project/target
106+
run: mkdir -p modules/skunk/js/target modules/sql-core/.js/target modules/circe/.jvm/target modules/generic/.jvm/target modules/doobie-mysql/target modules/doobie-pg/target unidocs/target modules/core/.native/target modules/skunk/jvm/target modules/core/.js/target modules/doobie-core/target modules/circe/.js/target modules/skunk/native/target modules/generic/.js/target modules/doobie-oracle/target modules/sql-core/.jvm/target modules/core/.jvm/target modules/sql-pg/native/target modules/doobie-mssql/target modules/sql-pg/js/target modules/doobie-sqlite/target modules/circe/.native/target modules/generic/.native/target modules/sql-pg/jvm/target modules/sql-core/.native/target project/target
107107

108108
- name: Compress target directories
109109
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main')
110-
run: tar cf targets.tar modules/skunk/js/target modules/sql-core/.js/target modules/circe/.jvm/target modules/generic/.jvm/target modules/doobie-pg/target unidocs/target modules/core/.native/target modules/skunk/jvm/target modules/core/.js/target modules/doobie-core/target modules/circe/.js/target modules/skunk/native/target modules/generic/.js/target modules/doobie-oracle/target modules/sql-core/.jvm/target modules/core/.jvm/target modules/sql-pg/native/target modules/doobie-mssql/target modules/sql-pg/js/target modules/doobie-sqlite/target modules/circe/.native/target modules/generic/.native/target modules/sql-pg/jvm/target modules/sql-core/.native/target project/target
110+
run: tar cf targets.tar modules/skunk/js/target modules/sql-core/.js/target modules/circe/.jvm/target modules/generic/.jvm/target modules/doobie-mysql/target modules/doobie-pg/target unidocs/target modules/core/.native/target modules/skunk/jvm/target modules/core/.js/target modules/doobie-core/target modules/circe/.js/target modules/skunk/native/target modules/generic/.js/target modules/doobie-oracle/target modules/sql-core/.jvm/target modules/core/.jvm/target modules/sql-pg/native/target modules/doobie-mssql/target modules/sql-pg/js/target modules/doobie-sqlite/target modules/circe/.native/target modules/generic/.native/target modules/sql-pg/jvm/target modules/sql-core/.native/target project/target
111111

112112
- name: Upload target directories
113113
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main')

‎build.sbt‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ val mssqlDriverVersion = "13.4.0.jre11"
2121
val munitVersion = "1.3.3"
2222
val munitCatsEffectVersion = "2.2.0"
2323
val munitScalaCheckVersion = "1.3.0"
24+
val mysqlDriverVersion = "9.3.0"
2425
val oracleDriverVersion = "23.26.2.0.0"
2526
val postgresVersion = "42.7.13"
2627
val skunkVersion = "1.0.0"
@@ -188,6 +189,7 @@ lazy val modules: List[CompositeProject] = List(
188189
doobiepg,
189190
doobieoracle,
190191
doobiemssql,
192+
doobiemysql,
191193
doobiesqlite,
192194
skunk,
193195
generic,
@@ -351,6 +353,24 @@ lazy val doobiemssql = project
351353
)
352354
)
353355

356+
lazy val doobiemysql = project
357+
.in(file("modules/doobie-mysql"))
358+
.enablePlugins(AutomateHeaderPlugin)
359+
.disablePlugins(RevolverPlugin)
360+
.dependsOn(doobiecore % "test->test;compile->compile")
361+
.settings(commonSettings)
362+
.settings(utcTestSettings)
363+
.settings(
364+
name := "grackle-doobie-mysql",
365+
Test / fork := true,
366+
Test / parallelExecution := false,
367+
Test / testOptions += Tests
368+
.Setup(_ => runDocker("docker compose up -d --wait --quiet-pull mysql")),
369+
libraryDependencies ++= Seq(
370+
"com.mysql" % "mysql-connector-j" % mysqlDriverVersion
371+
)
372+
)
373+
354374
lazy val doobiesqlite = project
355375
.in(file("modules/doobie-sqlite"))
356376
.enablePlugins(AutomateHeaderPlugin)
@@ -532,6 +552,7 @@ lazy val unidocs = project
532552
doobiepg,
533553
doobieoracle,
534554
doobiemssql,
555+
doobiemysql,
535556
doobiesqlite,
536557
skunk.jvm,
537558
generic.jvm

‎docker-compose.yml‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,33 @@ services:
5252
retries: 10
5353
start_period: 5s
5454
start_interval: 5s
55+
56+
mysql:
57+
# Defaults to the current LTS. The backend's documented floor is MySQL 8.0.14 (the first
58+
# release with LATERAL); override to re-verify it, e.g.
59+
# `MYSQL_VERSION=8.0.14 sbt doobiemysql/test` - green on 8.0.14, 8.0.46 and 8.4.
60+
image: mysql:${MYSQL_VERSION:-8.4}
61+
# initdb.d loads fixtures through the mysql CLI whose handshake may request latin1; force
62+
# utf8mb4 for every session so fixture content can never be double-encoded.
63+
# (--skip-character-set-client-handshake, the older way to pin this server-side, was
64+
# removed in MySQL 8.3+; entrypoint.sh pins the client default instead - see its header
65+
# comment for why that can't just be a bind-mounted conf.d file.)
66+
command: ["--character-set-server=utf8mb4"]
67+
entrypoint: ["/bin/bash", "/container-entrypoint-initdb.d/entrypoint.sh"]
68+
ports:
69+
- "3306:3306"
70+
environment:
71+
- MYSQL_DATABASE=test
72+
- MYSQL_USER=test
73+
- MYSQL_PASSWORD=test
74+
- MYSQL_ROOT_PASSWORD=test
75+
volumes:
76+
- ./testdata/mysql/:/docker-entrypoint-initdb.d/
77+
- ./modules/doobie-mysql/src/test/resources/scripts/:/container-entrypoint-initdb.d/
78+
healthcheck:
79+
# mysqladmin ping answers during the image's temporary init server phase too; a real
80+
# TCP query against the test schema only succeeds once init (and initdb.d) finished.
81+
test: ["CMD-SHELL", "mysql -h127.0.0.1 -utest -ptest -e 'SELECT 1' test"]
82+
interval: 5s
83+
timeout: 5s
84+
retries: 20
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA)
2+
// Copyright (c) 2016-2025 Grackle Contributors
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package grackle.doobie.mysql
17+
18+
import cats.effect.Sync
19+
import cats.syntax.all._
20+
import org.typelevel.doobie.Transactor
21+
22+
import grackle.Mapping
23+
import grackle.Query.OrderSelection
24+
import grackle.doobie._
25+
import grackle.sql._
26+
27+
abstract class DoobieMySqlMapping[F[_]](
28+
val transactor: Transactor[F],
29+
val monitor: DoobieMonitor[F]
30+
)(
31+
implicit val M: Sync[F]
32+
) extends Mapping[F]
33+
with DoobieMySqlMappingLike[F]
34+
35+
/**
36+
* MySQL 8.0.14+ dialect. LATERAL derived tables (8.0.14) and window functions (8.0) are native,
37+
* so the two deep structural hooks are pg-like; the differences are all local rendering: no
38+
* NULLS FIRST/LAST (CASE-WHEN emulation, nulls-low polarity), no DISTINCT ON (FirstValueColumn,
39+
* as MSSQL), no ILIKE (LIKE is already case-insensitive under the default *_ci collations -
40+
* it's the case-SENSITIVE branch that needs help), and a LIMIT-anchored offset/limit grammar
41+
* identical to SQLite's comma form, handled the same way.
42+
*/
43+
trait DoobieMySqlMappingLike[F[_]] extends DoobieMappingLike[F] with SqlMappingLike[F] {
44+
import SqlQuery.SqlSelect
45+
import TableExpr.Laterality
46+
47+
// MySQL has no charset-agnostic binary collation: the collation named here must match the
48+
// character set of the columns it is applied to. utf8mb4 is MySQL 8's default, so that is
49+
// the default here; mappings over a legacy charset (latin1, utf8mb3) must override this,
50+
// otherwise ordering a text column raises "COLLATION 'utf8mb4_bin' is not valid for
51+
// CHARACTER SET ..." at query time.
52+
def binaryCollation: String = "utf8mb4_bin"
53+
54+
// The default utf8mb4 collations are case/accent-insensitive; the collated rendering paths
55+
// want code-point order, which the binary collation supplies.
56+
def collateToFragment: Fragment = Fragments.const(s" COLLATE $binaryCollation")
57+
58+
def aliasDefToFragment(alias: String): Fragment =
59+
Fragments.const(s" AS $alias")
60+
61+
// MySQL's OFFSET is only legal inside a LIMIT clause, exactly like SQLite - and MySQL
62+
// supports the same comma form `LIMIT <offset>, <limit>`, so the same rendering trick
63+
// applies: offsetToFragment opens the clause, limitToFragment supplies the second operand,
64+
// and the two hooks below guarantee the pair is always complete.
65+
def offsetToFragment(offset: Fragment): Fragment =
66+
Fragments.const(" LIMIT ") |+| offset |+| Fragments.const(", ")
67+
68+
def limitToFragment(limit: Fragment): Fragment =
69+
limit
70+
71+
// MySQL has no `LIMIT -1`; its documented "no upper bound" idiom is LIMIT
72+
// 18446744073709551615, which doesn't fit the Option[Int] the query tree carries.
73+
// Int.MaxValue rows is unbounded for any practical purpose.
74+
def normalizeOffsetLimit(query: SqlQuery): SqlQuery =
75+
query match {
76+
case s: SqlSelect if s.offset.nonEmpty && s.limit.isEmpty =>
77+
s.copy(limit = Int.MaxValue.some)
78+
case _ => query
79+
}
80+
81+
def defaultOffsetForLimit(limit: Option[Int]): Option[Int] =
82+
limit.as(0)
83+
84+
// Inverted from Postgres: under MySQL's default *_ci collations a plain LIKE is already
85+
// case-insensitive, so the case-SENSITIVE branch is the one needing an explicit collation.
86+
// The case-insensitive branch still normalizes through UPPER (as Oracle/MSSQL/SQLite do)
87+
// so it works under any column collation, not just the default.
88+
def likeToFragment(expr: Fragment, pattern: String, caseInsensitive: Boolean): Fragment =
89+
if (caseInsensitive) {
90+
Fragments.const("UPPER(") |+| expr |+| Fragments.const(") LIKE ") |+|
91+
Fragments.bind(stringEncoder, pattern.toUpperCase)
92+
} else {
93+
expr |+| Fragments.const(s" COLLATE $binaryCollation LIKE ") |+|
94+
Fragments.bind(stringEncoder, pattern)
95+
}
96+
97+
// MySQL's CAST vocabulary is its own restricted set, not column type names.
98+
def ascribedNullToFragment(codec: Codec): Fragment =
99+
Fragments.sqlTypeName(codec) match {
100+
case Some(name) =>
101+
val castName = name.toUpperCase match {
102+
case "TINYINT" | "SMALLINT" | "INTEGER" | "INT" | "BIGINT" | "BOOLEAN" | "BIT" =>
103+
Some("SIGNED")
104+
case "CHAR" | "VARCHAR" | "NVARCHAR" | "TEXT" | "CLOB" => Some("CHAR")
105+
case "DECIMAL" | "NUMERIC" => Some("DECIMAL")
106+
// CAST(... AS DOUBLE/FLOAT) only exists since MySQL 8.0.17, above this dialect's
107+
// 8.0.14 floor - a bare NULL is legal everywhere and float-typed ascription is
108+
// only an inference hint, so it is the safe rendering here.
109+
case "FLOAT" | "REAL" | "DOUBLE" | "DOUBLE PRECISION" => None
110+
case "DATE" => Some("DATE")
111+
case "TIME" => Some("TIME")
112+
case "TIMESTAMP" | "DATETIME" => Some("DATETIME")
113+
// DoobieMapping.sqlTypeName reports a json codec's vendor type name as "JSONB".
114+
case "JSON" | "JSONB" => Some("JSON")
115+
case _ => None
116+
}
117+
castName match {
118+
case Some(cn) => Fragments.const(s"CAST(NULL AS $cn)")
119+
case None => Fragments.const("NULL")
120+
}
121+
case None => Fragments.const("NULL")
122+
}
123+
124+
def collateSelected: Boolean = false
125+
126+
// No DISTINCT ON - plain DISTINCT plus the FirstValueColumn window strategy, as MSSQL.
127+
def distinctOnToFragment(dcols: List[Fragment]): Fragment =
128+
Fragments.const("DISTINCT ")
129+
130+
def distinctOrderColumn(
131+
owner: ColumnOwner,
132+
col: SqlColumn,
133+
predCols: List[SqlColumn],
134+
orders: List[OrderSelection[_]]): SqlColumn =
135+
SqlColumn.FirstValueColumn(owner, col, predCols, orders)
136+
137+
// Parenthesized union branches are legal in MySQL and may carry inline ORDER BY/LIMIT/
138+
// OFFSET. A branch-level ORDER BY without LIMIT may be discarded by the optimizer, which is
139+
// harmless: branch orders only determine row *selection* when paired with limit/offset (and
140+
// normalizeOffsetLimit guarantees any offset gets a limit); pure ordering is redone by the
141+
// outer query or in memory. So no derived-table encapsulation is needed.
142+
def encapsulateUnionBranch(s: SqlSelect): SqlSelect = s
143+
def unionBranchToFragment(branch: Fragment): Fragment = Fragments.parentheses(branch)
144+
145+
// Native LATERAL derived tables, as Oracle - this is what sets the 8.0.14 version floor.
146+
def mkLateral(inner: Boolean): Laterality = Laterality.Lateral
147+
148+
// MySQL has no NULLS FIRST/LAST. Its default is nulls-low (NULLs first in ASC, last in
149+
// DESC), so the two cases needing correction are the mirror image of Postgres's: a
150+
// sort-key prefix pushes NULLs to the requested end. Note the key polarity: NULL -> 1
151+
// in BOTH branches (1 sorts last under ASC = NULLS LAST; 1 sorts first under DESC =
152+
// NULLS FIRST). MSSQL's dialect inverts the key in its DESC branch - a known latent bug
153+
// there, deliberately not replicated. Pinned by NullOrderingSuite.
154+
def orderToFragment(col: Fragment, ascending: Boolean, nullsLast: Boolean): Fragment = {
155+
val dir = if (ascending) Fragments.empty else Fragments.const(" DESC")
156+
val nulls =
157+
if (nullsLast && ascending)
158+
Fragments.const("CASE WHEN ") |+| col |+|
159+
Fragments.const(" IS NULL THEN 1 ELSE 0 END ASC, ")
160+
else if (!nullsLast && !ascending)
161+
Fragments.const("CASE WHEN ") |+| col |+|
162+
Fragments.const(" IS NULL THEN 1 ELSE 0 END DESC, ")
163+
else
164+
Fragments.empty
165+
166+
nulls |+| col |+| dir
167+
}
168+
169+
// MySQL sorts NULL below any non-NULL value (NULLs first in ASC), same as MSSQL/SQLite/H2.
170+
def nullsHigh: Boolean = false
171+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#!/bin/bash
2+
set -e
3+
4+
# The mysql CLI - used both by docker-entrypoint-initdb.d to load the fixtures under
5+
# testdata/mysql/, and by any ad-hoc `docker compose exec ... mysql` - picks its client-side
6+
# charset from its own compiled-in default (historically latin1) unless a config file says
7+
# otherwise; the server's --character-set-server setting has no effect on this. MySQL 8.3+
8+
# also removed --skip-character-set-client-handshake, the old server-side override.
9+
#
10+
# A bind-mounted conf.d file won't do it: mysql refuses to read "world-writable" option
11+
# files, and every file bind-mounted from the host in this dev environment shows up 0777
12+
# (WSL/DrvFS forces this regardless of the file's real permissions), so mysql would silently
13+
# ignore it. Writing the file fresh inside the container's own filesystem, with a mode mysql
14+
# will trust, sidesteps that entirely.
15+
install -d -m 0755 /etc/mysql/conf.d
16+
cat > /etc/mysql/conf.d/charset.cnf <<'EOF'
17+
[client]
18+
default-character-set=utf8mb4
19+
EOF
20+
chmod 0644 /etc/mysql/conf.d/charset.cnf
21+
22+
exec /usr/local/bin/docker-entrypoint.sh "$@"
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA)
2+
// Copyright (c) 2016-2025 Grackle Contributors
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package grackle.doobie.mysql.test
17+
18+
import java.sql.{Time, Timestamp}
19+
import java.time.{LocalDate, LocalTime, OffsetDateTime, ZoneId}
20+
import java.util.UUID
21+
22+
import scala.util.Try
23+
24+
import cats.effect.{IO, Resource, Sync}
25+
import cats.syntax.all._
26+
import io.circe.{Decoder => CDecoder, Encoder => CEncoder, Json}
27+
import io.circe.parser.parse
28+
import io.circe.syntax._
29+
import munit.catseffect._
30+
import org.typelevel.doobie.{Meta, Transactor}
31+
import org.typelevel.doobie.enumerated.JdbcType
32+
import org.typelevel.doobie.util.meta.MetaConstructors.Basic
33+
34+
import grackle.doobie.DoobieMonitor
35+
import grackle.doobie.mysql.DoobieMySqlMapping
36+
import grackle.doobie.test.DoobieDatabaseSuite
37+
import grackle.sql.test._
38+
39+
trait DoobieMySqlDatabaseSuite extends DoobieDatabaseSuite {
40+
abstract class DoobieMySqlTestMapping[F[_]: Sync](
41+
transactor: Transactor[F],
42+
monitor: DoobieMonitor[F] = DoobieMonitor.noopMonitor[IO])
43+
extends DoobieMySqlMapping[F](transactor, monitor)
44+
with DoobieTestMapping[F]
45+
with SqlTestMapping[F] {
46+
def mkTestCodec[T](meta: Meta[T]): TestCodec[T] = (meta, false)
47+
48+
val uuid: TestCodec[UUID] =
49+
mkTestCodec(Meta[String].tiemap(s =>
50+
Try(UUID.fromString(s)).toEither.leftMap(_.getMessage))(_.toString))
51+
52+
val localTime: TestCodec[LocalTime] =
53+
mkTestCodec(Meta[Time].timap(t => LocalTime.ofNanoOfDay(t.toLocalTime.toNanoOfDay))(lt =>
54+
Time.valueOf(lt)))
55+
56+
val localDate: TestCodec[LocalDate] =
57+
(Basic.oneObject(JdbcType.Date, None, classOf[LocalDate]), false)
58+
59+
// DATETIME(6) columns hold UTC instants (fixtures pre-converted); decode as UTC.
60+
val offsetDateTime: TestCodec[OffsetDateTime] =
61+
mkTestCodec(
62+
Meta[Timestamp].timap(t => OffsetDateTime.ofInstant(t.toInstant, ZoneId.of("UTC")))(o =>
63+
Timestamp.from(o.toInstant)))
64+
65+
val nvarchar: TestCodec[String] = mkTestCodec(Meta[String])
66+
67+
// JSON columns read/write cleanly as strings through the connector.
68+
val jsonb: TestCodec[Json] =
69+
mkTestCodec(Meta[String].tiemap(s => parse(s).leftMap(_.getMessage))(_.noSpaces))
70+
71+
// No native arrays - JSON-encode into JSON columns, same codec shape as MSSQL/SQLite.
72+
override def list[T: CDecoder: CEncoder](c: TestCodec[T]): TestCodec[List[T]] = {
73+
def put(ts: List[T]): String = ts.asJson.noSpaces
74+
def get(s: String): Either[String, List[T]] =
75+
parse(s).map(_.as[List[T]].toOption.get).leftMap(_.getMessage)
76+
77+
mkTestCodec(Meta[String].tiemap(get)(put))
78+
}
79+
}
80+
81+
def transactorResource: Resource[IO, Transactor[IO]] =
82+
Resource.pure(
83+
Transactor.fromDriverManager[IO](
84+
"com.mysql.cj.jdbc.Driver",
85+
// connectionTimeZone pins the session to the same UTC the forked test JVM runs in;
86+
// allowPublicKeyRetrieval is required for caching_sha2_password auth without SSL.
87+
// No charset params: Connector/J 8+ already defaults the connection charset to
88+
// utf8mb4, and fixture bytes are stored correctly (see this module's entrypoint.sh),
89+
// verified by ablation - the suites stay green and byte-correct without them.
90+
"jdbc:mysql://localhost:3306/test?connectionTimeZone=UTC&sslMode=DISABLED&allowPublicKeyRetrieval=true",
91+
"test",
92+
"test",
93+
None
94+
)
95+
)
96+
97+
val transactorFixture: IOFixture[Transactor[IO]] =
98+
ResourceSuiteLocalFixture("doobiemysql", transactorResource)
99+
override def munitFixtures: Seq[IOFixture[_]] = Seq(transactorFixture)
100+
101+
def transactor: Transactor[IO] = transactorFixture()
102+
}

0 commit comments

Comments
 (0)