diff --git a/examples/maven-postgresql/sql/migrations/V3__add_book_format_tags_and_details.sql b/examples/maven-postgresql/sql/migrations/V3__add_book_format_tags_and_details.sql
new file mode 100644
index 0000000..7a69bfd
--- /dev/null
+++ b/examples/maven-postgresql/sql/migrations/V3__add_book_format_tags_and_details.sql
@@ -0,0 +1,7 @@
+CREATE TYPE book_format AS ENUM ('HARDCOVER', 'PAPERBACK', 'EBOOK');
+
+ALTER TABLE books ADD COLUMN format book_format;
+
+ALTER TABLE books ADD COLUMN tags TEXT[];
+
+ALTER TABLE books ADD COLUMN details JSONB;
diff --git a/examples/maven-postgresql/sql/queries.sql b/examples/maven-postgresql/sql/queries.sql
index b6a0bc0..f8a22c6 100644
--- a/examples/maven-postgresql/sql/queries.sql
+++ b/examples/maven-postgresql/sql/queries.sql
@@ -53,3 +53,14 @@ SELECT a.name, b.title
FROM authors a
LEFT JOIN books b ON b.author_id = a.id
ORDER BY a.id, b.id;
+
+-- name: CreateCatalogedBook :one
+INSERT INTO books (author_id, title, format, tags, details)
+VALUES ($1, $2, $3, $4, $5)
+RETURNING *;
+
+-- name: ListBooksByFormat :many
+SELECT *
+FROM books
+WHERE format = $1
+ORDER BY id;
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
index 050c85b..aee537b 100644
--- a/examples/maven-postgresql/src/main/java/com/example/app/App.java
+++ b/examples/maven-postgresql/src/main/java/com/example/app/App.java
@@ -2,6 +2,8 @@
import com.example.app.db.AuthorRepository;
import com.example.app.db.AuthorsRow;
+import com.example.app.db.BookFormat;
+import com.example.app.db.BooksRow;
import dev.sqlcj.runtime.JdbcQueryExecutor;
import dev.sqlcj.runtime.QueryCardinalityException;
import dev.sqlcj.runtime.QueryExecutor;
@@ -18,17 +20,19 @@
* Runs the documented sqlcj workflow against PostgreSQL through generated
* code: create, read, optional read, list, update, a missing-row read, a
* committed transaction, a rolled back transaction, a case-insensitive search,
- * a count, a page, a book insert, a left-joined projection, and delete.
+ * a count, a page, a book insert, a left-joined projection, a cataloged book
+ * insert, a read by enum value, and delete.
*
- *
All eleven named queries are methods of one generated
+ *
All thirteen named queries are methods of one generated
* {@link AuthorRepository}. The repository is constructed once per execution
* context: once from the {@code DataSource}-backed executor, and once more per
* transaction from a caller-owned connection.
*
*
Create, read, optional read, list, search, and page each return one
* complete {@code authors} row, so all six share the single top-level
- * {@link AuthorsRow} record of the generated package. A query with its own
- * result shape generates its own nested record:
+ * {@link AuthorsRow} record of the generated package, and the two queries that
+ * return a complete {@code books} row share the top-level {@link BooksRow}
+ * record. A query with its own result shape generates its own nested record:
* {@link AuthorRepository.CountAuthorsResult} carries the non-null
* {@code Long} count, and {@link AuthorRepository.ListAuthorBooksResult}
* carries an author name beside the title of the left-joined {@code books} row,
@@ -39,12 +43,18 @@
* {@code GetAuthor} query requires exactly one row and raises a
* {@link QueryCardinalityException} when none matches.
*
+ *
The third migration adds an enum, an array, and a {@code JSONB} column to
+ * {@code books}, so {@link BooksRow} carries a {@link BookFormat} constant of
+ * the generated Java enum, a {@code List} of tags, and the
+ * {@code JSONB} document as JSON text, which PostgreSQL returns normalized.
+ * The enum is also bound as a query parameter to select books by format.
+ *
*
Every step is checked, so the process exits non-zero as soon as one
* generated operation returns an unexpected result.
*
*
The verification harness applies the {@code sql/migrations} files in
- * version order to empty {@code authors} and {@code books} tables before this
- * application runs.
+ * version order to empty {@code authors} and {@code books} tables, without the
+ * {@code book_format} type, before this application runs.
*/
public final class App {
@@ -176,6 +186,38 @@ public static void main(String[] args) throws SQLException {
+ ", " + books.get(1).name() + " / " + books.get(1).title()
);
+ List catalogedTags = List.of("compilers", "history");
+ String catalogedDetails = "{\"pages\": 320}";
+
+ BooksRow cataloged = authors.createCatalogedBook(
+ committedId,
+ "Automatic Programming",
+ BookFormat.HARDCOVER,
+ catalogedTags,
+ catalogedDetails
+ );
+
+ check(cataloged != null, "CreateCatalogedBook returned no row");
+ check(cataloged.id() != null, "CreateCatalogedBook returned no database-generated id");
+ checkEquals(committedId, cataloged.authorId(), "CreateCatalogedBook author id");
+ checkEquals("Automatic Programming", cataloged.title(), "CreateCatalogedBook title");
+ checkEquals(BookFormat.HARDCOVER, cataloged.format(), "CreateCatalogedBook format");
+ checkEquals(catalogedTags, cataloged.tags(), "CreateCatalogedBook tags");
+ checkEquals(catalogedDetails, cataloged.details(), "CreateCatalogedBook details");
+
+ List hardcovers = authors.listBooksByFormat(BookFormat.HARDCOVER);
+
+ checkEquals(1, hardcovers.size(), "ListBooksByFormat row count");
+ checkEquals(cataloged.id(), hardcovers.get(0).id(), "ListBooksByFormat id");
+ checkEquals(BookFormat.HARDCOVER, hardcovers.get(0).format(), "ListBooksByFormat format");
+ checkEquals(catalogedTags, hardcovers.get(0).tags(), "ListBooksByFormat tags");
+ checkEquals(catalogedDetails, hardcovers.get(0).details(), "ListBooksByFormat details");
+
+ System.out.println(
+ "cataloged book: " + cataloged.id() + " " + cataloged.format()
+ + " " + cataloged.tags() + " " + cataloged.details()
+ );
+
int deletedRows = authors.deleteAuthor(created.id());
checkEquals(1, deletedRows, "DeleteAuthor affected rows");
diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh
index d6f26ff..27ddf50 100755
--- a/scripts/verify-release.sh
+++ b/scripts/verify-release.sh
@@ -16,10 +16,13 @@
# then generate the query groups 'Author' and 'Library' and next only 'Writer',
# proving that a renamed or removed entry leaves no stale repository and a
# manifest listing the surviving files alone. Both entries share the one
-# top-level 'AuthorsRow' record the package generates. The final generation
-# uses the sample's own 'sqlcj.yaml' from the sample directory and must restore
-# the first generated output byte for byte before the sample is compiled and
-# run.
+# top-level 'AuthorsRow' record and the one 'BookFormat' enum the package
+# generates. The final generation uses the sample's own 'sqlcj.yaml' from the
+# sample directory and must restore the first generated output byte for byte
+# before the sample is compiled and run. The sample's migrations declare an
+# enum type and add an enum, an array, and a 'JSONB' column to 'books', so the
+# generated package holds the shared 'AuthorsRow' and 'BooksRow' records, the
+# 'BookFormat' enum, and one repository.
#
# Requirements: JDK 21, Maven, psql, and a reachable PostgreSQL server.
#
@@ -186,7 +189,7 @@ YAML
(cd "${elsewhere_dir}" && java -jar "${cli_jar}" generate \
--config ../regeneration/author-and-library.yaml)
-for generated in AuthorRepository LibraryRepository AuthorsRow; do
+for generated in AuthorRepository LibraryRepository AuthorsRow BooksRow BookFormat; do
[ -f "${generated_package}/${generated}.java" ] \
|| fail "the renamed-entry setup is missing ${generated_package}/${generated}.java"
done
@@ -196,6 +199,11 @@ shared_row_count=$(find "${generated_root}" -type f -name 'AuthorsRow.java' | wc
[ "${shared_row_count}" -eq 1 ] \
|| fail "expected the two entries to share 1 row record but found ${shared_row_count}"
+shared_enum_count=$(find "${generated_root}" -type f -name 'BookFormat.java' | wc -l)
+
+[ "${shared_enum_count}" -eq 1 ] \
+ || fail "expected the two entries to share 1 enum but found ${shared_enum_count}"
+
(cd "${elsewhere_dir}" && java -jar "${cli_jar}" generate \
--config ../regeneration/writer.yaml)
@@ -207,16 +215,16 @@ done
stale_count=$(find "${generated_root}" -type f -name '*.java' | wc -l)
-[ "${stale_count}" -eq 2 ] \
- || fail "expected 2 generated sources after the rename but found ${stale_count}"
+[ "${stale_count}" -eq 4 ] \
+ || fail "expected 4 generated sources after the rename but found ${stale_count}"
-for generated in WriterRepository AuthorsRow; do
+for generated in WriterRepository AuthorsRow BooksRow BookFormat; do
[ -f "${generated_package}/${generated}.java" ] \
|| fail "the surviving generated sources are missing ${generated_package}/${generated}.java"
done
expected_manifest="${workspace}/expected-manifest.txt"
-printf 'com/example/app/db/AuthorsRow.java\ncom/example/app/db/WriterRepository.java\n' \
+printf 'com/example/app/db/AuthorsRow.java\ncom/example/app/db/BookFormat.java\ncom/example/app/db/BooksRow.java\ncom/example/app/db/WriterRepository.java\n' \
> "${expected_manifest}"
cmp "${expected_manifest}" "${manifest_file}" \
@@ -226,18 +234,19 @@ log "Generating sources with the packaged CLI"
(cd "${sample_dir}" && java -jar "${cli_jar}" generate)
-for generated in AuthorRepository AuthorsRow; do
+for generated in AuthorRepository AuthorsRow BooksRow BookFormat; 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 2 ] \
- || fail "expected 2 generated sources but found ${generated_count}"
+[ "${generated_count}" -eq 4 ] \
+ || fail "expected 4 generated sources but found ${generated_count}"
for method in createAuthor getAuthor findAuthor listAuthors updateAuthorBio deleteAuthor \
- searchAuthors countAuthors listAuthorPage createBook listAuthorBooks; do
+ searchAuthors countAuthors listAuthorPage createBook listAuthorBooks \
+ createCatalogedBook listBooksByFormat; do
grep -q " ${method}(" "${generated_package}/AuthorRepository.java" \
|| fail "the generated repository is missing the ${method} method"
done
@@ -274,9 +283,10 @@ PGPASSWORD="${db_password}" psql \
--set=ON_ERROR_STOP=1 \
--quiet \
--username "${db_user}" \
- --command 'DROP TABLE IF EXISTS books, authors;' \
+ --command 'DROP TABLE IF EXISTS books, authors; DROP TYPE IF EXISTS book_format;' \
--file "${sample_dir}/sql/migrations/V1__create_authors_and_books.sql" \
--file "${sample_dir}/sql/migrations/V2__add_author_created_at_and_book_index.sql" \
+ --file "${sample_dir}/sql/migrations/V3__add_book_format_tags_and_details.sql" \
"${jdbc_url#jdbc:}"
log "Compiling the sample against the staged runtime"
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 fbcce44..1bc7cb3 100644
--- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java
+++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java
@@ -254,6 +254,88 @@ marks CHAR(3)[]
"marks"
);
+ /**
+ * A snapshot reproducing the schema constructs of sqlc's {@code booktest}
+ * fixture with sqlcj's own names: two tables keyed by {@code SERIAL} with
+ * an inline foreign key reference, a unique defaulted text column, an enum
+ * column defaulting to a label, an integer column named {@code year}, a
+ * {@code timestamptz} column, a {@code varchar[]} column defaulting to the
+ * empty array, single- and two-column indexes, and a dollar-quoted
+ * {@code plpgsql} function followed by one more index.
+ */
+ private static final String CATALOG_SCHEMA = """
+ CREATE TABLE studios
+ (
+ studio_id SERIAL PRIMARY KEY,
+ name text NOT NULL DEFAULT ''
+ );
+
+ CREATE INDEX studios_name_idx ON studios (name);
+
+ CREATE TYPE album_kind AS ENUM ('STUDIO', 'LIVE');
+
+ CREATE TABLE albums
+ (
+ album_id SERIAL PRIMARY KEY,
+ studio_id integer NOT NULL REFERENCES studios (studio_id),
+ catalog_no text NOT NULL DEFAULT '' UNIQUE,
+ kind album_kind NOT NULL DEFAULT 'STUDIO',
+ title text NOT NULL DEFAULT '',
+ year integer NOT NULL DEFAULT 2000,
+ released timestamptz NOT NULL DEFAULT now(),
+ tags varchar[] NOT NULL DEFAULT '{}'
+ );
+
+ CREATE INDEX albums_title_year_idx ON albums (title, year);
+
+ CREATE FUNCTION greet_listener(text) RETURNS text AS $$
+ BEGIN
+ RETURN CONCAT('hello ', $1);
+ END;
+ $$ LANGUAGE plpgsql;
+
+ CREATE INDEX albums_kind_idx ON albums (kind);
+ """;
+
+ /**
+ * Queries whose shapes mirror {@code booktest}'s {@code GetBook},
+ * {@code CreateBook}, {@code UpdateBook}, and {@code UpdateBookISBN}: a
+ * full-row read by key, a seven-column insert returning the full row, an
+ * update of a text and an array column by key, and an update whose third
+ * assignment binds {@code $4} while the key binds {@code $3}.
+ */
+ private static final String CATALOG_QUERIES = """
+ -- name: GetAlbum :one
+ SELECT *
+ FROM albums
+ WHERE album_id = $1;
+
+ -- name: CreateAlbum :one
+ INSERT INTO albums (
+ studio_id,
+ catalog_no,
+ kind,
+ title,
+ year,
+ released,
+ tags
+ )
+ VALUES (
+ $1, $2, $3, $4, $5, $6, $7
+ )
+ RETURNING *;
+
+ -- name: UpdateAlbum :exec
+ UPDATE albums
+ SET title = $1, tags = $2
+ WHERE album_id = $3;
+
+ -- name: UpdateAlbumCatalogNo :exec
+ UPDATE albums
+ SET title = $1, tags = $2, catalog_no = $4
+ WHERE album_id = $3;
+ """;
+
/**
* Queries used by the caller-owned transaction tests: an affected-row
* write, a returning write, and a read.
@@ -409,6 +491,10 @@ void resetDatabase() throws Exception {
execute("DROP TABLE IF EXISTS stage_events");
execute("DROP TABLE IF EXISTS array_values");
execute("DROP TYPE IF EXISTS stage_setting");
+ execute("DROP TABLE IF EXISTS albums");
+ execute("DROP TABLE IF EXISTS studios");
+ execute("DROP TYPE IF EXISTS album_kind");
+ execute("DROP FUNCTION IF EXISTS greet_listener(text)");
execute("DROP TABLE IF EXISTS customer_orders");
execute("DROP TABLE IF EXISTS customers");
execute("DROP TABLE IF EXISTS measurements");
@@ -1663,6 +1749,191 @@ INSERT INTO documents (id, payload, config)
}
}
+ /**
+ * Proves that the schema constructs and query shapes of sqlc's
+ * {@code booktest} fixture generate typed Java and run against PostgreSQL:
+ * the enum type becomes a Java enum keeping its PostgreSQL labels, the full
+ * row of the referencing table carries the enum, the
+ * {@code TIMESTAMP WITH TIME ZONE}, and the {@code varchar[]} column as
+ * typed components, the seven-column insert binds them in placeholder
+ * order, and the update whose assignments use {@code $1}, {@code $2}, and
+ * {@code $4} around the {@code $3} key exposes its parameters in
+ * placeholder order while binding them in textual order.
+ */
+ @Test
+ void shouldExecuteGeneratedCatalogQueriesOverBooktestSchemaConstructs() throws Exception {
+ execute(CATALOG_SCHEMA);
+ execute("INSERT INTO studios (studio_id, name) VALUES (1, 'Blue Note')");
+
+ Path classesDirectory = generateAndCompile(CATALOG_SCHEMA, CATALOG_QUERIES);
+
+ OffsetDateTime released = OffsetDateTime.of(
+ 1963,
+ 4,
+ 1,
+ 20,
+ 30,
+ 0,
+ 0,
+ ZoneOffset.ofHours(-5)
+ );
+
+ try (URLClassLoader classLoader = classLoader(classesDirectory)) {
+ Class> albumKind = Class.forName("generated.AlbumKind", true, classLoader);
+
+ assertTrue(albumKind.isEnum());
+
+ Object[] constants = albumKind.getEnumConstants();
+
+ assertEquals(
+ List.of("STUDIO", "LIVE"),
+ Arrays.stream(constants).map(Object::toString).toList()
+ );
+
+ Method label = albumKind.getMethod("label");
+
+ List