diff --git a/examples/maven-postgresql/sql/migrations/V4__add_author_updated_at.sql b/examples/maven-postgresql/sql/migrations/V4__add_author_updated_at.sql new file mode 100644 index 0000000..d59dbf9 --- /dev/null +++ b/examples/maven-postgresql/sql/migrations/V4__add_author_updated_at.sql @@ -0,0 +1 @@ +ALTER TABLE authors ADD COLUMN updated_at TIMESTAMP; diff --git a/examples/maven-postgresql/sql/queries.sql b/examples/maven-postgresql/sql/queries.sql index f8a22c6..7f14078 100644 --- a/examples/maven-postgresql/sql/queries.sql +++ b/examples/maven-postgresql/sql/queries.sql @@ -64,3 +64,33 @@ SELECT * FROM books WHERE format = $1 ORDER BY id; + +-- name: FilterAuthors :many +SELECT * +FROM authors +WHERE (:name::text IS NULL OR name = :name) +ORDER BY id; + +-- name: RenameAuthor :exec +UPDATE authors +SET name = :name, updated_at = now() +WHERE id = :id; + +-- name: CountBooksByAuthor :many +SELECT author_id, COUNT(*) AS books +FROM books +GROUP BY author_id +ORDER BY author_id; + +-- name: UpsertAuthor :one +INSERT INTO authors (id, name, bio) +VALUES (:id, :name, :bio) +ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name, bio = EXCLUDED.bio, updated_at = now() +RETURNING *; + +-- name: ListAuthorsByIds :many +SELECT * +FROM authors +WHERE id = ANY(:ids) +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 aee537b..7ffbeba 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 @@ -21,22 +21,26 @@ * 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, a cataloged book - * insert, a read by enum value, and delete. + * insert, a read by enum value, an optional filter by name, a read by id list, + * a grouped book count, a rename, an upsert, and delete. * - *

All thirteen named queries are methods of one generated + *

All eighteen 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, 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, - * which is {@code null} for an author without a book. + *

