diff --git a/README.md b/README.md index 5d0cb7c..3f59dca 100644 --- a/README.md +++ b/README.md @@ -135,9 +135,13 @@ Each part of the app demonstrates a Storm feature: ./gradlew test ``` -Repository tests run on an in-memory H2 database via `@StormTest`, so no -Docker is required. Tests receive an `ORMTemplate` and a `SqlCapture` as parameters, so -they can assert on the SQL Storm generates. +Repository tests run on an in-memory H2 database via `@StormTest`. Tests receive +an `ORMTemplate` and a `SqlCapture` as parameters, so they can assert on the SQL +Storm generates. `EntitySchemaValidationTest` runs on PostgreSQL instead, through +`@StormTest(database = POSTGRESQL)`: Storm starts a Testcontainers-managed +PostgreSQL once per test run and applies the Flyway migration to it, so the +entities are validated against the schema and dialect the application deploys +with. That one test needs Docker, like running the application does. The Playwright interface tests run against a live application: diff --git a/build.gradle.kts b/build.gradle.kts index 21d311d..b43cae6 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() } @@ -47,6 +49,9 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testRuntimeOnly("st.orm:storm-h2") testRuntimeOnly("com.h2database:h2:2.3.232") + // EntitySchemaValidationTest runs on PostgreSQL through @StormTest(database = POSTGRESQL); + // the module starts the container, the driver above is on the test runtime classpath already. + testImplementation("org.testcontainers:testcontainers-postgresql") testImplementation("com.microsoft.playwright:playwright:1.61.0") } 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/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 ecd4341..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; @@ -19,7 +18,7 @@ public interface MovieGenreRepository extends EntityRepository findGenres(Movie movie) { return select(Genre.class) .where(MovieGenre_.movie, movie) - .orderByAny(Genre_.name) + .orderBy(MovieGenre_.genre.name) .getResultList(); } @@ -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) - .groupByAny(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/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..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_; @@ -35,7 +33,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 +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))) - .groupByAny(Movie_.id, Movie_.primaryTitle, Movie_.originalTitle, Movie_.startYear, Movie_.runtimeMinutes) + .groupBy(Principal_.movie) .orderByDescending(RAW."COUNT(*)") .limit(limit) .getResultList(); @@ -66,7 +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")) - .groupByAny(Person_.id, Person_.primaryName, Person_.birthYear, Person_.deathYear) + .groupBy(Principal_.person) .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/EntitySchemaValidationTest.java b/src/test/java/st/orm/demo/imdb/EntitySchemaValidationTest.java index dc1630d..526c38a 100644 --- a/src/test/java/st/orm/demo/imdb/EntitySchemaValidationTest.java +++ b/src/test/java/st/orm/demo/imdb/EntitySchemaValidationTest.java @@ -1,6 +1,7 @@ package st.orm.demo.imdb; import static org.junit.jupiter.api.Assertions.assertTrue; +import static st.orm.test.TestDatabase.POSTGRESQL; import java.util.List; import org.junit.jupiter.api.Test; @@ -22,10 +23,14 @@ /** * Validates every entity against the database schema at the JDBC level: * column presence, type compatibility, nullability, primary keys, and - * foreign key consistency. The schema.sql script is the same DDL that - * Flyway applies in production. + * foreign key consistency. Unlike the other tests, which run on H2, this + * one runs on PostgreSQL in a Testcontainers-managed container and applies + * the Flyway migration itself, so the entities are checked against the + * schema the application deploys with, on the dialect it deploys on. The + * container starts once per test run; the class receives a database of its + * own inside it. */ -@StormTest(scripts = {"/schema.sql"}) +@StormTest(database = POSTGRESQL, scripts = {"/db/migration/V1__create_schema.sql"}) class EntitySchemaValidationTest { @Test 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()));