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-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/doobie-h2/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

- 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-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/doobie-h2/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

- name: Upload target directories
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main')
Expand Down
57 changes: 57 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ val munitScalaCheckVersion = "1.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 h2DriverVersion = "2.4.240"
val shapeless2Version = "2.3.13"
val shapeless3Version = "3.6.0"
val sourcePosVersion = "1.2.0"
Expand Down Expand Up @@ -180,6 +182,8 @@ lazy val modules: List[CompositeProject] = List(
doobiepg,
doobieoracle,
doobiemssql,
doobiesqlite,
doobieh2,
skunk,
generic,
docs,
Expand Down Expand Up @@ -341,6 +345,57 @@ lazy val doobiemssql = project
)
)

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 doobieh2 = project
.in(file("modules/doobie-h2"))
.enablePlugins(AutomateHeaderPlugin)
.disablePlugins(RevolverPlugin)
.dependsOn(doobiecore % "test->test;compile->compile")
.settings(commonSettings)
.settings(
name := "grackle-doobie-h2",
Test / fork := true,
Test / parallelExecution := false,
// H2 has no docker service: the test harness seeds a fresh in-memory database per suite
// from testdata/h2/*.sql. Pass the directory as a system property (fork'd tests don't share
// the build's working directory).
Test / javaOptions += s"-Dgrackle.h2.testdata=${(ThisBuild / baseDirectory).value / "testdata" / "h2"}",
libraryDependencies ++= Seq(
"org.typelevel" %% "doobie-h2" % doobieVersion,
"com.h2database" % "h2" % h2DriverVersion
)
)

lazy val skunk = crossProject(JVMPlatform, JSPlatform, NativePlatform)
.crossType(CrossType.Full)
.in(file("modules/skunk"))
Expand Down Expand Up @@ -489,6 +544,8 @@ lazy val unidocs = project
doobiepg,
doobieoracle,
doobiemssql,
doobiesqlite,
doobieh2,
skunk.jvm,
generic.jvm
)
Expand Down
123 changes: 123 additions & 0 deletions modules/doobie-h2/src/main/scala/DoobieH2Mapping.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// 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.h2

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 DoobieH2Mapping[F[_]](
val transactor: Transactor[F],
val monitor: DoobieMonitor[F]
)(
implicit val M: Sync[F]
) extends Mapping[F]
with DoobieH2MappingLike[F]

/**
* H2 (REGULAR mode) is close to Postgres for the constructs the shared query builder needs -
* ILIKE, DISTINCT ON, NULLS FIRST/LAST and parenthesized union branches are all native - with
* three exceptions: offset/limit render as standard OFFSET .. ROWS / FETCH NEXT .. ROWS ONLY
* (as Oracle); there is no LATERAL join, which `mkLateral` answers with `NotLateral` exactly as
* the SQLite backend does (see `supportsLateralJoin`'s doc comment for the consequences); and
* although modern H2 supports `NULLS FIRST`/`NULLS LAST` natively, unlike Postgres/Oracle H2's
* default places NULLs low (first in ASC, last in DESC), so `orderToFragment` emits the
* explicit clause on the mirror-image cases relative to the pg dialect - the same polarity
* correction the MSSQL dialect makes.
*/
trait DoobieH2MappingLike[F[_]] extends DoobieMappingLike[F] with SqlMappingLike[F] {
import SqlQuery.SqlSelect
import TableExpr.Laterality

// H2 has no per-expression COLLATE, and its default ordering is already code-point order -
// the very thing the other dialects' COLLATE "C"/BINARY opt into - so nothing needs to be
// emitted on the rare collated-rendering paths either.
def collateToFragment: Fragment = Fragments.empty

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

// Standard SQL OFFSET/FETCH, as Oracle renders it. In H2 each clause is independently legal,
// in the offset-then-limit order the shared builder renders, with or without ORDER BY - so no
// offset/limit normalization is needed (normalizeOffsetLimit below is the identity).
def offsetToFragment(offset: Fragment): Fragment =
Fragments.const(" OFFSET ") |+| offset |+| Fragments.const(" ROWS")

def limitToFragment(limit: Fragment): Fragment =
Fragments.const(" FETCH NEXT ") |+| limit |+| Fragments.const(" ROWS ONLY")

// H2 supports ILIKE natively in REGULAR mode, same as Postgres.
def likeToFragment(expr: Fragment, pattern: String, caseInsensitive: Boolean): Fragment = {
val op = if (caseInsensitive) "ILIKE" else "LIKE"
expr |+| Fragments.const(s" $op ") |+| Fragments.bind(stringEncoder, pattern)
}

// H2's CAST accepts any type name its driver reports.
def ascribedNullToFragment(codec: Codec): Fragment =
Fragments.sqlTypeName(codec) match {
case Some(name) => Fragments.const(s"CAST(NULL AS $name)")
case None => Fragments.const("NULL")
}

def collateSelected: Boolean = false

// H2 supports DISTINCT ON with Postgres semantics (first row per group under ORDER BY).
def distinctOnToFragment(dcols: List[Fragment]): Fragment =
Fragments.const("DISTINCT ON ") |+| Fragments.parentheses(
dcols.intercalate(Fragments.const(", ")))

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

// A parenthesized compound-select branch may carry its own ORDER BY/OFFSET/FETCH inline (an
// unparenthesized one may not - the parentheses supplied by unionBranchToFragment are
// load-bearing), so no derived-table wrapping is needed.
def encapsulateUnionBranch(s: SqlSelect): SqlSelect = s
def unionBranchToFragment(branch: Fragment): Fragment = Fragments.parentheses(branch)

// H2 has no LATERAL (or APPLY) mechanism, so NotLateral (plain subquery, no keyword) is the
// only possible rendering; SqlMappingLike derives supportsLateralJoin = false from it - see
// that member's doc comment.
def mkLateral(inner: Boolean): Laterality = Laterality.NotLateral

def normalizeOffsetLimit(query: SqlQuery): SqlQuery = query
def defaultOffsetForLimit(limit: Option[Int]): Option[Int] = None

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(" NULLS LAST ")
else if (!nullsLast && !ascending)
Fragments.const(" NULLS FIRST ")
else
Fragments.empty

col |+| dir |+| nulls
}