Create, read, optional read, list, search, page, filter, upsert, and the + * read by id list each return one complete {@code authors} row, so all nine + * share the single top-level {@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, + * {@link AuthorRepository.ListAuthorBooksResult} carries an author name beside + * the title of the left-joined {@code books} row, which is {@code null} for an + * author without a book, and + * {@link AuthorRepository.CountBooksByAuthorResult} carries an author id beside + * the non-null {@code Long} number of that author's books. * *

Row absence is expressed by the {@code :optional} {@code FindAuthor} * query, which returns an empty {@link Optional}, while the {@code :one} @@ -49,6 +53,16 @@ * {@code JSONB} document as JSON text, which PostgreSQL returns normalized. * The enum is also bound as a query parameter to select books by format. * + *

The fourth migration adds the nullable {@code updated_at} column to + * {@code authors}, so {@link AuthorsRow} carries it as a + * {@link java.time.LocalDateTime}. The rename and the upsert set it with + * {@code now()} without binding a parameter for it, and the upsert targets the + * existing author of the conflicting id, so the {@code BIGSERIAL} key is never + * given an explicit new value. The filter binds its one {@code :name} + * parameter to both of its occurrences, so a {@code null} argument returns + * every author and a name returns only that author, and the read by id list + * binds one {@code List} as one server array, so an empty list matches no row. + * *

Every step is checked, so the process exits non-zero as soon as one * generated operation returns an unexpected result. * @@ -218,6 +232,71 @@ public static void main(String[] args) throws SQLException { + " " + cataloged.tags() + " " + cataloged.details() ); + List unfiltered = authors.filterAuthors(null); + + checkEquals(2, unfiltered.size(), "FilterAuthors row count without a name"); + checkEquals(created.id(), unfiltered.get(0).id(), "FilterAuthors first unfiltered id"); + checkEquals(committedId, unfiltered.get(1).id(), "FilterAuthors second unfiltered id"); + + List filtered = authors.filterAuthors("Ada Lovelace"); + + checkEquals(1, filtered.size(), "FilterAuthors row count for one name"); + checkEquals(created.id(), filtered.get(0).id(), "FilterAuthors filtered id"); + checkEquals("Ada Lovelace", filtered.get(0).name(), "FilterAuthors filtered name"); + + System.out.println( + "filtered: " + unfiltered.size() + " without a name, " + + filtered.get(0).name() + " by name" + ); + + List byIds = authors.listAuthorsByIds(List.of(created.id(), committedId)); + + checkEquals(2, byIds.size(), "ListAuthorsByIds row count for two ids"); + checkEquals(created.id(), byIds.get(0).id(), "ListAuthorsByIds first id"); + checkEquals(committedId, byIds.get(1).id(), "ListAuthorsByIds second id"); + checkEquals( + 0, + authors.listAuthorsByIds(List.of()).size(), + "ListAuthorsByIds row count for an empty list" + ); + + System.out.println("by ids: " + byIds.get(0).id() + " " + byIds.get(1).id()); + + List bookCounts = authors.countBooksByAuthor(); + + checkEquals(1, bookCounts.size(), "CountBooksByAuthor row count"); + checkEquals(committedId, bookCounts.get(0).authorId(), "CountBooksByAuthor author id"); + checkEquals(2L, bookCounts.get(0).books(), "CountBooksByAuthor count"); + + System.out.println( + "book counts: " + bookCounts.get(0).authorId() + " / " + bookCounts.get(0).books() + ); + + int renamedRows = authors.renameAuthor("Ada Byron", created.id()); + + checkEquals(1, renamedRows, "RenameAuthor affected rows"); + + AuthorsRow renamed = authors.getAuthor(created.id()); + + checkEquals("Ada Byron", renamed.name(), "name after rename"); + check(renamed.updatedAt() != null, "RenameAuthor left updatedAt null"); + + System.out.println("renamed: " + renamed.name() + " at " + renamed.updatedAt()); + + AuthorsRow upserted = authors.upsertAuthor( + committedId, + "Grace Murray Hopper", + "Rear admiral" + ); + + check(upserted != null, "UpsertAuthor returned no row"); + checkEquals(committedId, upserted.id(), "UpsertAuthor id"); + checkEquals("Grace Murray Hopper", upserted.name(), "UpsertAuthor name"); + checkEquals("Rear admiral", upserted.bio(), "UpsertAuthor bio"); + check(upserted.updatedAt() != null, "UpsertAuthor left updatedAt null"); + + System.out.println("upserted: " + upserted.name() + " / " + upserted.bio()); + 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 27ddf50..19eab9c 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -20,9 +20,12 @@ # 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. +# enum type and add an enum, an array, and a 'JSONB' column to 'books' and an +# 'updated_at' column to 'authors', so the generated package holds the shared +# 'AuthorsRow' and 'BooksRow' records, the 'BookFormat' enum, and one +# repository whose methods cover every named query, including the named +# parameter filter, the 'now()' rename, the grouped book count, the upsert, and +# the id-list read. # # Requirements: JDK 21, Maven, psql, and a reachable PostgreSQL server. # @@ -246,7 +249,8 @@ generated_count=$(find "${generated_root}" -type f -name '*.java' | wc -l) for method in createAuthor getAuthor findAuthor listAuthors updateAuthorBio deleteAuthor \ searchAuthors countAuthors listAuthorPage createBook listAuthorBooks \ - createCatalogedBook listBooksByFormat; do + createCatalogedBook listBooksByFormat filterAuthors renameAuthor \ + countBooksByAuthor upsertAuthor listAuthorsByIds; do grep -q " ${method}(" "${generated_package}/AuthorRepository.java" \ || fail "the generated repository is missing the ${method} method" done @@ -254,6 +258,9 @@ done grep -q "LocalDateTime createdAt" "${generated_package}/AuthorsRow.java" \ || fail "the generated row is missing the column the second migration adds" +grep -q "LocalDateTime updatedAt" "${generated_package}/AuthorsRow.java" \ + || fail "the generated row is missing the column the fourth migration adds" + diff -r "${first_generation}" "${generated_root}" \ || fail "generating from the sample directory did not restore its first generated output" @@ -287,6 +294,7 @@ PGPASSWORD="${db_password}" psql \ --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" \ + --file "${sample_dir}/sql/migrations/V4__add_author_updated_at.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 ceaa651..d98ec52 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -302,7 +302,9 @@ CREATE FUNCTION greet_listener(text) RETURNS text AS $$ * {@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}. + * assignment binds {@code $4} while the key binds {@code $3}, together with + * a read of unqualified columns over a {@code LEFT JOIN} filtered by a cast + * array placeholder compared with {@code &&}. */ private static final String CATALOG_QUERIES = """ -- name: GetAlbum :one @@ -334,6 +336,12 @@ INSERT INTO albums ( UPDATE albums SET title = $1, tags = $2, catalog_no = $4 WHERE album_id = $3; + + -- name: ListAlbumsByTags :many + SELECT album_id, title, name, catalog_no, tags + FROM albums + LEFT JOIN studios ON albums.studio_id = studios.studio_id + WHERE tags && $1::varchar[]; """; /** @@ -415,13 +423,21 @@ region text NOT NULL REFERENCES region (code), ALTER TABLE stage ADD COLUMN opened_at TIMESTAMP NOT NULL DEFAULT now(); """; - /** Queries over the tables the migration directory leaves. */ + /** + * Queries over the tables the migration directory leaves, including an + * insert that writes {@code NOW()} between its placeholders before + * returning the generated key. + */ private static final String MIGRATED_QUERIES = """ -- name: CreateRegion :one INSERT INTO region (code, title) VALUES ($1, $2) RETURNING *; -- name: GetStage :one SELECT id, handle, title, region, opened_at FROM stage WHERE handle = $1 AND region = $2; + + -- name: CreateStage :one + INSERT INTO stage (handle, title, region, opened_at, setting, past_settings, keywords) + VALUES ($1, $2, $3, NOW(), $4, $5, $6) RETURNING id; """; private static final UUID EXTERNAL_ID = UUID.fromString("3f2504e0-4f89-11d3-9a0c-0305e82c3301"); @@ -2297,6 +2313,13 @@ INSERT INTO documents (id, payload, config) * 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. + * + *

A read of unqualified columns over {@code albums LEFT JOIN studios} + * resolves each of them to its own table and takes one {@code List} + * parameter for the {@code varchar[]} cast placeholder its {@code &&} + * predicate compares with the array column, so an overlapping tag list + * returns the album beside its studio name and a non-overlapping one + * returns nothing. */ @Test void shouldExecuteGeneratedCatalogQueriesOverBooktestSchemaConstructs() throws Exception { @@ -2469,6 +2492,52 @@ void shouldExecuteGeneratedCatalogQueriesOverBooktestSchemaConstructs() throws E assertEquals(List.of("jazz", "reissue"), component(reissued, "tags")); assertEquals("BN-1002", component(reissued, "catalogNo")); assertEquals(live, component(reissued, "kind")); + + Method listByTagsMethod = repository.getClass().getMethod( + "listAlbumsByTags", + List.class + ); + + assertEquals( + List.of(List.class), + List.of(listByTagsMethod.getParameterTypes()) + ); + + List overlapping = (List) listByTagsMethod.invoke( + repository, + List.of("reissue", "classical") + ); + + assertEquals(1, overlapping.size()); + + Object tagged = overlapping.get(0); + + assertEquals( + List.of("albumId", "title", "name", "catalogNo", "tags"), + recordComponentNames(tagged) + ); + + assertEquals( + List.of( + Integer.class, + String.class, + String.class, + String.class, + List.class + ), + recordComponentTypes(tagged) + ); + + assertEquals(albumId, component(tagged, "albumId")); + assertEquals("Night Sessions, Vol. 2", component(tagged, "title")); + assertEquals("Blue Note", component(tagged, "name")); + assertEquals("BN-1002", component(tagged, "catalogNo")); + assertEquals(List.of("jazz", "reissue"), component(tagged, "tags")); + + assertEquals( + List.of(), + listByTagsMethod.invoke(repository, List.of("classical")) + ); } } @@ -3034,6 +3103,12 @@ void shouldRollBackGeneratedOperationsOnCallerOwnedConnection() throws Exception * the compiler loads as its schema source, in file-name order, and the * generated code writes and reads the migrated tables through the renamed * table and its added column. + * + *

