Skip to content
Open
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 @@ -103,11 +103,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/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/circe/.native/target modules/generic/.native/target modules/sql-pg/jvm/target modules/sql-core/.native/target project/target
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/doobie-mariadb/target modules/sql-core/.native/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/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/circe/.native/target modules/generic/.native/target modules/sql-pg/jvm/target modules/sql-core/.native/target project/target
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/doobie-mariadb/target modules/sql-core/.native/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
74 changes: 74 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@ val kindProjectorVersion = "0.13.4"
val literallyVersion = "1.2.0"
val logbackVersion = "1.6.3"
val log4catsVersion = "2.8.0"
val mariadbDriverVersion = "3.5.9"
val mssqlDriverVersion = "13.4.0.jre11"
val munitVersion = "1.3.5"
val munitCatsEffectVersion = "2.2.0"
val munitScalaCheckVersion = "1.3.0"
val mysqlDriverVersion = "9.3.0"
val oracleDriverVersion = "23.26.3.0.0"
val postgresVersion = "42.7.13"
val skunkVersion = "1.0.0"
val sqliteDriverVersion = "3.53.2.0"
val shapeless2Version = "2.3.13"
val shapeless3Version = "3.6.0"
val sourcePosVersion = "1.2.0"
Expand Down Expand Up @@ -180,6 +183,9 @@ lazy val modules: List[CompositeProject] = List(
doobiepg,
doobieoracle,
doobiemssql,
doobiemysql,
doobiemariadb,
doobiesqlite,
skunk,
generic,
docs,
Expand Down Expand Up @@ -341,6 +347,71 @@ lazy val doobiemssql = project
)
)

lazy val doobiemysql = project
.in(file("modules/doobie-mysql"))
.enablePlugins(AutomateHeaderPlugin)
.disablePlugins(RevolverPlugin)
.dependsOn(doobiecore % "test->test;compile->compile")
.settings(commonSettings)
.settings(
name := "grackle-doobie-mysql",
Test / fork := true,
Test / parallelExecution := false,
Test / testOptions += Tests
.Setup(_ => runDocker("docker compose up -d --wait --quiet-pull mysql")),
libraryDependencies ++= Seq(
"com.mysql" % "mysql-connector-j" % mysqlDriverVersion
)
)

lazy val doobiemariadb = project
.in(file("modules/doobie-mariadb"))
.enablePlugins(AutomateHeaderPlugin)
.disablePlugins(RevolverPlugin)
.dependsOn(doobiecore % "test->test;compile->compile")
.settings(commonSettings)
.settings(
name := "grackle-doobie-mariadb",
Test / fork := true,
Test / parallelExecution := false,
Test / testOptions += Tests
.Setup(_ => runDocker("docker compose up -d --wait --quiet-pull mariadb")),
libraryDependencies ++= Seq(
"org.mariadb.jdbc" % "mariadb-java-client" % mariadbDriverVersion
)
)

lazy val doobiesqlite = project
.in(file("modules/doobie-sqlite"))
.enablePlugins(AutomateHeaderPlugin)
.disablePlugins(RevolverPlugin)
.dependsOn(doobiecore % "test->test;compile->compile")
.settings(commonSettings)
.settings(
name := "grackle-doobie-sqlite",
Test / fork := true,
Test / parallelExecution := false,
// SQLite has no docker service: unlike Oracle/MSSQL, whose containers auto-run the seed SQL
// mounted from testdata/<db>/, the test harness loads and executes testdata/sqlite/*.sql
// itself against a fresh temp database file per suite. Pass the directory as a system property
// (fork'd tests don't share the build's working directory) rather than relying on a relative
// path guess.
Test / javaOptions += s"-Dgrackle.sqlite.testdata=${(ThisBuild / baseDirectory).value / "testdata" / "sqlite"}",
// sqlite-jdbc's native cleanup on Connection#close touches JNI from what recent JDKs treat as
// a restricted context; without this the forked test JVM logs "restricted method" warnings and
// native handle teardown can throw spuriously. The flag only exists on JDK 17+ (JEP 412) -
// older JVMs, such as CI's temurin@11, refuse to start when given it (the forked JVM inherits
// the JDK sbt runs on), so it has to be supplied conditionally.
Test / javaOptions ++= {
if (sys.props("java.specification.version").toDouble >= 17)
Seq("--enable-native-access=ALL-UNNAMED")
else Nil
},
libraryDependencies ++= Seq(
"org.xerial" % "sqlite-jdbc" % sqliteDriverVersion
)
)

