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 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"));
+ }
}
}