Add Hibernate 7 batches 2-6, batch 7, and batch 8: mapping styles, JPA annotations, natural IDs, @Immutable, stored procedures, in-memory test databases, JNDI mocking, proxies, associations, temporal mapping, named queries, HQL, Criteria API, EntityManager bootstrapping, Ehcache 3 L2 cache configuration, HikariCP connection pooling, Hibernate Validator CDI integration, aggregate functions, sorting, pagination, interceptors, and Hibernate Search 8 (Hibernate 7.4.5.Final + Spring Boot 4.1.1 + JDK 25)

This commit is contained in:
2026-09-20 06:06:42 +00:00
committed by Claude
commit 8568c0ce6c
330 changed files with 23668 additions and 0 deletions
@@ -0,0 +1,198 @@
package com.ankurm.hibernatedemo.search;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.hibernatedemo.HibernateDemoApplication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.util.List;
import org.hibernate.search.mapper.orm.Search;
import org.hibernate.search.mapper.orm.session.SearchSession;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Proves chapter 25's Hibernate Search claims: automatic indexing on persist, full-text search
* with fuzzy matching, exact keyword filtering, sortable range fields, searching through an
* {@code @IndexedEmbedded} association, and rebuilding the index with a {@code MassIndexer}.
*
* <p>All Movie titles in this class carry a unique per-test prefix, and every search query
* filters by that prefix -- the Lucene index, like the H2 database, is shared across every test
* method (and every OTHER test class in this repo that boots the same Spring context), so an
* unscoped query would also match movies indexed by earlier test runs still on disk under
* {@code target/lucene-indexes}.
*
* <p>Docs: docs/25-hibernate-search.md.
*/
@SpringBootTest(classes = HibernateDemoApplication.class)
class HibernateSearchTest {
@Autowired
private EntityManagerFactory emf;
@Test
void fullTextSearch_withFuzzyMatching_findsATypo() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Director coppola = new Director("Fts Francis Ford Coppola");
em.persist(coppola);
em.persist(new Movie("Fts The Godfather", "Drama", 1972, coppola));
em.persist(new Movie("Fts The Godfather Part II", "Drama", 1974, coppola));
em.getTransaction().commit();
SearchSession searchSession = Search.session(em);
// Deliberate typo: "Godfaher" instead of "Godfather" -- fuzzy(1) tolerates a
// one-character edit distance.
List<Movie> hits = searchSession.search(Movie.class)
.where(f -> f.match().field("title").matching("Godfaher").fuzzy(1))
.fetchHits(20);
em.close();
List<String> titles = hits.stream().map(Movie::getTitle).filter(t -> t.startsWith("Fts")).toList();
System.out.println("RESULT[search-fulltext-fuzzy]: searching title for 'Godfaher' "
+ "(a one-character typo of 'Godfather') with .fuzzy(1) matched: " + titles
+ " -- a plain SQL LIKE '%Godfaher%' would have matched nothing.");
assertThat(titles).containsExactlyInAnyOrder("Fts The Godfather", "Fts The Godfather Part II");
}
@Test
void keywordField_exactMatchOnly_noPartialOrCaseInsensitiveMatch() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Director lucas = new Director("Kwf George Lucas");
em.persist(lucas);
em.persist(new Movie("Kwf A New Hope", "Science Fiction", 1977, lucas));
em.persist(new Movie("Kwf Annie Hall", "Comedy", 1977, lucas));
em.getTransaction().commit();
SearchSession searchSession = Search.session(em);
List<Movie> exactMatch = searchSession.search(Movie.class)
.where(f -> f.bool()
.must(f.match().field("genre").matching("Science Fiction"))
.must(f.match().field("title").matching("Kwf")))
.fetchHits(20);
// A KeywordField compares the WHOLE stored value -- "science" alone (lowercase, partial)
// does not match "Science Fiction" the way a FullTextField's tokenized/lower-cased terms
// would.
List<Movie> partialLowercaseAttempt = searchSession.search(Movie.class)
.where(f -> f.bool()
.must(f.match().field("genre").matching("science"))
.must(f.match().field("title").matching("Kwf")))
.fetchHits(20);
em.close();
System.out.println("RESULT[search-keyword-exact-match]: @KeywordField genre matched by "
+ "the exact stored value 'Science Fiction' -> " + exactMatch.size() + " hit(s) | "
+ "the same field searched with the partial, lowercase 'science' -> "
+ partialLowercaseAttempt.size() + " hit(s) -- a KeywordField is compared whole,"
+ " unlike a FullTextField's tokenized and lower-cased terms.");
assertThat(exactMatch).hasSize(1);
assertThat(partialLowercaseAttempt).isEmpty();
}
@Test
void sortableGenericField_ordersByReleaseYear() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Director spielberg = new Director("Sgf Steven Spielberg");
em.persist(spielberg);
em.persist(new Movie("Sgf Jaws", "Thriller", 1975, spielberg));
em.persist(new Movie("Sgf E.T.", "Family", 1982, spielberg));
em.persist(new Movie("Sgf Jurassic Park", "Adventure", 1993, spielberg));
em.getTransaction().commit();
SearchSession searchSession = Search.session(em);
List<Movie> sortedNewestFirst = searchSession.search(Movie.class)
.where(f -> f.match().field("title").matching("Sgf"))
.sort(f -> f.field("releaseYear").desc())
.fetchHits(20);
em.close();
List<Integer> years = sortedNewestFirst.stream().map(Movie::getReleaseYear).toList();
System.out.println("RESULT[search-sortable-generic-field]: sort(f -> f.field("
+ "\"releaseYear\").desc()) over the 3 Sgf-prefixed movies -- years in the order "
+ "returned: " + years + " -- @GenericField(sortable = Sortable.YES) is what "
+ "makes this sort possible; the default is NOT sortable.");
assertThat(years).containsExactly(1993, 1982, 1975);
}
@Test
void indexedEmbedded_searchesThroughTheAssociation() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Director nolan = new Director("Iea Christopher Nolan");
Director tarantino = new Director("Iea Quentin Tarantino");
em.persist(nolan);
em.persist(tarantino);
em.persist(new Movie("Iea Inception", "Science Fiction", 2010, nolan));
em.persist(new Movie("Iea Pulp Fiction", "Crime", 1994, tarantino));
em.getTransaction().commit();
// director.name is a @KeywordField -- compared as one whole value, same as the genre
// field in the previous test -- so the query has to match the FULL stored director name,
// not a substring of it. That's why this searches "Iea Christopher Nolan" whole, not
// just "Nolan".
SearchSession searchSession = Search.session(em);
List<Movie> hits = searchSession.search(Movie.class)
.where(f -> f.bool()
.must(f.match().field("title").matching("Iea"))
.must(f.match().field("director.name").matching("Iea Christopher Nolan")))
.fetchHits(20);
em.close();
List<String> titles = hits.stream().map(Movie::getTitle).toList();
System.out.println("RESULT[search-indexed-embedded]: field 'director.name' matched the "
+ "full keyword value 'Iea Christopher Nolan' -- " + titles + " -- Director "
+ "itself carries no @Indexed annotation at all; its @KeywordField only exists "
+ "inside Movie's index because of @IndexedEmbedded on the director association.");
assertThat(titles).containsExactly("Iea Inception");
}
@Test
void massIndexer_rebuildsTheIndexFromTheDatabase() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Director scorsese = new Director("Mix Martin Scorsese");
em.persist(scorsese);
em.persist(new Movie("Mix Goodfellas", "Crime", 1990, scorsese));
em.getTransaction().commit();
SearchSession searchSession = Search.session(em);
long beforePurge = searchSession.search(Movie.class)
.where(f -> f.match().field("title").matching("Mix"))
.fetchTotalHitCount();
// Purge the index (NOT the database) to simulate an index that has gone stale or was
// never built -- the row is still in H2, only the Lucene index forgot about it.
searchSession.workspace().purge();
long afterPurgeBeforeReindex = searchSession.search(Movie.class)
.where(f -> f.match().field("title").matching("Mix"))
.fetchTotalHitCount();
try {
searchSession.massIndexer(Movie.class).startAndWait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
long afterMassIndexer = searchSession.search(Movie.class)
.where(f -> f.match().field("title").matching("Mix"))
.fetchTotalHitCount();
em.close();
System.out.println("RESULT[search-mass-indexer]: hits before purge=" + beforePurge
+ " | hits after workspace().purge() (row still in H2, index emptied)="
+ afterPurgeBeforeReindex + " | hits after massIndexer(Movie.class)."
+ "startAndWait() (index rebuilt straight from the database, no re-persisting)="
+ afterMassIndexer + ".");
assertThat(beforePurge).isEqualTo(1);
assertThat(afterPurgeBeforeReindex).isZero();
assertThat(afterMassIndexer).isEqualTo(1);
}
}