Let’s say I have an app with many different entities, which do not have relations between each other.
I would like to create a search that queries all of them, but returns a unified type, i.e:
class SearchResult { String stype; String title; String teaser; }
So my idea was to index the entities and put their values into one single index (with same index fields):
@Indexed(index = "idx_search") class Book { @Field(name = "stype", analyze = ...) final String stype = "BOOK"; @Field(name = "title", analyze = ...) String bookTitle; @Field(name = "teaser", analyze = ...) String bookBlurb ; } @Indexed(index = "idx_search") class Person{ @Field(name = "stype", analyze = ...) final String stype = "PERSON"; @Field(name = "title", analyze = ...) String fullname; @Field(name = "teaser", analyze = ...) String profileIntroText; } @Indexed(index = "idx_search") class Location{ @Field(name = "stype", analyze = ...) final String stype = "LOCATION"; @Field(name = "title", analyze = ...) String streetPcAndCity; @Field(name = "teaser", analyze = ...) String wikiIntoText; }
As you can see, the index name and the fields names are the same on all entities.
Now I want to query them getting such results:
SearchResult[stype: PERSON, title: Spongebob, teaser: A funny sponge] SearchResult[stype: BOOK, title: Clean Architecture , teaser: A Craftsmans Guide to Software...] SearchResult[stype: PERSON, title: Patric, teaser: A funny seastar] SearchResult[stype: LOCATION, title: Hannover, teaser: A city in Germany]
So SearchResult is not an entity, but just merging the results into a single type. The indexing works, but I have to pass the entity type into the query and the QueryBuilder when searching:
final QueryBuilder queryBuilder = fullTextEntityManager .getSearchFactory() .buildQueryBuilder() .forEntity(SearchResult.class) .get(); ...
Hibernate then returns this error message:
HSEARCH000331: Can't build query for type 'SearchResult' which is neither configured nor has any configured sub-types.
Do you think this there is a way to make this work?
Advertisement
Answer
Note that you don’t need to assign each type to the same index; Hibernate Search is perfectly capable of searching through multiple indexes in a single query. And performance would likely be identical (Lucene indexes are often split into multiple segments under the hood anyway).
That being said, here’s how you could do it, assuming there’s a constructor in SearchResult
:
class SearchResult { String stype; String title; String teaser; public SearchResult(String stype, String title, String teaser) { this.stype = stype; this.title = title; this.teaser = teaser; } }
I’ll start with Hibernate Search 6, since this could be significantly less awkward if you upgraded. You’ll find an older answer for Hibernate Search 5 below.
For Hibernate Search 6, you’ll need a few changes to your mapping:
@Indexed class Book { @KeywordField(name = "stype", projectable = Projectable.YES) @IndexingDependency(reindexOnUpdate = ReindexOnUpdate.NO) final String stype = "BOOK"; @FullTextField(name = "title", projectable = Projectable.YES, analyzer = ...) String bookTitle; @FullTextField(name = "teaser", projectable = Projectable.YES, analyzer = ...) String bookBlurb ; } @Indexed class Person{ @KeywordField(name = "stype", projectable = Projectable.YES) @IndexingDependency(reindexOnUpdate = ReindexOnUpdate.NO) final String stype = "PERSON"; @FullTextField(name = "title", projectable = Projectable.YES, analyzer = ...) String fullname; @FullTextField(name = "teaser", projectable = Projectable.YES, analyzer = ...) String profileIntroText; } @Indexed class Location{ @KeywordField(name = "stype", projectable = Projectable.YES) @IndexingDependency(reindexOnUpdate = ReindexOnUpdate.NO) final String stype = "LOCATION"; @FullTextField(name = "title", projectable = Projectable.YES, analyzer = ...) String streetPcAndCity; @FullTextField(name = "teaser", projectable = Projectable.YES, analyzer = ...) String wikiIntoText; }
But then I think the improvements when searching would be worth the trouble:
List<SearchResult> hits = Search.session(entityManager) .search(Arrays.asList(Book.class, Person.class, Location.class)) .select(f -> f.composite( SearchResult::new, f.field("stype", String.class), f.field("title", String.class), f.field("teaser", String.class))) .where(f -> ...) .fetchHits( 20 );
Still in Hibernate Search 6 (though I believe you’ll need 6.1 at least), you could even use an interface:
interface Searchable { @KeywordField(projectable = Projectable.YES, analyzer = ...) String getStype(); @FullTextField(projectable = Projectable.YES, analyzer = ...) String getTitle(); @FullTextField(projectable = Projectable.YES, analyzer = ...) String getTeaser(); } @Indexed class Book implements Searchable { String bookTitle; String bookBlurb; @Override @javax.persistence.Transient @IndexingDependency(reindexOnUpdate = ReindexOnUpdate.NO) String getStype() { return "BOOK"; } @Override @javax.persistence.Transient @IndexingDependency(derivedFrom = @ObjectPath( @PropertyValue(propertyName = "bookTitle") )) String getTitle() { return bookTitle; } @Override @javax.persistence.Transient @IndexingDependency(derivedFrom = @ObjectPath( @PropertyValue(propertyName = "bookTitle") )) String getTeaser() { return bookBlurb; } } @Indexed class Person implements Searchable { String fullname; String profileIntroText; @Override @javax.persistence.Transient @IndexingDependency(reindexOnUpdate = ReindexOnUpdate.NO) String getStype() { return "PERSON"; } @Override @javax.persistence.Transient @IndexingDependency(derivedFrom = @ObjectPath( @PropertyValue(propertyName = "bookTitle") )) String getTitle() { return bookTitle; } @Override @javax.persistence.Transient @IndexingDependency(derivedFrom = @ObjectPath( @PropertyValue(propertyName = "bookTitle") )) String getTeaser() { return bookBlurb; } } @Indexed class Location implements Searchable { String streetPcAndCity; String wikiIntoText; @Override @javax.persistence.Transient @IndexingDependency(reindexOnUpdate = ReindexOnUpdate.NO) String getStype() { return "LOCATION"; } @Override @javax.persistence.Transient @IndexingDependency(derivedFrom = @ObjectPath( @PropertyValue(propertyName = "streetPcAndCity") )) String getTitle() { return streetPcAndCity; } @Override @javax.persistence.Transient @IndexingDependency(derivedFrom = @ObjectPath( @PropertyValue(propertyName = "wikiIntoText") )) String getTeaser() { return wikiIntoText; } }
Then you could search that way:
List<SearchResult> hits = Search.session(entityManager) .search(Searchable.class) .select(f -> f.composite( SearchResult::new, f.field("stype", String.class), f.field("title", String.class), f.field("teaser", String.class))) .where(f -> ...) .fetchHits( 20 );
And alternatively, you would be able to load the entities directly:
List<Searchable> hits = Search.session(entityManager) .search(Searchable.class) .where(f -> ...) .fetchHits( 20 ); for (Searchable hit : hits) { String stype = hit.getStype(); String title = hit.getTitle(); String teaser = hit.getTeaser(); if ( hit instanceof Book ) { ... } else if ( hit instanceof Location ) { ... } else { ... } }
And starting with Hibernate Search 6.2, you can make the projection even simpler by annotating the projection class with @ProjectionConstructor
(and compiling with the -parameters
compiler flag):
class SearchResult { String stype; String title; String teaser; @ProjectionConstructor public SearchResult(String stype, String title, String teaser) { this.stype = stype; this.title = title; this.teaser = teaser; } }
Then you could search that way:
List<SearchResult> hits = Search.session(entityManager) .search(Searchable.class) .select(SearchResult.class) .where(f -> ...) .fetchHits( 20 );
Older answer for Hibernate Search 5:
Mark your fields as stored:
@Indexed class Book { @Field(name = "stype", store = Store.YES, analyze = ...) final String stype = "BOOK"; @Field(name = "title", store = Store.YES, analyze = ...) String bookTitle; @Field(name = "teaser", store = Store.YES, analyze = ...) String bookBlurb ; } @Indexed class Person{ @Field(name = "stype", store = Store.YES, analyze = ...) final String stype = "PERSON"; @Field(name = "title", store = Store.YES, analyze = ...) String fullname; @Field(name = "teaser", store = Store.YES, analyze = ...) String profileIntroText; } @Indexed class Location{ @Field(name = "stype", store = Store.YES, analyze = ...) final String stype = "LOCATION"; @Field(name = "title", store = Store.YES, analyze = ...) String streetPcAndCity; @Field(name = "teaser", store = Store.YES, analyze = ...) String wikiIntoText; }
Then query like this:
FullTextEntityManager fullTextEntityManager = Search.getFullTextEntityManager( entityManager); final QueryBuilder queryBuilder = fullTextEntityManager .getSearchFactory() .buildQueryBuilder() .forEntity(Book.class) .get(); Query luceneQuery = ...; FullTextQuery query = fullTextEntityManager.createFullTextQuery(query, Book.class, Person.class, Location.class); query.setProjection("stype", "title", "teaser"); query.setMaxResults(20); List<Object[]> arrayResults = query.list(); List<SearchResult> hits = new ArrayList<>(); for (Object[] array : arrayResults) { hits.add(new SearchResult((String) array[0], (String) array[1], (String) array[2]); }