// H2 sorts NULL below any non-NULL value by default (NULLs first in ASC), the same convention
// as MSSQL and SQLite.
def nullsHigh: Boolean = false
}
170 changes: 170 additions & 0 deletions modules/doobie-h2/src/test/scala/DoobieH2DatabaseSuite.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// 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.h2.test

import java.io.File
import java.nio.file.Files
import java.sql.DriverManager
import java.time.{LocalDate, LocalTime, OffsetDateTime, ZoneOffset}
import java.util.UUID

import scala.util.Using

import cats.data.NonEmptyList
import cats.effect.{IO, Resource, Sync}
import cats.syntax.all._
import io.circe.{Decoder => CDecoder, Encoder => CEncoder, Json}
import io.circe.parser.parse
import munit.catseffect._
import org.typelevel.doobie.{Get, Meta, Put, Transactor}
import org.typelevel.doobie.enumerated.JdbcType
// H2's own implicits provide Meta instances for java.time types (JavaLocalTimeMeta etc., from
// H2JavaTimeMetaInstances); importing org.typelevel.doobie.implicits.javatimedrivernative._
// alongside this binds the same simple names via a second wildcard import, which makes them
// ambiguous by name and drops them from implicit scope entirely (a silent "not found" rather
// than an "ambiguous implicit" error) - so only the h2-specific import is kept.
import org.typelevel.doobie.h2.implicits._

import grackle.doobie.DoobieMonitor
import grackle.doobie.h2.DoobieH2Mapping
import grackle.doobie.test.DoobieDatabaseSuite
import grackle.sql.test._

