Skip to content

Commit 9772692

Browse files
authored
Merge pull request #912 from phdoerfler/feat/generated-test-data
Share the test data as CSV, one directory per dataset
2 parents 9b6e051 + 2bdea6c commit 9772692

183 files changed

Lines changed: 6947 additions & 17641 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ jobs:
7575
- name: Check Headers
7676
run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' headerCheckAll
7777

78+
- name: Check and build the test data
79+
run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' checkTestData genTestData
80+
7881
- name: Start up test databases
7982
run: docker compose up --force-recreate -d --wait --quiet-pull
8083

@@ -310,6 +313,9 @@ jobs:
310313
if: matrix.java == 'temurin@11' && steps.setup-java-temurin-11.outputs.cache-hit == 'false'
311314
run: sbt +update
312315

316+
- name: Build the test data
317+
run: sbt genTestData
318+
313319
- name: Start up test databases
314320
run: docker compose up --force-recreate -d --wait --quiet-pull
315321

build.sbt

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,13 @@ ThisBuild / githubWorkflowBuild ~= { steps =>
5656
commands = List("headerCheckAll"),
5757
name = Some("Check Headers")
5858
),
59+
WorkflowStep.Sbt(
60+
commands = List("checkTestData", "genTestData"),
61+
name = Some("Check and build the test data")
62+
),
5963
WorkflowStep.Run(
64+
// The scripts have to exist before this: compose mounts target/testdata into the
65+
// containers, and a database only reads its init directory on a first start.
6066
commands = List("docker compose up --force-recreate -d --wait --quiet-pull"),
6167
name = Some("Start up test databases")
6268
)
@@ -91,6 +97,10 @@ ThisBuild / githubWorkflowAddedJobs +=
9197
sbtStepPreamble = Nil,
9298
steps = githubWorkflowJobSetup.value.toList ++
9399
List(
100+
WorkflowStep.Sbt(
101+
commands = List("genTestData"),
102+
name = Some("Build the test data")
103+
),
94104
WorkflowStep.Run(
95105
commands = List("docker compose up --force-recreate -d --wait --quiet-pull"),
96106
name = Some("Start up test databases")
@@ -102,6 +112,11 @@ ThisBuild / githubWorkflowAddedJobs +=
102112

103113
ThisBuild / tlSitePublishBranch := Some("main")
104114

115+
lazy val genTestData =
116+
taskKey[Unit]("Build the container init scripts from the shared test data")
117+
lazy val checkTestData =
118+
taskKey[Unit]("Check every dataset's scripts and CSVs against each other")
119+
lazy val newDataset = inputKey[Unit]("Create an empty dataset directory: newDataset <name>")
105120
lazy val allUp = taskKey[Unit]("Start all docker compose services")
106121
lazy val allStop = taskKey[Unit]("Stop all docker compose services")
107122
lazy val pgUp = taskKey[Unit]("Start Postgres")
@@ -111,18 +126,50 @@ lazy val oracleStop = taskKey[Unit]("Stop Oracle")
111126
lazy val mssqlUp = taskKey[Unit]("Start SQL Server")
112127
lazy val mssqlStop = taskKey[Unit]("Stop SQL Server")
113128

114-
ThisBuild / allUp := runDocker("docker compose up -d --wait --quiet-pull")
115-
ThisBuild / allStop := runDocker("docker compose stop")
116-
ThisBuild / pgUp := runDocker("docker compose up -d --wait --quiet-pull postgres")
117-
ThisBuild / pgStop := runDocker("docker compose stop postgres")
118-
ThisBuild / oracleUp := runDocker("docker compose up -d --wait --quiet-pull oracle")
119-
ThisBuild / oracleStop := runDocker("docker compose stop oracle")
120-
ThisBuild / mssqlUp := runDocker("docker compose up -d --wait --quiet-pull mssql")
121-
ThisBuild / mssqlStop := runDocker("docker compose stop mssql")
122-
123-
def runDocker(cmd: String): Unit = {
124-
require(cmd.! == 0, s"docker indicated an error")
129+
ThisBuild / genTestData := GenTestData(buildRoot)
130+
ThisBuild / checkTestData := {
131+
val problems = GenTestData.check(buildRoot)
132+
val log = streams.value.log
133+
problems.foreach(log.error(_))
134+
if (problems.nonEmpty) sys.error(s"${problems.size} problems in testdata")
125135
}
136+
ThisBuild / newDataset := NewDataset(buildRoot, Def.spaceDelimited("<name>").parsed)
137+
138+
// An input task is evaluated once per aggregated project, so without this newDataset creates the
139+
// directory and then fails three times saying it exists. Plain tasks resolve to one scoped key
140+
// and run once already.
141+
ThisBuild / newDataset / aggregate := false
142+
ThisBuild / allUp := dockerUp()
143+
ThisBuild / allStop := dockerStop()
144+
ThisBuild / pgUp := dockerUp("postgres")
145+
ThisBuild / pgStop := dockerStop("postgres")
146+
ThisBuild / oracleUp := dockerUp("oracle")
147+
ThisBuild / oracleStop := dockerStop("oracle")
148+
ThisBuild / mssqlUp := dockerUp("mssql")
149+
ThisBuild / mssqlStop := dockerStop("mssql")
150+
151+
// The compose file is named relatively, so docker is already run from the build root; the
152+
// generated init scripts are written relative to the same place.
153+
def buildRoot: File = file(".").getAbsoluteFile
154+
155+
/**
156+
* Starts the named services, or every service when named none.
157+
*/
158+
def dockerUp(services: String*): Unit = {
159+
// A container only reads its init scripts the first time it starts, so they have to be
160+
// current before anything brings one up.
161+
GenTestData(buildRoot)
162+
runDocker("docker compose up -d --wait --quiet-pull", services)
163+
}
164+
165+
/**
166+
* Stops the named services, or every service when named none.
167+
*/
168+
def dockerStop(services: String*): Unit =
169+
runDocker("docker compose stop", services)
170+
171+
def runDocker(cmd: String, services: Seq[String]): Unit =
172+
require((cmd +: services).mkString(" ").! == 0, s"docker indicated an error")
126173

127174
lazy val commonSettings = Seq(
128175
// scalacOptions --= Seq("-Wunused:params", "-Wunused:imports", "-Wunused:patvars", "-Wdead-code", "-Wunused:locals", "-Wunused:privates", "-Wunused:implicits"),
@@ -295,8 +342,7 @@ lazy val doobiepg = project
295342
name := "grackle-doobie-pg",
296343
Test / fork := true,
297344
Test / parallelExecution := false,
298-
Test / testOptions += Tests
299-
.Setup(_ => runDocker("docker compose up -d --wait --quiet-pull postgres")),
345+
Test / testOptions += Tests.Setup(_ => dockerUp("postgres")),
300346
libraryDependencies ++= Seq(
301347
"org.typelevel" %% "doobie-postgres-circe" % doobieVersion,
302348
// Pin transitive Postgres JDBC driver to >= 42.7.11 (CVE-2026-42198 / GHSA-98qh-xjc8-98pq)
@@ -314,8 +360,7 @@ lazy val doobieoracle = project
314360
name := "grackle-doobie-oracle",
315361
Test / fork := true,
316362
Test / parallelExecution := false,
317-
Test / testOptions += Tests
318-
.Setup(_ => runDocker("docker compose up -d --wait --quiet-pull oracle")),
363+
Test / testOptions += Tests.Setup(_ => dockerUp("oracle")),
319364
libraryDependencies ++= Seq(
320365
"com.oracle.database.jdbc" % "ojdbc8" % oracleDriverVersion
321366
)
@@ -334,8 +379,7 @@ lazy val doobiemssql = project
334379
// mssql-jdbc binds a zone-naive java.sql.Timestamp using the ambient JVM zone, so MSSQL
335380
// datetime tests fail off-UTC unless pinned.
336381
Test / javaOptions += "-Duser.timezone=UTC",
337-
Test / testOptions += Tests
338-
.Setup(_ => runDocker("docker compose up -d --wait --quiet-pull mssql")),
382+
Test / testOptions += Tests.Setup(_ => dockerUp("mssql")),
339383
libraryDependencies ++= Seq(
340384
"com.microsoft.sqlserver" % "mssql-jdbc" % mssqlDriverVersion
341385
)
@@ -359,8 +403,7 @@ lazy val skunk = crossProject(JVMPlatform, JSPlatform, NativePlatform)
359403
)
360404
.jvmSettings(
361405
Test / fork := true,
362-
Test / testOptions += Tests.Setup(_ =>
363-
runDocker("docker compose up -d --wait --quiet-pull postgres")),
406+
Test / testOptions += Tests.Setup(_ => dockerUp("postgres")),
364407
libraryDependencies ++= Seq(
365408
"ch.qos.logback" % "logback-classic" % logbackVersion % "test"
366409
)

docker-compose.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ services:
88
- POSTGRES_USER=test
99
- POSTGRES_PASSWORD=test
1010
volumes:
11-
- ./testdata/pg/:/docker-entrypoint-initdb.d/
11+
- ./target/testdata/pg/:/docker-entrypoint-initdb.d/
1212
healthcheck:
1313
test: ["CMD-SHELL", "pg_isready -U postgres"]
1414
interval: 5s
@@ -22,7 +22,7 @@ services:
2222
environment:
2323
ORACLE_PASSWORD: test
2424
volumes:
25-
- ./testdata/oracle/:/grackle-initdb.d/
25+
- ./target/testdata/oracle/:/grackle-initdb.d/
2626
- ./modules/doobie-oracle/src/test/resources/scripts/:/container-entrypoint-initdb.d/
2727
healthcheck:
2828
test: bash -c "[ -f /tmp/healthy ]"
@@ -42,7 +42,7 @@ services:
4242
ACCEPT_EULA: Y
4343
MSSQL_TCP_PORT: 1433
4444
volumes:
45-
- ./testdata/mssql/:/grackle-initdb.d/
45+
- ./target/testdata/mssql/:/grackle-initdb.d/
4646
- ./modules/doobie-mssql/src/test/resources/scripts/:/container-entrypoint-initdb.d/
4747
entrypoint: ["/bin/bash", "/container-entrypoint-initdb.d/entrypoint.sh"]
4848
healthcheck:

project/Column.scala

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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+
/**
17+
* A column, and how the dialects disagree about writing its values, if they do.
18+
*/
19+
case class Column(name: String, kind: Kind, sqlType: String)
20+
21+
object Column {
22+
23+
/**
24+
* `nextshowing:timestamptz` is a timestamp column; a bare `title` is a plain one. The type
25+
* comes from the dialect's own schema, which is where Oracle's array constructor lives.
26+
*/
27+
def parse(header: String, sqlTypeOf: String => String): Column = {
28+
val (name, kind) = header.split(":", -1) match {
29+
case Array(name) => (name, Kind.Plain: Kind)
30+
case Array(name, kind) => (name, Kind.named(kind))
31+
case _ => sys.error(s"malformed column header '$header'")
32+
}
33+
Column(name, kind, sqlTypeOf(name))
34+
}
35+
}

project/Dialect.scala

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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+
import java.time.OffsetDateTime
17+
import java.time.format.DateTimeFormatter
18+
19+
import Dialect._
20+
import fs2.{Fallible, Stream}
21+
import fs2.data.csv.lowlevel
22+
23+
/**
24+
* How one database spells the values the dialects disagree about.
25+
*/
26+
sealed abstract class Dialect(val name: String) {
27+
def terminator: String = ";"
28+
def date(value: String): String = literal(value)
29+
def time(value: String): String = literal(value)
30+
def timestamp(value: String): String = literal(value)
31+
def boolean(value: String): String = literal(value.toUpperCase)
32+
def array(elements: List[String], sqlType: String): String
33+
34+
/**
35+
* `\N` is the CSV's null; a plain value is a string literal for the database to coerce.
36+
*/
37+
final def value(column: Column, cell: String): String =
38+
if (cell == "\\N") "NULL"
39+
else
40+
column.kind match {
41+
case Kind.Plain => literal(cell)
42+
case Kind.Array => array(elements(cell), column.sqlType)
43+
case Kind.Date => date(cell)
44+
case Kind.Time => time(cell)
45+
case Kind.Timestamp => timestamp(cell)
46+
case Kind.Boolean => boolean(cell)
47+
}
48+
49+
final def literal(value: String): String = s"'${value.replace("'", "''")}'"
50+
}
51+
52+
object Postgres extends Dialect("pg") {
53+
def array(elements: List[String], sqlType: String): String =
54+
literal(elements.map(quoted).mkString("{", ",", "}"))
55+
}
56+
57+
object Oracle extends Dialect("oracle") {
58+
override def date(value: String): String = s"DATE ${literal(value)}"
59+
override def time(value: String): String = s"INTERVAL '0 $value' DAY TO SECOND (0)"
60+
override def timestamp(value: String): String = s"TIMESTAMP ${literal(sqlTimestamp(value))}"
61+
62+
/**
63+
* A VARRAY value is built by calling the type, so the column's type is the constructor.
64+
*/
65+
def array(elements: List[String], sqlType: String): String = {
66+
require(sqlType.nonEmpty, "an array column needs a collection type in Oracle's schema")
67+
elements.map(literal).mkString(s"$sqlType(", ", ", ")")
68+
}
69+
}
70+
71+
object SqlServer extends Dialect("mssql") {
72+
override def terminator: String = ";\nGO"
73+
override def timestamp(value: String): String = literal(sqlTimestamp(value))
74+
override def boolean(value: String): String = if (value.toBoolean) "1" else "0"
75+
76+
/**
77+
* SQL Server has no array type; the mappings read a JSON array out of a string column.
78+
*/
79+
def array(elements: List[String], sqlType: String): String =
80+
literal(elements.map(quoted).mkString("[", ", ", "]"))
81+
}
82+
83+
object Dialect {
84+
85+
/**
86+
* An array's elements are comma separated, quoted the way any other CSV field would be.
87+
*/
88+
def elements(cell: String): List[String] =
89+
if (cell.isEmpty) Nil
90+
else
91+
Stream
92+
.emit(cell)
93+
.through(lowlevel.rows[Fallible, String](','))
94+
.compile
95+
.toList
96+
.fold(throw _, _.head.values.toList)
97+
98+
def quoted(element: String): String =
99+
"\"" + element.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
100+
101+
/**
102+
* ISO-8601 in the CSV; `2020-05-22 19:35:00 +00:00` is what Oracle and SQL Server read.
103+
*/
104+
def sqlTimestamp(value: String): String =
105+
OffsetDateTime.parse(value).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss xxx"))
106+
}

0 commit comments

Comments
 (0)