lazy val skunk = crossProject(JVMPlatform, JSPlatform, NativePlatform)
.crossType(CrossType.Full)
.in(file("modules/skunk"))
Expand Down Expand Up @@ -489,6 +560,9 @@ lazy val unidocs = project
doobiepg,
doobieoracle,
doobiemssql,
doobiemysql,
doobiemariadb,
doobiesqlite,
skunk.jvm,
generic.jvm
)
Expand Down
54 changes: 54 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,57 @@ services:
retries: 10
start_period: 5s
start_interval: 5s

mysql:
# Defaults to the current LTS. The backend's documented floor is MySQL 8.0.14 (the first
# release with LATERAL); override to re-verify it, e.g.
# `MYSQL_VERSION=8.0.14 sbt doobiemysql/test` - green on 8.0.14, 8.0.46 and 8.4.
image: mysql:${MYSQL_VERSION:-8.4}
# initdb.d loads fixtures through the mysql CLI whose handshake may request latin1; force
# utf8mb4 for every session so fixture content can never be double-encoded.
# (--skip-character-set-client-handshake, the older way to pin this server-side, was
# removed in MySQL 8.3+; entrypoint.sh pins the client default instead - see its header
# comment for why that can't just be a bind-mounted conf.d file.)
command: ["--character-set-server=utf8mb4"]
entrypoint: ["/bin/bash", "/container-entrypoint-initdb.d/entrypoint.sh"]
ports:
- "3306:3306"
environment:
- MYSQL_DATABASE=test
- MYSQL_USER=test
- MYSQL_PASSWORD=test
- MYSQL_ROOT_PASSWORD=test
volumes:
- ./testdata/mysql/:/docker-entrypoint-initdb.d/
- ./modules/doobie-mysql/src/test/resources/scripts/:/container-entrypoint-initdb.d/
healthcheck:
# mysqladmin ping answers during the image's temporary init server phase too; a real
# TCP query against the test schema only succeeds once init (and initdb.d) finished.
test: ["CMD-SHELL", "mysql -h127.0.0.1 -utest -ptest -e 'SELECT 1' test"]
interval: 5s
timeout: 5s
retries: 20

mariadb:
# The official image's `lts` tag always tracks MariaDB's current long-term-support
# release, so no version number needs to be pinned or re-pinned later. Override to test a
# specific version, e.g. `MARIADB_VERSION=11.4 sbt doobiemariadb/test`.
image: mariadb:${MARIADB_VERSION:-lts}
ports:
- "3307:3306"
environment:
- MARIADB_DATABASE=test
- MARIADB_USER=test
- MARIADB_PASSWORD=test
- MARIADB_ROOT_PASSWORD=test
volumes:
# Reuses the MySQL backend's fixtures unchanged - verified by inspection to be plain
# standard SQL (CREATE TABLE/INSERT, nothing MySQL-8-exclusive). Do NOT copy this to a
# testdata/mariadb/ directory; if Task 3 finds a real incompatibility, fork only the one
# file that needs to differ and note it in the report.
- ./testdata/mysql/:/docker-entrypoint-initdb.d/
healthcheck:
test: ["CMD-SHELL", "mariadb-admin ping -h127.0.0.1 -utest -ptest --silent"]
interval: 5s
timeout: 5s
retries: 20
183 changes: 183 additions & 0 deletions modules/doobie-mariadb/src/main/scala/DoobieMariaDbMapping.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA)
// Copyright (c) 2016-2025 Grackle Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package grackle.doobie.mariadb

import cats.effect.Sync
import cats.syntax.all._
import org.typelevel.doobie.Transactor

import grackle.Mapping
import grackle.Query.OrderSelection
import grackle.doobie._
import grackle.sql._

abstract class DoobieMariaDbMapping[F[_]](
val transactor: Transactor[F],
val monitor: DoobieMonitor[F]
)(
implicit val M: Sync[F]
) extends Mapping[F]
with DoobieMariaDbMappingLike[F]

