diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 47b6aef..bce2637 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -14,9 +14,23 @@ concurrency:
jobs:
test:
- name: Maven test
+ name: Maven test and release verification
runs-on: ubuntu-latest
- timeout-minutes: 10
+ timeout-minutes: 20
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_DB: quickstart
+ POSTGRES_USER: quickstart
+ POSTGRES_PASSWORD: quickstart
+ ports:
+ - 5432/tcp
+ options: >-
+ --health-cmd "pg_isready --username quickstart --dbname quickstart"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 10
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
@@ -32,4 +46,11 @@ jobs:
cache-dependency-path: pom.xml
- name: Run tests
- run: mvn --batch-mode --no-transfer-progress test
+ run: mvn --batch-mode --no-transfer-progress -Dsqlcj.test.requireDocker=true test
+
+ - name: Verify staged artifacts with the external Maven sample
+ env:
+ SQLCJ_SAMPLE_JDBC_URL: jdbc:postgresql://localhost:${{ job.services.postgres.ports['5432'] }}/quickstart
+ SQLCJ_SAMPLE_DB_USER: quickstart
+ SQLCJ_SAMPLE_DB_PASSWORD: quickstart
+ run: scripts/verify-release.sh
diff --git a/examples/maven-postgresql/pom.xml b/examples/maven-postgresql/pom.xml
new file mode 100644
index 0000000..2e3c0cd
--- /dev/null
+++ b/examples/maven-postgresql/pom.xml
@@ -0,0 +1,61 @@
+
+
+ 4.0.0
+
+ com.example
+ sqlcj-maven-postgresql-sample
+ 1.0.0-SNAPSHOT
+
+
+
+
+ 21
+ UTF-8
+ 0.1.0-SNAPSHOT
+ ${project.build.directory}/generated-sources/sqlcj
+
+
+
+
+ dev.sqlcj
+ sqlcj-runtime
+ ${sqlcj.version}
+
+
+ org.postgresql
+ postgresql
+ 42.7.13
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.15.0
+
+
+ ${project.basedir}/src/main/java
+ ${sqlcj.generated.sources}
+
+
+
+
+ org.codehaus.mojo
+ exec-maven-plugin
+ 3.6.3
+
+ com.example.app.App
+
+
+
+
+
diff --git a/examples/maven-postgresql/sql/queries.sql b/examples/maven-postgresql/sql/queries.sql
new file mode 100644
index 0000000..80652e6
--- /dev/null
+++ b/examples/maven-postgresql/sql/queries.sql
@@ -0,0 +1,24 @@
+-- name: CreateAuthor :one
+INSERT INTO authors (name, bio)
+VALUES ($1, $2)
+RETURNING id, name, bio;
+
+-- name: GetAuthor :one
+SELECT id, name, bio
+FROM authors
+WHERE id = $1;
+
+-- name: ListAuthors :many
+SELECT id, name
+FROM authors
+ORDER BY id;
+
+-- name: UpdateAuthorBio :exec
+UPDATE authors
+SET bio = $2
+WHERE id = $1;
+
+-- name: DeleteAuthor :exec
+DELETE
+FROM authors
+WHERE id = $1;
diff --git a/examples/maven-postgresql/sql/schema.sql b/examples/maven-postgresql/sql/schema.sql
new file mode 100644
index 0000000..e3d3e96
--- /dev/null
+++ b/examples/maven-postgresql/sql/schema.sql
@@ -0,0 +1,7 @@
+CREATE TABLE authors
+(
+ id BIGSERIAL PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ bio TEXT,
+ created_at TIMESTAMP
+);
diff --git a/examples/maven-postgresql/sqlcj.yaml b/examples/maven-postgresql/sqlcj.yaml
new file mode 100644
index 0000000..eebb0b9
--- /dev/null
+++ b/examples/maven-postgresql/sqlcj.yaml
@@ -0,0 +1,7 @@
+version: "1"
+sql:
+ - schema: sql/schema.sql
+ queries: sql/queries.sql
+java:
+ package: com.example.app.db
+ out: target/generated-sources/sqlcj
diff --git a/examples/maven-postgresql/src/main/java/com/example/app/App.java b/examples/maven-postgresql/src/main/java/com/example/app/App.java
new file mode 100644
index 0000000..35fa71d
--- /dev/null
+++ b/examples/maven-postgresql/src/main/java/com/example/app/App.java
@@ -0,0 +1,190 @@
+package com.example.app;
+
+import com.example.app.db.CreateAuthor;
+import com.example.app.db.DeleteAuthor;
+import com.example.app.db.GetAuthor;
+import com.example.app.db.ListAuthors;
+import com.example.app.db.UpdateAuthorBio;
+import dev.sqlcj.runtime.JdbcQueryExecutor;
+import dev.sqlcj.runtime.QueryExecutor;
+import org.postgresql.ds.PGSimpleDataSource;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Runs the documented sqlcj workflow against PostgreSQL through generated
+ * code: create, read, list, update, a missing-row read, a committed
+ * transaction, a rolled back transaction, and delete.
+ *
+ *
Every step is checked, so the process exits non-zero as soon as one
+ * generated operation returns an unexpected result.
+ *
+ *
The verification harness applies {@code sql/schema.sql} to an empty
+ * {@code authors} table before this application runs.
+ */
+public final class App {
+
+ private App() {
+ }
+
+ public static void main(String[] args) throws SQLException {
+ DataSource dataSource = dataSource();
+
+ QueryExecutor executor = new JdbcQueryExecutor(dataSource);
+
+ GetAuthor getAuthor = new GetAuthor(executor);
+ ListAuthors listAuthors = new ListAuthors(executor);
+
+ CreateAuthor.CreateAuthorResult created = new CreateAuthor(executor)
+ .createAuthor("Ada Lovelace", "First programmer");
+
+ check(created != null, "CreateAuthor returned no row");
+ check(created.id() != null, "CreateAuthor returned no database-generated id");
+ checkEquals("Ada Lovelace", created.name(), "CreateAuthor name");
+ checkEquals("First programmer", created.bio(), "CreateAuthor bio");
+
+ System.out.println("created: " + created.id() + " " + created.name());
+
+ GetAuthor.GetAuthorResult read = getAuthor.getAuthor(created.id());
+
+ check(read != null, "GetAuthor returned no row for the created author");
+ checkEquals(created.id(), read.id(), "GetAuthor id");
+ checkEquals("Ada Lovelace", read.name(), "GetAuthor name");
+ checkEquals("First programmer", read.bio(), "GetAuthor bio");
+
+ System.out.println("read: " + read.name() + " / " + read.bio());
+
+ List listed = listAuthors.listAuthors();
+
+ checkEquals(1, listed.size(), "ListAuthors row count after create");
+ checkEquals(created.id(), listed.get(0).id(), "ListAuthors id");
+ checkEquals("Ada Lovelace", listed.get(0).name(), "ListAuthors name");
+
+ System.out.println("listed: " + listed.get(0).id() + " " + listed.get(0).name());
+
+ int updatedRows = new UpdateAuthorBio(executor).updateAuthorBio(created.id(), "Mathematician");
+
+ checkEquals(1, updatedRows, "UpdateAuthorBio affected rows");
+ checkEquals("Mathematician", getAuthor.getAuthor(created.id()).bio(), "bio after update");
+
+ System.out.println("updated rows: " + updatedRows);
+
+ check(getAuthor.getAuthor(-1L) == null, "GetAuthor must return null for a missing row");
+
+ System.out.println("missing row: null");
+
+ Long committedId = writeAndCommit(dataSource);
+
+ GetAuthor.GetAuthorResult committed = getAuthor.getAuthor(committedId);
+
+ check(committed != null, "the committed author is not readable after commit");
+ checkEquals("Grace Hopper", committed.name(), "committed name");
+ checkEquals("Compiler pioneer", committed.bio(), "committed bio");
+
+ System.out.println("committed: " + committed.bio());
+
+ Long discardedId = writeAndRollback(dataSource);
+
+ check(getAuthor.getAuthor(discardedId) == null, "the rolled back author must not be readable");
+
+ System.out.println("rolled back: null");
+
+ int deletedRows = new DeleteAuthor(executor).deleteAuthor(created.id());
+
+ checkEquals(1, deletedRows, "DeleteAuthor affected rows");
+ check(getAuthor.getAuthor(created.id()) == null, "the deleted author must not be readable");
+
+ System.out.println("deleted rows: " + deletedRows);
+
+ List remaining = listAuthors.listAuthors();
+
+ checkEquals(1, remaining.size(), "ListAuthors row count after delete");
+ checkEquals(committedId, remaining.get(0).id(), "remaining author id");
+
+ System.out.println("sqlcj sample verification passed");
+ }
+
+ /**
+ * Writes through a caller-owned connection with auto-commit disabled and
+ * commits, so the written row must be visible to a later read.
+ */
+ private static Long writeAndCommit(DataSource dataSource) throws SQLException {
+ try (Connection connection = dataSource.getConnection()) {
+ connection.setAutoCommit(false);
+
+ QueryExecutor transactional = new JdbcQueryExecutor(connection);
+
+ CreateAuthor.CreateAuthorResult author = new CreateAuthor(transactional)
+ .createAuthor("Grace Hopper", null);
+
+ check(author != null, "CreateAuthor returned no row inside the committed transaction");
+ checkEquals(null, author.bio(), "committed bio before update");
+
+ int rows = new UpdateAuthorBio(transactional).updateAuthorBio(author.id(), "Compiler pioneer");
+
+ checkEquals(1, rows, "UpdateAuthorBio affected rows inside the committed transaction");
+
+ connection.commit();
+
+ return author.id();
+ }
+ }
+
+ /**
+ * Writes through a caller-owned connection with auto-commit disabled and
+ * rolls back, so the written row must not be visible to a later read.
+ */
+ private static Long writeAndRollback(DataSource dataSource) throws SQLException {
+ try (Connection connection = dataSource.getConnection()) {
+ connection.setAutoCommit(false);
+
+ QueryExecutor transactional = new JdbcQueryExecutor(connection);
+
+ CreateAuthor.CreateAuthorResult author = new CreateAuthor(transactional)
+ .createAuthor("Temporary Author", null);
+
+ check(author != null, "CreateAuthor returned no row inside the rolled back transaction");
+
+ int rows = new UpdateAuthorBio(transactional).updateAuthorBio(author.id(), "never stored");
+
+ checkEquals(1, rows, "UpdateAuthorBio affected rows inside the rolled back transaction");
+
+ connection.rollback();
+
+ return author.id();
+ }
+ }
+
+ private static DataSource dataSource() {
+ PGSimpleDataSource dataSource = new PGSimpleDataSource();
+
+ dataSource.setUrl(environment("SQLCJ_SAMPLE_JDBC_URL", "jdbc:postgresql://localhost:5432/quickstart"));
+ dataSource.setUser(environment("SQLCJ_SAMPLE_DB_USER", "quickstart"));
+ dataSource.setPassword(environment("SQLCJ_SAMPLE_DB_PASSWORD", "quickstart"));
+
+ return dataSource;
+ }
+
+ private static String environment(String name, String fallback) {
+ String value = System.getenv(name);
+
+ return value == null || value.isBlank() ? fallback : value;
+ }
+
+ private static void checkEquals(Object expected, Object actual, String description) {
+ check(
+ Objects.equals(expected, actual),
+ description + ": expected " + expected + " but was " + actual
+ );
+ }
+
+ private static void check(boolean condition, String message) {
+ if (!condition) {
+ throw new IllegalStateException(message);
+ }
+ }
+}
diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh
new file mode 100755
index 0000000..c5f5bc4
--- /dev/null
+++ b/scripts/verify-release.sh
@@ -0,0 +1,175 @@
+#!/usr/bin/env bash
+#
+# Verifies that a clean external Maven project can consume staged sqlcj
+# artifacts.
+#
+# The script stages the reactor into an isolated local Maven repository, runs
+# the packaged CLI from that repository, and then copies examples/maven-postgresql
+# outside the repository to generate, compile, and execute it against a running
+# PostgreSQL 16 server. The sqlcj source tree, its reactor classes, and its
+# target directories are never placed on the sample's classpath.
+#
+# Requirements: JDK 21, Maven, psql, and a reachable PostgreSQL server.
+#
+# Connection settings, defaulting to the values used by docs/quickstart.md:
+#
+# SQLCJ_SAMPLE_JDBC_URL jdbc:postgresql://localhost:5432/quickstart
+# SQLCJ_SAMPLE_DB_USER quickstart
+# SQLCJ_SAMPLE_DB_PASSWORD quickstart
+
+set -euo pipefail
+
+script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
+repo_root=$(cd -- "${script_dir}/.." && pwd)
+
+jdbc_url=${SQLCJ_SAMPLE_JDBC_URL:-jdbc:postgresql://localhost:5432/quickstart}
+db_user=${SQLCJ_SAMPLE_DB_USER:-quickstart}
+db_password=${SQLCJ_SAMPLE_DB_PASSWORD:-quickstart}
+
+workspace=$(mktemp -d "${TMPDIR:-/tmp}/sqlcj-verify-release.XXXXXXXXXX")
+local_repo="${workspace}/repository"
+sample_dir="${workspace}/sample"
+
+trap 'rm -rf "${workspace}"' EXIT
+
+log() {
+ printf '\n=== %s\n' "$1"
+}
+
+fail() {
+ printf 'verify-release: %s\n' "$1" >&2
+ exit 1
+}
+
+sample_mvn() {
+ mvn --batch-mode --no-transfer-progress \
+ -f "${sample_dir}/pom.xml" \
+ -Dmaven.repo.local="${local_repo}" \
+ -Dsqlcj.version="${version}" \
+ "$@"
+}
+
+command -v psql >/dev/null 2>&1 || fail "psql is required to apply the sample schema"
+
+log "Staging the reactor into ${local_repo}"
+
+mvn --batch-mode --no-transfer-progress \
+ -f "${repo_root}/pom.xml" \
+ -Dmaven.repo.local="${local_repo}" \
+ -Dmaven.test.skip=true \
+ install
+
+version=$(
+ mvn --batch-mode --no-transfer-progress --quiet \
+ -f "${repo_root}/pom.xml" \
+ -Dmaven.repo.local="${local_repo}" \
+ -Dexpression=project.version \
+ -DforceStdout \
+ help:evaluate | tail -n 1 | tr -d '[:space:]'
+)
+
+[ -n "${version}" ] || fail "could not determine the reactor version"
+
+log "Verifying the staged artifacts for version ${version}"
+
+cli_jar="${local_repo}/dev/sqlcj/sqlcj-cli/${version}/sqlcj-cli-${version}.jar"
+runtime_jar="${local_repo}/dev/sqlcj/sqlcj-runtime/${version}/sqlcj-runtime-${version}.jar"
+
+[ -f "${cli_jar}" ] || fail "the staged CLI is missing: ${cli_jar}"
+[ -f "${runtime_jar}" ] || fail "the staged runtime is missing: ${runtime_jar}"
+
+reported_version=$(java -jar "${cli_jar}" version | tr -d '[:space:]')
+
+[ "${reported_version}" = "${version}" ] \
+ || fail "the packaged CLI reports '${reported_version}' instead of '${version}'"
+
+log "Verifying the handled configuration failure"
+
+failure_dir="${workspace}/handled-failure"
+mkdir -p "${failure_dir}"
+
+set +e
+failure_output=$(cd "${failure_dir}" && java -jar "${cli_jar}" generate 2>&1)
+failure_status=$?
+set -e
+
+[ "${failure_status}" -eq 1 ] \
+ || fail "a missing configuration file exited with ${failure_status} instead of 1"
+
+printf '%s\n' "${failure_output}" | grep -q '^sqlcj: ' \
+ || fail "the handled failure did not print an 'sqlcj: ' diagnostic: ${failure_output}"
+
+if printf '%s\n' "${failure_output}" | grep -qE 'Exception|^[[:space:]]+at .*\('; then
+ fail "the handled failure printed a stack trace: ${failure_output}"
+fi
+
+log "Copying the external sample to ${sample_dir}"
+
+cp -R "${repo_root}/examples/maven-postgresql" "${sample_dir}"
+rm -rf "${sample_dir}/target"
+
+log "Generating sources with the packaged CLI"
+
+(cd "${sample_dir}" && java -jar "${cli_jar}" generate)
+
+generated_root="${sample_dir}/target/generated-sources/sqlcj"
+generated_package="${generated_root}/com/example/app/db"
+
+for generated in CreateAuthor DeleteAuthor GetAuthor ListAuthors UpdateAuthorBio; do
+ [ -f "${generated_package}/${generated}.java" ] \
+ || fail "the expected generated source is missing: ${generated_package}/${generated}.java"
+done
+
+generated_count=$(find "${generated_root}" -type f -name '*.java' | wc -l)
+
+[ "${generated_count}" -eq 5 ] \
+ || fail "expected 5 generated sources but found ${generated_count}"
+
+find "${generated_root}" -type f -name '*.java' | sort
+
+log "Verifying the sample dependency classpath"
+
+classpath_file="${workspace}/sample-classpath.txt"
+
+sample_mvn org.apache.maven.plugins:maven-dependency-plugin:3.8.1:build-classpath \
+ "-Dmdep.outputFile=${classpath_file}"
+
+grep -q "sqlcj-runtime-${version}.jar" "${classpath_file}" \
+ || fail "the sample classpath does not use the staged runtime artifact"
+
+if grep -q "${repo_root}" "${classpath_file}"; then
+ fail "the sample classpath references the sqlcj source tree: $(cat "${classpath_file}")"
+fi
+
+tr ':' '\n' < "${classpath_file}"
+
+log "Applying the sample schema to ${jdbc_url}"
+
+# psql accepts the JDBC URL as a connection URI once the 'jdbc:' prefix is
+# removed, so the sample and the schema reset share one setting.
+PGPASSWORD="${db_password}" psql \
+ --set=ON_ERROR_STOP=1 \
+ --quiet \
+ --username "${db_user}" \
+ --command 'DROP TABLE IF EXISTS authors;' \
+ --file "${sample_dir}/sql/schema.sql" \
+ "${jdbc_url#jdbc:}"
+
+log "Compiling the sample against the staged runtime"
+
+sample_mvn compile
+
+log "Running the sample against PostgreSQL"
+
+sample_output="${workspace}/sample-output.txt"
+
+export SQLCJ_SAMPLE_JDBC_URL="${jdbc_url}"
+export SQLCJ_SAMPLE_DB_USER="${db_user}"
+export SQLCJ_SAMPLE_DB_PASSWORD="${db_password}"
+
+sample_mvn exec:java | tee "${sample_output}"
+
+grep -q 'sqlcj sample verification passed' "${sample_output}" \
+ || fail "the sample did not report a successful run"
+
+log "Release verification passed for version ${version}"
diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java
index 5f13572..a7e993a 100644
--- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java
+++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java
@@ -52,11 +52,21 @@
* generated Java is compiled, and the generated classes are executed through
* the JDBC runtime.
*
- * The whole class is skipped when Docker is unavailable.
+ *
The whole class is skipped when Docker is unavailable, so that
+ * {@code mvn test} stays runnable without Docker. Setting
+ * {@code -Dsqlcj.test.requireDocker=true}, as continuous integration does,
+ * turns an unavailable Docker into a failure instead of a skip.
*/
@EnabledIf("dockerAvailable")
class PostgresIntegrationTest {
+ /**
+ * System property that forbids skipping these tests. Continuous
+ * integration sets it so that a broken Docker environment fails the build
+ * instead of silently reducing coverage.
+ */
+ private static final String REQUIRE_DOCKER_PROPERTY = "sqlcj.test.requireDocker";
+
private static final String SCHEMA = """
CREATE TABLE users
(
@@ -146,7 +156,15 @@ INSERT INTO users (id, code, name)
Path tempDir;
static boolean dockerAvailable() {
- return DockerClientFactory.instance().isDockerAvailable();
+ boolean available = DockerClientFactory.instance().isDockerAvailable();
+
+ if (!available && Boolean.getBoolean(REQUIRE_DOCKER_PROPERTY)) {
+ throw new IllegalStateException(
+ "Docker is unavailable, but " + REQUIRE_DOCKER_PROPERTY + " requires these tests to run."
+ );
+ }
+
+ return available;
}
@BeforeAll