The insert whose {@code VALUES} list writes {@code NOW()} between its + * placeholders binds the remaining columns in placeholder order, with the + * generated enum and the two array columns among them, and returns the + * generated key, so PostgreSQL stores the bound values and its own + * timestamp. */ @Test void shouldExecuteGeneratedCodeOverAMigratedSchemaAgainstPostgres() throws Exception { @@ -3105,6 +3180,77 @@ INSERT INTO stage (handle, setting, title, region) assertEquals("Main Hall", component(stage, "title")); assertEquals("north", component(stage, "region")); assertNotNull(component(stage, "openedAt")); + + Class stageSetting = Class.forName( + "generated.StageSetting", + true, + classLoader + ); + + Object[] settings = stageSetting.getEnumConstants(); + + Method createStageMethod = repository.getClass().getMethod( + "createStage", + String.class, + String.class, + String.class, + stageSetting, + List.class, + List.class + ); + + assertEquals( + List.of( + String.class, + String.class, + String.class, + stageSetting, + List.class, + List.class + ), + List.of(createStageMethod.getParameterTypes()) + ); + + Object createdStage = createStageMethod.invoke( + repository, + "west-wing", + "West Wing", + "north", + settings[0], + List.of(settings[1], settings[0]), + List.of("seated", "historic") + ); + + assertNotNull(createdStage); + + Object createdStageId = component(createdStage, "id"); + + assertNotNull(createdStageId); + + try ( + Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet stored = statement.executeQuery( + "SELECT setting, past_settings, keywords, opened_at FROM stage WHERE id = " + + createdStageId + ) + ) { + assertTrue(stored.next()); + + assertEquals("indoor", stored.getString("setting")); + + assertArrayEquals( + new String[] { "outdoor", "indoor" }, + (Object[]) stored.getArray("past_settings").getArray() + ); + + assertArrayEquals( + new String[] { "seated", "historic" }, + (Object[]) stored.getArray("keywords").getArray() + ); + + assertNotNull(stored.getTimestamp("opened_at")); + } } }