From ba5548d1a6cca5b4837360479bd31589b5d56c28 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Wed, 12 Aug 2026 17:26:50 +0200 Subject: [PATCH 1/3] Track the Storm 1.14.0 release A join now widens the query, so a clause that names a joined entity uses the plain call and the Any variants are gone. Where a query joins nothing but references another entity of its graph, widen() says so; scrollByGenre narrows back to the projection, because a scroll key has to identify one row of the root and a key on the junction table does not. SqlCapture.run is record, named so a Kotlin caller cannot resolve it to kotlin.run. @StormTest rolls each test back, so the tests no longer pick rows other methods leave alone. Resolves Storm from mavenLocal until 1.14.0 is on Maven Central and the Gradle Plugin Portal. --- build.gradle.kts | 4 +++- settings.gradle.kts | 9 +++++++++ .../demo/imdb/repository/MovieGenreRepository.java | 5 +++-- .../demo/imdb/repository/MovieSummaryRepository.java | 9 ++++++--- .../demo/imdb/repository/PrincipalRepository.java | 8 +++++--- .../orm/demo/imdb/repository/RatingRepository.java | 12 +++++++----- .../imdb/repository/MovieViewRepositoryTest.java | 2 +- .../imdb/repository/PersonGalleryRepositoryTest.java | 6 +++--- .../imdb/repository/WatchlistRepositoryTest.java | 6 +++--- 9 files changed, 40 insertions(+), 21 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 21d311d..e4507c6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { // The Storm plugin imports the BOM, adds storm-java21 and storm-core, wires the // metamodel annotation processor, and enables the preview flags that Storm's Java // String Templates (JEP 430) require on compile, test, and run (BootRun included). - id("st.orm") version "1.13.1" + id("st.orm") version "1.14.0" } group = "st.orm.demo" @@ -19,6 +19,8 @@ java { } repositories { + // TEMPORARY: resolves Storm 1.14.0 from a local build. Remove once it is on Maven Central. + mavenLocal() mavenCentral() } diff --git a/settings.gradle.kts b/settings.gradle.kts index 71395e1..3559258 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,10 @@ +// TEMPORARY: mavenLocal() resolves the st.orm plugin from a local Storm build. +// Remove this pluginManagement block once 1.14.0 is on the Gradle Plugin Portal. +pluginManagement { + repositories { + mavenLocal() + gradlePluginPortal() + } +} + rootProject.name = "storm-imdb-demo" diff --git a/src/main/java/st/orm/demo/imdb/repository/MovieGenreRepository.java b/src/main/java/st/orm/demo/imdb/repository/MovieGenreRepository.java index ecd4341..65bb98b 100644 --- a/src/main/java/st/orm/demo/imdb/repository/MovieGenreRepository.java +++ b/src/main/java/st/orm/demo/imdb/repository/MovieGenreRepository.java @@ -19,7 +19,8 @@ public interface MovieGenreRepository extends EntityRepository findGenres(Movie movie) { return select(Genre.class) .where(MovieGenre_.movie, movie) - .orderByAny(Genre_.name) + .widen() + .orderBy(Genre_.name) .getResultList(); } @@ -31,7 +32,7 @@ default List findGenres(Movie movie) { default List findGenreRatingStatistics(int minimumMovieCount, int limit) { return select(GenreRatingStatistics.class, RAW."\{Genre.class}, AVG(\{Rating_.averageRating}), COUNT(*)") .innerJoin(Rating.class).on(Movie.class) - .groupByAny(Genre_.id, Genre_.name) + .groupBy(Genre_.id, Genre_.name) .having(RAW."COUNT(*) >= \{minimumMovieCount}") .orderByDescending(RAW."AVG(\{Rating_.averageRating})") .limit(limit) diff --git a/src/main/java/st/orm/demo/imdb/repository/MovieSummaryRepository.java b/src/main/java/st/orm/demo/imdb/repository/MovieSummaryRepository.java index c983910..8bd9b34 100644 --- a/src/main/java/st/orm/demo/imdb/repository/MovieSummaryRepository.java +++ b/src/main/java/st/orm/demo/imdb/repository/MovieSummaryRepository.java @@ -41,7 +41,7 @@ default List findTitleSuggestions(String query, int limit) { return select() .innerJoin(Rating.class).on(MovieSummary.class) .where(RAW."LOWER(\{MovieSummary_.primaryTitle}) LIKE LOWER(\{pattern})") - .orderByDescendingAny(Rating_.voteCount) + .orderByDescending(Rating_.voteCount) .limit(limit) .getResultList(); } @@ -50,12 +50,15 @@ default List findTitleSuggestions(String query, int limit) { * All movies in a genre with keyset scrolling. The junction table has a * composite key and cannot be scrolled directly, so the scroll runs on * the movie's simple primary key with a JOIN through the junction table, - * resolved automatically against the projection by table. + * resolved automatically against the projection by table. The scroll key + * has to identify one row of the projection, which a key on the junction + * table would not. */ default Window scrollByGenre(Genre genre, Scrollable scrollable) { return select() .innerJoin(MovieGenre.class).on(MovieSummary.class) - .whereAny(predicate -> predicate.whereAny(MovieGenre_.genre, genre)) + .where(MovieGenre_.genre, genre) + .narrow(MovieSummary.class) .scroll(scrollable); } } diff --git a/src/main/java/st/orm/demo/imdb/repository/PrincipalRepository.java b/src/main/java/st/orm/demo/imdb/repository/PrincipalRepository.java index 4e12fc1..3024907 100644 --- a/src/main/java/st/orm/demo/imdb/repository/PrincipalRepository.java +++ b/src/main/java/st/orm/demo/imdb/repository/PrincipalRepository.java @@ -35,7 +35,7 @@ default List findFilmography(Person person) { return select(FilmographyEntry.class, RAW."\{Principal.class}, \{Movie.class}, \{Rating_.averageRating}") .innerJoin(Rating.class).on(Movie.class) .where(Principal_.person, person) - .orderByDescendingAny(Rating_.averageRating) + .orderByDescending(Rating_.averageRating) .getResultList(); } @@ -56,7 +56,8 @@ default List findMoviesSharingCast(List castMembers, Movie return select(RelatedMovie.class, RAW."\{Movie.class}, COUNT(*)") .where(predicate -> predicate.where(Principal_.person, IN, castMembers) .and(predicate.where(Principal_.movie, NOT_EQUALS, excludedMovie))) - .groupByAny(Movie_.id, Movie_.primaryTitle, Movie_.originalTitle, Movie_.startYear, Movie_.runtimeMinutes) + .widen() + .groupBy(Movie_.id, Movie_.primaryTitle, Movie_.originalTitle, Movie_.startYear, Movie_.runtimeMinutes) .orderByDescending(RAW."COUNT(*)") .limit(limit) .getResultList(); @@ -66,7 +67,8 @@ default List findMoviesSharingCast(List castMembers, Movie default List findMostProlificActors(int limit) { return select(ProlificActor.class, RAW."\{Person.class}, COUNT(*)") .where(Principal_.category, IN, List.of("actor", "actress")) - .groupByAny(Person_.id, Person_.primaryName, Person_.birthYear, Person_.deathYear) + .widen() + .groupBy(Person_.id, Person_.primaryName, Person_.birthYear, Person_.deathYear) .orderByDescending(RAW."COUNT(*)") .limit(limit) .getResultList(); diff --git a/src/main/java/st/orm/demo/imdb/repository/RatingRepository.java b/src/main/java/st/orm/demo/imdb/repository/RatingRepository.java index 281c9e0..cdbc8f9 100644 --- a/src/main/java/st/orm/demo/imdb/repository/RatingRepository.java +++ b/src/main/java/st/orm/demo/imdb/repository/RatingRepository.java @@ -4,6 +4,7 @@ import static st.orm.Operator.IS_NOT_NULL; import java.util.List; +import st.orm.Data; import st.orm.demo.imdb.model.Genre; import st.orm.demo.imdb.model.Movie; import st.orm.demo.imdb.model.MovieGenre; @@ -36,17 +37,18 @@ default List findTopRated(int minimumVoteCount, int limit) { * builder is assembled. */ default List findTopMovies(Genre genre, TopMoviesSort sortBy, int minimumVoteCount, int limit) { - QueryBuilder query = select() - .where(Rating_.voteCount, GREATER_THAN_OR_EQUAL, minimumVoteCount); + QueryBuilder query = select() + .where(Rating_.voteCount, GREATER_THAN_OR_EQUAL, minimumVoteCount) + .widen(); if (genre != null) { query = query.innerJoin(MovieGenre.class).on(Movie.class) - .whereAny(predicate -> predicate.whereAny(MovieGenre_.genre, genre)); + .where(MovieGenre_.genre, genre); } query = switch (sortBy) { case RATING -> query.orderByDescending(Rating_.averageRating); case YEAR -> query - .whereAny(predicate -> predicate.whereAny(Movie_.startYear, IS_NOT_NULL)) - .orderByDescendingAny(Movie_.startYear, Rating_.averageRating); + .where(Movie_.startYear, IS_NOT_NULL) + .orderByDescending(Movie_.startYear, Rating_.averageRating); }; return query.limit(limit).getResultList(); } diff --git a/src/test/java/st/orm/demo/imdb/repository/MovieViewRepositoryTest.java b/src/test/java/st/orm/demo/imdb/repository/MovieViewRepositoryTest.java index 47cb999..2aab31b 100644 --- a/src/test/java/st/orm/demo/imdb/repository/MovieViewRepositoryTest.java +++ b/src/test/java/st/orm/demo/imdb/repository/MovieViewRepositoryTest.java @@ -34,7 +34,7 @@ void findRecentViewsStaysOnTheViewTableThanksToRef(ORMTemplate orm, SqlCapture c @Test void recordingAViewInsertsByIdWithoutLoadingTheMovie(ORMTemplate orm, SqlCapture capture) { MovieViewRepository movieViewRepository = orm.repository(MovieViewRepository.class); - capture.run(() -> + capture.record(() -> movieViewRepository.insert( // Older than the seeded views so it never becomes the newest. new MovieView(0L, Ref.of(Movie.class, "tt0110912"), Instant.parse("2026-06-30T00:00:00Z")))); diff --git a/src/test/java/st/orm/demo/imdb/repository/PersonGalleryRepositoryTest.java b/src/test/java/st/orm/demo/imdb/repository/PersonGalleryRepositoryTest.java index ccdcc29..6f87810 100644 --- a/src/test/java/st/orm/demo/imdb/repository/PersonGalleryRepositoryTest.java +++ b/src/test/java/st/orm/demo/imdb/repository/PersonGalleryRepositoryTest.java @@ -28,7 +28,7 @@ void aGalleryRoundTripsItsPhotosThroughTheJsonColumn(ORMTemplate orm, SqlCapture new Photo("https://upload.wikimedia.org/keanu-2.jpg") ); - capture.run(() -> { + capture.record(() -> { galleryRepository.insert(new PersonGallery(keanu, photos, Instant.parse("2026-07-03T10:00:00Z"))); assertEquals(photos, galleryRepository.getById(keanu).photos()); }); @@ -40,8 +40,8 @@ void aGalleryRoundTripsItsPhotosThroughTheJsonColumn(ORMTemplate orm, SqlCapture void aRefreshedGalleryReplacesTheStoredPhotos(ORMTemplate orm) { PersonRepository personRepository = orm.repository(PersonRepository.class); PersonGalleryRepository galleryRepository = orm.repository(PersonGalleryRepository.class); - // Morgan Freeman is not touched by other tests in this class — the - // @StormTest database is shared across the class's test methods. + // @StormTest rolls each test back, so this person has no gallery yet + // however the class orders its methods. Ref morgan = Ref.of(personRepository.getById("nm0000151")); // The refresh runs the way the service does it: upsert writes the diff --git a/src/test/java/st/orm/demo/imdb/repository/WatchlistRepositoryTest.java b/src/test/java/st/orm/demo/imdb/repository/WatchlistRepositoryTest.java index f6ac868..6e86d9d 100644 --- a/src/test/java/st/orm/demo/imdb/repository/WatchlistRepositoryTest.java +++ b/src/test/java/st/orm/demo/imdb/repository/WatchlistRepositoryTest.java @@ -23,11 +23,11 @@ class WatchlistRepositoryTest { void theToggleCycleExistsInsertExistsRemoveWorksOnTheMovieKey(ORMTemplate orm, SqlCapture capture) { MovieRepository movieRepository = orm.repository(MovieRepository.class); WatchlistRepository watchlistRepository = orm.repository(WatchlistRepository.class); - // Pulp Fiction is not touched by other tests in this class — the - // @StormTest database is shared across the class's test methods. + // @StormTest rolls each test back, so the watchlist starts out empty + // however the class orders its methods. Movie pulpFiction = movieRepository.getById("tt0110912"); - capture.run(() -> { + capture.record(() -> { assertFalse(watchlistRepository.existsById(pulpFiction)); watchlistRepository.insert(new Watchlist(pulpFiction, Instant.now())); From 00f4160ea399adb90649116a9aa2ba00dd24da6d Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Wed, 12 Aug 2026 17:36:41 +0200 Subject: [PATCH 2/3] Order the movie's genres by a path from the query root findGenres reaches the genre name through the junction table's foreign key rather than naming the genre table in short form, so the clause stays typed to the root and the query needs no widening. The SQL is unchanged. The remaining widen() calls stay: the two GROUP BY sites would resolve a foreign key path's id component to the junction column while the SELECT still projects the referenced table's key, which strict grouping rejects, and findTopMovies joins conditionally, so its builder is widened either way. --- .../java/st/orm/demo/imdb/repository/MovieGenreRepository.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/st/orm/demo/imdb/repository/MovieGenreRepository.java b/src/main/java/st/orm/demo/imdb/repository/MovieGenreRepository.java index 65bb98b..54f276e 100644 --- a/src/main/java/st/orm/demo/imdb/repository/MovieGenreRepository.java +++ b/src/main/java/st/orm/demo/imdb/repository/MovieGenreRepository.java @@ -19,8 +19,7 @@ public interface MovieGenreRepository extends EntityRepository findGenres(Movie movie) { return select(Genre.class) .where(MovieGenre_.movie, movie) - .widen() - .orderBy(Genre_.name) + .orderBy(MovieGenre_.genre.name) .getResultList(); } From 5cb9bb0a7bc3ff6bb791e3799cc1e8ba372a91a0 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Thu, 13 Aug 2026 12:02:53 +0200 Subject: [PATCH 3/3] Group by the entity the query counts A grouping states what one row stands for, and 1.14 emits the columns that express it, so these queries name the entity and stop enumerating its columns. The lists were there to satisfy the strictest dialect and had to be revisited whenever an entity gained a field; the dialect decides now. findMoviesSharingCast and findMostProlificActors reach the entity through the credit's own foreign keys, so they no longer widen either: the path starts at the query root and keeps the compile-time root check. The SQL is equivalent, and narrower where a key already determined the columns that were being listed. --- .../java/st/orm/demo/imdb/repository/GenreRepository.java | 2 +- .../st/orm/demo/imdb/repository/MovieGenreRepository.java | 3 +-- .../st/orm/demo/imdb/repository/PrincipalRepository.java | 8 ++------ 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/main/java/st/orm/demo/imdb/repository/GenreRepository.java b/src/main/java/st/orm/demo/imdb/repository/GenreRepository.java index e97ffd6..8914219 100644 --- a/src/main/java/st/orm/demo/imdb/repository/GenreRepository.java +++ b/src/main/java/st/orm/demo/imdb/repository/GenreRepository.java @@ -28,7 +28,7 @@ default List findAllOrderedByName() { default List findGenresWithMovieCounts() { return select(GenreMovieCount.class, RAW."\{Genre.class}, COUNT(*)") .innerJoin(MovieGenre.class).on(Genre.class) - .groupBy(Genre_.id, Genre_.name) + .groupBy(Genre_.id) .orderBy(Genre_.name) .getResultList(); } diff --git a/src/main/java/st/orm/demo/imdb/repository/MovieGenreRepository.java b/src/main/java/st/orm/demo/imdb/repository/MovieGenreRepository.java index 54f276e..3de77a4 100644 --- a/src/main/java/st/orm/demo/imdb/repository/MovieGenreRepository.java +++ b/src/main/java/st/orm/demo/imdb/repository/MovieGenreRepository.java @@ -4,7 +4,6 @@ import java.util.List; import st.orm.demo.imdb.model.Genre; -import st.orm.demo.imdb.model.Genre_; import st.orm.demo.imdb.model.Movie; import st.orm.demo.imdb.model.MovieGenre; import st.orm.demo.imdb.model.MovieGenrePk; @@ -31,7 +30,7 @@ default List findGenres(Movie movie) { default List findGenreRatingStatistics(int minimumMovieCount, int limit) { return select(GenreRatingStatistics.class, RAW."\{Genre.class}, AVG(\{Rating_.averageRating}), COUNT(*)") .innerJoin(Rating.class).on(Movie.class) - .groupBy(Genre_.id, Genre_.name) + .groupBy(MovieGenre_.genre) .having(RAW."COUNT(*) >= \{minimumMovieCount}") .orderByDescending(RAW."AVG(\{Rating_.averageRating})") .limit(limit) diff --git a/src/main/java/st/orm/demo/imdb/repository/PrincipalRepository.java b/src/main/java/st/orm/demo/imdb/repository/PrincipalRepository.java index 3024907..38fcc48 100644 --- a/src/main/java/st/orm/demo/imdb/repository/PrincipalRepository.java +++ b/src/main/java/st/orm/demo/imdb/repository/PrincipalRepository.java @@ -6,9 +6,7 @@ import java.util.List; import st.orm.demo.imdb.model.Movie; -import st.orm.demo.imdb.model.Movie_; import st.orm.demo.imdb.model.Person; -import st.orm.demo.imdb.model.Person_; import st.orm.demo.imdb.model.Principal; import st.orm.demo.imdb.model.PrincipalPk; import st.orm.demo.imdb.model.Principal_; @@ -56,8 +54,7 @@ default List findMoviesSharingCast(List castMembers, Movie return select(RelatedMovie.class, RAW."\{Movie.class}, COUNT(*)") .where(predicate -> predicate.where(Principal_.person, IN, castMembers) .and(predicate.where(Principal_.movie, NOT_EQUALS, excludedMovie))) - .widen() - .groupBy(Movie_.id, Movie_.primaryTitle, Movie_.originalTitle, Movie_.startYear, Movie_.runtimeMinutes) + .groupBy(Principal_.movie) .orderByDescending(RAW."COUNT(*)") .limit(limit) .getResultList(); @@ -67,8 +64,7 @@ default List findMoviesSharingCast(List castMembers, Movie default List findMostProlificActors(int limit) { return select(ProlificActor.class, RAW."\{Person.class}, COUNT(*)") .where(Principal_.category, IN, List.of("actor", "actress")) - .widen() - .groupBy(Person_.id, Person_.primaryName, Person_.birthYear, Person_.deathYear) + .groupBy(Principal_.person) .orderByDescending(RAW."COUNT(*)") .limit(limit) .getResultList();