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 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