trait DoobieH2DatabaseSuite extends DoobieDatabaseSuite {
abstract class DoobieH2TestMapping[F[_]: Sync](
transactor: Transactor[F],
monitor: DoobieMonitor[F] = DoobieMonitor.noopMonitor[IO])
extends DoobieH2Mapping[F](transactor, monitor)
with DoobieTestMapping[F]
with SqlTestMapping[F] {
def mkTestCodec[T](meta: Meta[T]): TestCodec[T] = (meta, false)

val uuid: TestCodec[UUID] = mkTestCodec(Meta[UUID])
val localTime: TestCodec[LocalTime] = mkTestCodec(Meta[LocalTime])
val localDate: TestCodec[LocalDate] = mkTestCodec(Meta[LocalDate])

// H2 preserves whatever offset the stored literal carried (unlike Postgres, whose driver
// hands back UTC-normalized values); the shared expected-JSON fixtures are written in
// Postgres's UTC ("Z") form, so normalize on decode.
val offsetDateTime: TestCodec[OffsetDateTime] =
mkTestCodec(
Meta[OffsetDateTime].timap(_.withOffsetSameInstant(ZoneOffset.UTC))(odt => odt))

val nvarchar: TestCodec[String] = mkTestCodec(Meta[String])

// H2's JSON type has no useful JDBC mapping - store JSON text in VARCHAR, as MSSQL does.
val jsonb: TestCodec[Json] =
mkTestCodec(Meta[String].tiemap(s => parse(s).leftMap(_.getMessage))(_.noSpaces))

// Native VARCHAR ARRAY columns, read via rs.getArray(n).getArray() and written via
// connection.createArrayOf + ps.setArray (Put.Advanced.array - the write half of the same
// constructor DoobieTestMapping's inherited default list codec uses for Postgres's
// "_VARCHAR"), both confirmed working against H2 2.4.240 by a standalone JDBC probe.
//
// The read half is hand-rolled rather than reused from Get.Advanced.array/Meta.Advanced.array:
// that helper does `rs.getArray(n).getArray().asInstanceOf[Array[A]]`, a whole-array cast that
// relies on the driver handing back an array reified as the element type (works for Postgres).
// H2 hands back a reified Object[] regardless of the declared element type - a whole-array
// cast to Array[String] then fails with ClassCastException, confirmed by triggering it here
// before switching to the element-wise cast below (`Object[]` -> map each element to String
// individually, which is safe since every element *is* a String instance at runtime, only the
// array's own reified component type is Object).
//
// NOT doobie-h2's own Meta[Array[String]] (org.typelevel.doobie.h2.implicits.
// unliftedStringArrayType) either: verified via the same JDBC probe that it is broken from the
// read side too. It's built on Meta.Advanced.other[Array[Object]], which reads via
// rs.getObject(n, classOf[Array[Object]]) - H2 rejects that conversion outright
// ("Data conversion error converting CHARACTER VARYING to JAVA_OBJECT"), even for a value it
// just wrote itself.
//
// The vendor type name matters beyond Get/Put too: DoobieMapping's sqlTypeName renders it
// verbatim into `CAST(NULL AS <name>)` for ascribed nulls, and H2 only accepts the full
// "VARCHAR ARRAY" spelling there - bare "ARRAY" (H2's own Meta's vendor name) is a syntax
// error ("expected 'data type'"), while "_VARCHAR" (the Postgres-flavoured default) is
// meaningless to H2.
private val arrayStringMeta: Meta[Array[String]] = {
val vendorTypeNames = NonEmptyList.of("VARCHAR ARRAY")
val get: Get[Array[String]] = Get
.Advanced
.one[Array[String]](
JdbcType.Array,
vendorTypeNames,
(rs, n) => {
val a = rs.getArray(n)
if (a == null) null
// A null array *element* passes through this cast silently as `null` rather than
// being rejected - fine today since no fixture uses a nullable-element list column,
// but worth revisiting if one is ever added.
else a.getArray.asInstanceOf[Array[AnyRef]].map(_.asInstanceOf[String])
}
)
val put: Put[Array[String]] = Put.Advanced.array[String](vendorTypeNames, "VARCHAR")
new Meta(get, put)
}

override def list[T: CDecoder: CEncoder](c: TestCodec[T]): TestCodec[List[T]] = {
val cm = c._1
val decode = cm.get.get.k.asInstanceOf[String => T]
val encode = cm.put.put.k.asInstanceOf[T => String]
mkTestCodec(arrayStringMeta.imap(_.toList.map(decode))(_.map(encode).toArray))
}
}

// Where the seed scripts live - see the `Test / javaOptions` setting for grackle-doobie-h2
// in build.sbt, which points this at testdata/h2/ regardless of the fork's working directory.
def testdataDir: File =
new File(
sys
.props
.getOrElse(
"grackle.h2.testdata",
throw new IllegalStateException(
"grackle.h2.testdata system property not set; see build.sbt's doobieh2 project")))

// A fresh named in-memory H2 database, seeded from every script in testdataDir. DB_CLOSE_DELAY
// keeps it alive between connections (each doobie transaction opens a new one); the explicit
// SHUTDOWN on release drops it so it doesn't outlive its suite.
def transactorResource: Resource[IO, Transactor[IO]] = {
val url = s"jdbc:h2:mem:grackle-${UUID.randomUUID()};DB_CLOSE_DELAY=-1"

def seedScript: IO[String] =
IO.blocking {
Option(testdataDir.listFiles((_, name) => name.endsWith(".sql")))
.fold(List.empty[File])(_.toList)
.sortBy(_.getName)
.map(f => new String(Files.readAllBytes(f.toPath), "UTF-8"))
.mkString("\n")
}

def exec(sql: String): IO[Unit] =
IO.blocking {
Using.resource(DriverManager.getConnection(url, "sa", "")) { conn =>
Using.resource(conn.createStatement())(_.execute(sql))
}
}.void

val mkTransactor =
Transactor.fromDriverManager[IO]("org.h2.Driver", url, "sa", "", None)

Resource.make(seedScript.flatMap(exec).as(mkTransactor))(_ => exec("SHUTDOWN"))
}

val transactorFixture: IOFixture[Transactor[IO]] =
ResourceSuiteLocalFixture("doobieh2", transactorResource)
override def munitFixtures: Seq[IOFixture[_]] = Seq(transactorFixture)

def transactor: Transactor[IO] = transactorFixture()
}
Loading
Loading