Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
7 changes: 6 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()
}

Expand Down Expand Up @@ -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")
}

Expand Down
9 changes: 9 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ default List<Genre> findAllOrderedByName() {
default List<GenreMovieCount> 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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,7 +18,7 @@ public interface MovieGenreRepository extends EntityRepository<MovieGenre, Movie
default List<Genre> findGenres(Movie movie) {
return select(Genre.class)
.where(MovieGenre_.movie, movie)
.orderByAny(Genre_.name)
.orderBy(MovieGenre_.genre.name)
.getResultList();
}

Expand All @@ -31,7 +30,7 @@ default List<Genre> findGenres(Movie movie) {
default List<GenreRatingStatistics> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ default List<MovieSummary> 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();
}
Expand All @@ -50,12 +50,15 @@ default List<MovieSummary> 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<MovieSummary> scrollByGenre(Genre genre, Scrollable<MovieSummary> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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_;
Expand All @@ -35,7 +33,7 @@ default List<FilmographyEntry> 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();
}

Expand All @@ -56,7 +54,7 @@ default List<RelatedMovie> findMoviesSharingCast(List<Person> 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();
Expand All @@ -66,7 +64,7 @@ default List<RelatedMovie> findMoviesSharingCast(List<Person> castMembers, Movie
default List<ProlificActor> 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();
Expand Down
12 changes: 7 additions & 5 deletions src/main/java/st/orm/demo/imdb/repository/RatingRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -36,17 +37,18 @@ default List<Rating> findTopRated(int minimumVoteCount, int limit) {
* builder is assembled.
*/
default List<Rating> findTopMovies(Genre genre, TopMoviesSort sortBy, int minimumVoteCount, int limit) {
QueryBuilder<Rating, Rating, Movie> query = select()
.where(Rating_.voteCount, GREATER_THAN_OR_EQUAL, minimumVoteCount);
QueryBuilder<Data, Rating, Movie> 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();
}
Expand Down
11 changes: 8 additions & 3 deletions src/test/java/st/orm/demo/imdb/EntitySchemaValidationTest.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
});
Expand All @@ -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<Person> morgan = Ref.of(personRepository.getById("nm0000151"));

// The refresh runs the way the service does it: upsert writes the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand Down