Skip to content

Single return type in Hibernate Search

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?

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;
  }
}

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]);
}

Also note that this could be significantly less awkward if you upgraded to Hibernate Search 6.

You’d 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(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 { 
        ...
    } 
}