From ac2e7071ac865d34ae313a41aff82df432b4d089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Tue, 29 Sep 2026 20:59:14 +0300 Subject: [PATCH 1/2] test(sample): exercise enum, array, and JSONB columns in the sample A new migration adds a book_format enum, a TEXT[] tags column, and a JSONB details column to books. The sample inserts a cataloged book and reads it back by format, and the release script applies the migration, resets the enum type, and expects the shared BookFormat enum and BooksRow record in every generation. --- .../V3__add_book_format_tags_and_details.sql | 7 +++ examples/maven-postgresql/sql/queries.sql | 11 ++++ .../src/main/java/com/example/app/App.java | 54 ++++++++++++++++--- scripts/verify-release.sh | 38 ++++++++----- 4 files changed, 90 insertions(+), 20 deletions(-) create mode 100644 examples/maven-postgresql/sql/migrations/V3__add_book_format_tags_and_details.sql 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" From 74ee3b464652abacec695111b6348d0fcd23c5c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Tue, 29 Sep 2026 20:59:26 +0300 Subject: [PATCH 2/2] test(integration): run booktest-shaped schema and queries on PostgreSQL --- .../compiler/PostgresIntegrationTest.java | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) 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 labels = new ArrayList<>(); + + for (Object constant : constants) { + labels.add(label.invoke(constant)); + } + + assertEquals(enumLabels("album_kind"), labels); + + Object live = constants[1]; + + Object repository = newRepository(classLoader); + + Method createMethod = repository.getClass().getMethod( + "createAlbum", + Integer.class, + String.class, + albumKind, + String.class, + Integer.class, + OffsetDateTime.class, + List.class + ); + + Object created = createMethod.invoke( + repository, + 1, + "BN-1001", + live, + "Night Sessions", + 1963, + released, + List.of("jazz", "live") + ); + + assertNotNull(created); + + assertEquals( + List.of( + "albumId", + "studioId", + "catalogNo", + "kind", + "title", + "year", + "released", + "tags" + ), + recordComponentNames(created) + ); + + assertEquals( + List.of( + Integer.class, + Integer.class, + String.class, + albumKind, + String.class, + Integer.class, + OffsetDateTime.class, + List.class + ), + recordComponentTypes(created) + ); + + Object albumId = component(created, "albumId"); + + assertNotNull(albumId); + assertEquals(1, component(created, "studioId")); + assertEquals(live, component(created, "kind")); + assertEquals(List.of("jazz", "live"), component(created, "tags")); + + assertTrue(released.isEqual((OffsetDateTime) component(created, "released"))); + + Method getMethod = repository.getClass().getMethod("getAlbum", Integer.class); + + Object read = getMethod.invoke(repository, albumId); + + assertNotNull(read); + assertEquals("BN-1001", component(read, "catalogNo")); + assertEquals("Night Sessions", component(read, "title")); + assertEquals(1963, component(read, "year")); + assertEquals(live, component(read, "kind")); + assertEquals(List.of("jazz", "live"), component(read, "tags")); + + assertTrue(released.isEqual((OffsetDateTime) component(read, "released"))); + + Method updateMethod = repository.getClass().getMethod( + "updateAlbum", + String.class, + List.class, + Integer.class + ); + + assertEquals( + 1, + updateMethod.invoke( + repository, + "Night Sessions, Complete", + List.of("jazz"), + albumId + ) + ); + + Object updated = getMethod.invoke(repository, albumId); + + assertEquals("Night Sessions, Complete", component(updated, "title")); + assertEquals(List.of("jazz"), component(updated, "tags")); + assertEquals("BN-1001", component(updated, "catalogNo")); + + Method updateCatalogNoMethod = repository.getClass().getMethod( + "updateAlbumCatalogNo", + String.class, + List.class, + Integer.class, + String.class + ); + + assertEquals( + List.of(String.class, List.class, Integer.class, String.class), + List.of(updateCatalogNoMethod.getParameterTypes()) + ); + + assertEquals( + 1, + updateCatalogNoMethod.invoke( + repository, + "Night Sessions, Vol. 2", + List.of("jazz", "reissue"), + albumId, + "BN-1002" + ) + ); + + Object reissued = getMethod.invoke(repository, albumId); + + assertEquals("Night Sessions, Vol. 2", component(reissued, "title")); + assertEquals(List.of("jazz", "reissue"), component(reissued, "tags")); + assertEquals("BN-1002", component(reissued, "catalogNo")); + assertEquals(live, component(reissued, "kind")); + } + } + /** * Proves that a snapshot carrying table-level foreign key and check * constraints, column defaults, and named constraints is valid PostgreSQL