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