/**
* MariaDB dialect. Unlike MySQL, MariaDB has no LATERAL derived tables in any released version,
* so mkLateral answers NotLateral (SqlMappingLike derives supportsLateralJoin = false from
* that, same mechanism SQLite/H2 use - see mkLateral's comment below for the consequences).
* Everything else - no NULLS FIRST/LAST (CASE-WHEN emulation, nulls-low polarity), no DISTINCT
* ON (FirstValueColumn), no ILIKE (LIKE is already case-insensitive under the default *_ci
* collations), and the LIMIT-anchored comma-form offset/limit grammar
* - is inherited unchanged from the MySQL dialect this was forked from.
*/
trait DoobieMariaDbMappingLike[F[_]] extends DoobieMappingLike[F] with SqlMappingLike[F] {
import SqlQuery.SqlSelect
import TableExpr.Laterality

// MariaDB has no charset-agnostic binary collation: the collation named here must match the
// character set of the columns it is applied to. utf8mb4 is MariaDB's default, so that is
// the default here; mappings over a legacy charset (latin1, utf8mb3) must override this,
// otherwise ordering a text column raises "COLLATION 'utf8mb4_bin' is not valid for
// CHARACTER SET ..." at query time.
def binaryCollation: String = "utf8mb4_bin"

// The default utf8mb4 collations are case/accent-insensitive; the collated rendering paths
// want code-point order, which the binary collation supplies.
def collateToFragment: Fragment = Fragments.const(s" COLLATE $binaryCollation")

def aliasDefToFragment(alias: String): Fragment =
Fragments.const(s" AS $alias")

// MariaDB's OFFSET is only legal inside a LIMIT clause, exactly like SQLite - and MariaDB
// supports the same comma form `LIMIT <offset>, <limit>`, so the same rendering trick
// applies: offsetToFragment opens the clause, limitToFragment supplies the second operand,
// and the two hooks below guarantee the pair is always complete.
def offsetToFragment(offset: Fragment): Fragment =
Fragments.const(" LIMIT ") |+| offset |+| Fragments.const(", ")

def limitToFragment(limit: Fragment): Fragment =
limit

// MariaDB has no `LIMIT -1`; its documented "no upper bound" idiom is LIMIT
// 18446744073709551615, which doesn't fit the Option[Int] the query tree carries.
// Int.MaxValue rows is unbounded for any practical purpose.
def normalizeOffsetLimit(query: SqlQuery): SqlQuery =
query match {
case s: SqlSelect if s.offset.nonEmpty && s.limit.isEmpty =>
s.copy(limit = Int.MaxValue.some)
case _ => query
}

def defaultOffsetForLimit(limit: Option[Int]): Option[Int] =
limit.as(0)

// Inverted from Postgres: under MariaDB's default *_ci collations a plain LIKE is already
// case-insensitive, so the case-SENSITIVE branch is the one needing an explicit collation.
// The case-insensitive branch still normalizes through UPPER (as Oracle/MSSQL/SQLite do)
// so it works under any column collation, not just the default.
def likeToFragment(expr: Fragment, pattern: String, caseInsensitive: Boolean): Fragment =
if (caseInsensitive) {
Fragments.const("UPPER(") |+| expr |+| Fragments.const(") LIKE ") |+|
Fragments.bind(stringEncoder, pattern.toUpperCase)
} else {
expr |+| Fragments.const(s" COLLATE $binaryCollation LIKE ") |+|
Fragments.bind(stringEncoder, pattern)
}

// MariaDB's CAST vocabulary is its own restricted set, not column type names.
def ascribedNullToFragment(codec: Codec): Fragment =
Fragments.sqlTypeName(codec) match {
case Some(name) =>
val castName = name.toUpperCase match {
case "TINYINT" | "SMALLINT" | "INTEGER" | "INT" | "BIGINT" | "BOOLEAN" | "BIT" =>
Some("SIGNED")
case "CHAR" | "VARCHAR" | "NVARCHAR" | "TEXT" | "CLOB" => Some("CHAR")
case "DECIMAL" | "NUMERIC" => Some("DECIMAL")
// CAST(NULL AS DOUBLE)/CAST(NULL AS FLOAT) both work fine on MariaDB - but an
// ascribed NULL is only ever an inference hint, not semantically required, so a bare
// NULL is the simplest safe rendering here; keeping it a non-cast is a style choice,
// not a compatibility requirement (unlike the JSON case just below).
case "FLOAT" | "REAL" | "DOUBLE" | "DOUBLE PRECISION" => None
case "DATE" => Some("DATE")
case "TIME" => Some("TIME")
case "TIMESTAMP" | "DATETIME" => Some("DATETIME")
// DoobieMapping.sqlTypeName reports a json codec's vendor type name as "JSONB".
// MariaDB's JSON is a LONGTEXT alias, not a native CAST target -
// CAST(NULL AS JSON) fails with "ERROR 1064 (42000): You have an error in your SQL
// syntax". A bare NULL is the safe rendering; as above, the ascription is only ever
// an inference hint, never semantically required.
case "JSON" | "JSONB" => None
case _ => None
}
castName match {
case Some(cn) => Fragments.const(s"CAST(NULL AS $cn)")
case None => Fragments.const("NULL")
}
case None => Fragments.const("NULL")
}

def collateSelected: Boolean = false

// No DISTINCT ON - plain DISTINCT plus the FirstValueColumn window strategy, as MSSQL.
def distinctOnToFragment(dcols: List[Fragment]): Fragment =
Fragments.const("DISTINCT ")

def distinctOrderColumn(
owner: ColumnOwner,
col: SqlColumn,
predCols: List[SqlColumn],
orders: List[OrderSelection[_]]): SqlColumn =
SqlColumn.FirstValueColumn(owner, col, predCols, orders)

// Parenthesized union branches are legal in MariaDB and may carry inline ORDER BY/LIMIT/
// OFFSET. A branch-level ORDER BY without LIMIT may be discarded by the optimizer, which is
// harmless: branch orders only determine row *selection* when paired with limit/offset (and
// normalizeOffsetLimit guarantees any offset gets a limit); pure ordering is redone by the
// outer query or in memory. So no derived-table encapsulation is needed.
def encapsulateUnionBranch(s: SqlSelect): SqlSelect = s
def unionBranchToFragment(branch: Fragment): Fragment = Fragments.parentheses(branch)

// MariaDB has no LATERAL/APPLY mechanism at all, so NotLateral (plain subquery, no keyword)
// is the only possible answer; SqlMappingLike derives supportsLateralJoin = false from it,
// which omits the parent-constraint predicate only a lateral subquery could resolve and
// gates the "Case 1" fast paths - see that member's doc comment. This is the one deliberate
// structural difference from the MySQL dialect this file was forked from (MySQL 8.0.14+ has
// native LATERAL).
def mkLateral(inner: Boolean): Laterality =
Laterality.NotLateral

// MariaDB has no NULLS FIRST/LAST. Its default is nulls-low (NULLs first in ASC, last in
// DESC), so the two cases needing correction are the mirror image of Postgres's: a
// sort-key prefix pushes NULLs to the requested end. Note the key polarity: NULL -> 1
// in BOTH branches (1 sorts last under ASC = NULLS LAST; 1 sorts first under DESC =
// NULLS FIRST). MSSQL's dialect inverts the key in its DESC branch - a known latent bug
// there, deliberately not replicated. Pinned by NullOrderingSuite.
def orderToFragment(col: Fragment, ascending: Boolean, nullsLast: Boolean): Fragment = {
val dir = if (ascending) Fragments.empty else Fragments.const(" DESC")
val nulls =
if (nullsLast && ascending)
Fragments.const("CASE WHEN ") |+| col |+|
Fragments.const(" IS NULL THEN 1 ELSE 0 END ASC, ")
else if (!nullsLast && !ascending)
Fragments.const("CASE WHEN ") |+| col |+|
Fragments.const(" IS NULL THEN 1 ELSE 0 END DESC, ")
else
Fragments.empty

nulls |+| col |+| dir
}

// MariaDB sorts NULL below any non-NULL value (NULLs first in ASC), same as MSSQL/SQLite/H2.
def nullsHigh: Boolean = false
}
Loading
Loading