Every time an entity class is annotated with
@Indexed
, by default Hibernate Search will create a Lucene index just for that class. We can have as many entities, and as many separate indexes, as we wish. However, searching each index separately would be a very awkward and cumbersome approach.
Most Hibernate ORM data models already capture the various associations between entity classes. When we search an entity's Lucene index, shouldn't Hibernate Search follow those associations? In this section we will see how to make it do just that.
So far, the entity fields in our example application have been simple data types. The
App
class represents a table named APP
, and its member variables map to columns in that table. Now let's add a complex type field, for a second database table that is associated through a foreign key.
Online app stores usually support a range of different hardware devices. So we will create a new entity called Device
, representing devices for which an App
entity is available.
@Entity public class Device { @Id @GeneratedValue private Long id; @Column @Field private String manufacturer; @Column @Field private String name; @ManyToMany(mappedBy="supportedDevices", fetch=FetchType.EAGER, cascade = { CascadeType.ALL } ) @ContainedIn private Set<App> supportedApps; public Device() { } public Device(String manufacturer, String name, Set<App>supportedApps) { this.manufacturer = manufacturer; this.name = name; this.supportedApps = supportedApps; } // // Getters and setters for all fields... // }
Most details of this class should be familiar from Chapter 1, Your First Application. Device
is annotated with @Entity
, so Hibernate Search will create a Lucene index just for it. The entity class contains searchable fields for device name, and manufacturer name.
However, the supportedApps
member variable introduces a new annotation, for making the association between these two entities bidirectional. An App
entity will contain a list of all its supported devices, and a Device
entity will contain a list of all its supported apps.
If for no other reason, using bidirectional associations improves the reliability of Hibernate Search.
A Lucene index contains denormalized data from associated entities, but those entities are still primarily tied to their own Lucene indexes. To cut a long story short, when the association between two entities is bidirectional, and changes are set to cascade, then you can count on both indexes being updated when either entity changes.
The Hibernate ORM reference manual describes several bidirectional mapping types and options. Here we are using
@ManyToMany
, to declare a many-to-many relationship between the App
and Device
entities. The
cascade
element is set to ensure that changes on this end of the association properly update the other side.
Normally, Hibernate is "lazy". It doesn't actually fetch associated entities from the database until they are needed.
However, here we are writing a multi-tiered application, and the controller servlet has already closed the Hibernate session by the time our search results JSP receives these entities. If a view tries to fetch associations after the session has closed, errors will occur.
There are several ways around this problem. For simplicity, we are also adding a fetch
element to the
@ManyToMany
annotation, changing the fetch type from "lazy" to "eager". Now when we retrieve a Device entity, Hibernate will immediately fetch all the associated App
entities while the session is still open.
Eager fetching is very inefficient with large amounts of data, however, so in Chapter 5, Advanced Querying, we will explore a more advanced strategy for handling this.
Everything about supportedApps
discussed so far has been in the realm of Hibernate ORM. So last but not least, we will add the Hibernate Search
@ContainedIn
annotation, declaring that App
's Lucene index should contain data from Device
. Hibernate ORM already saw these two entities as being associated. The Hibernate Search @ContainedIn
annotation sets up a bidirectional association from Lucene's perspective too.
The other half of the bidirectional association involves giving the App
entity class a list of supported Device
entity classes.
...
@ManyToMany(fetch=FetchType.EAGER, cascade = { CascadeType.ALL })
@IndexedEmbedded(depth=1)
private Set<Device>supportedDevices;
...
// Getter and setter methods
...
This is very similar to the Device
side of the association, except that the @IndexedEmbedded
annotation
here serves as the counterpoint to @ContainedIn
.
If your associated objects contain other associated objects themselves, then you might end up indexing a lot more data than you wanted. Even worse, you could run into problems with circular dependencies.
To safeguard against this, set the @IndexEmbedded
annotation's optional depth
element to a max limit. When indexing objects, Hibernate Search will go no further than the specified number of levels.
The previous code specifies a depth of one level. This means that an app will be indexed with information about its supported devices, but not information about a device's other supported apps.
Once associated entities have been mapped for Hibernate Search, it is easy to include them in search queries. The following code snippet updates SearchServlet
to add supportedDevices
to the list of fields searched:
...
QueryBuilderqueryBuilder =
fullTextSession.getSearchFactory().buildQueryBuilder()
.forEntity(App.class ).get();
org.apache.lucene.search.QueryluceneQuery = queryBuilder
.keyword()
.onFields("name", "description", "supportedDevices.name")
.matching(searchString)
.createQuery();
org.hibernate.QueryhibernateQuery =
fullTextSession.createFullTextQuery(luceneQuery, App.class);
...
Complex types are a bit different from the simple data types we have worked with so far. With complex types, we are not really interested in the field itself, because the field is actually just an object reference (or a collection of object references).
What we really want our searches to match are the simple data type fields within the complex type. In other words, we want to search the Device
entity's name
field. So, as long as an associated class field has been indexed (that is, with the
@Field
annotation), it can be queried with a [entity field].[nested field] format, such as supportedDevices.name
in the previous code.
In the sample code for this chapter, StartupDataLoader
has been expanded to save some Device
entities in the database, and associate them with the App
entities. One of these test devices is named xPhone. When we run the VAPORware Marketplace application and search for this keyword, the search results will include apps that are compatible with the xPhone, even if that keyword doesn't appear in the name or description of the app itself.
Associated entities are full-blown entities in their own right. They typically correspond to a database table and Lucene index of their own, and may stand apart from their associations. For example, if we delete an app entity that is supported on the xPhone, that doesn't mean we want to delete the xPhone Device
too.
There is a different type of association, in which the lifecycle of associated objects depends on the entity that contains them. If the VAPORware Marketplace apps had customer reviews, and an app was permanently deleted from the database, then we would probably expect all its customer reviews to be removed along with it.
Classic Hibernate ORM terminology refers to such objects as components (or sometimes elements ). In the newer JPA jargon, they are known as embedded objects.
Embedded objects are not entities themselves. Hibernate Search does not create separate Lucene indexes for them, and they cannot be searched outside the context of the entity containing them. Otherwise, they look and feel quite similar to associated entities.
Let's give the example application an embedded object type for customer reviews. A CustomerReview
instance will consist of the username of the person submitting the review, the rating they gave (for example, five stars), and any additional comments they wrote.
@Embeddable public class CustomerReview { @Field private String username; private int stars; @Field private String comments; publicCustomerReview() { } public CustomerReview(String username, int stars, String comments) { this.username = username; this.stars = stars; this.comments = comments; } // Getter and setter methods... }
This class is annotated with @Embeddable
rather than the usual @Entity
annotation, telling Hibernate ORM that the lifecycle of a CustomerReview
instance is dependent on whichever entity object contains it.
The @Field
annotation is applied to searchable fields as before. However, Hibernate Search will not create a standalone Lucene index just for CustomerReview
. This annotation only adds information to the indexes of other entities that contain this embeddable class.
In our case, the containing class will be App
. Let's give it a set of customer reviews as a member variable:
... @ElementCollection(fetch=FetchType.EAGER) @Fetch(FetchMode.SELECT) @IndexedEmbedded(depth=1) private Set<CustomerReview>customerReviews; ...
Rather than one of the usual JPA relationship annotations (for example, @OneToOne
, @ManyToMany
, and so on), this field is annotated as a JPA @ElementCollection
. If this field were a single object, no annotation would be necessary. JPA would simply figure it out based on that object class having the @Embeddable
annotation. However, the
@ElementCollection
annotation is necessary when dealing with collections of embeddable elements.
The @ElementCollection
annotation has a fetch
element set to use eager fetching, for the same reasons discussed earlier in this chapter.
On the next line we use the Hibernate-specific @Fetch
annotation, to ensure that the CustomerReview
instances are fetched through multiple SELECT
statements rather than a single OUTER JOIN
. This avoids duplication of customer reviews, due to Hibernate ORM quirks that are discussed further in comments within the downloadable source code. Unfortunately, this mode is inefficient when dealing with very large collections, so you may wish to consider another approach in such cases.
Querying embedded objects is the same as with associated entities. Here is the query code snippet from SearchServlet
, modified to also search against the comments fields of the embedded CustomerReview
instances:
...
QueryBuilderqueryBuilder =
fullTextSession.getSearchFactory().buildQueryBuilder()
.forEntity(App.class ).get();
org.apache.lucene.search.QueryluceneQuery = queryBuilder
.keyword()
.onFields("name", "description", "supportedDevices.name",
"customerReviews.comments")
.matching(searchString)
.createQuery();
org.hibernate.QueryhibernateQuery = fullTextSession.createFullTextQuery(
luceneQuery, App.class);
...
Now we have a query that is really doing some searching! The chapter2
version of StartupDataLoader
has been extended to load some customer reviews for all of the test apps. Searches will now produce results when a match is found in a customer review, even though the keyword doesn't otherwise appear in the App
itself.
The HTML in the VAPORware Marketplace application has also been updated. Now each search result has a Full Details button, which pops-up a modal box with supported devices and customer reviews for that app. Notice that the search keyword in this screenshot is matched against a customer review rather the actual app description: