Search code examples
hibernatejpalucenehibernate-search

lucene - search in list of objects


I have the entity ExerciseEntity which contains a list of objects identifiers

@ElementCollection
@IndexedEmbedded(targetElement = IdentifierEntity.class)
private Set<IdentifierEntity> identifiers;

Example of the list:

"identifiers": [
    {
      "identifierType": "AM",
      "identifierValue": "333333333"
    },
    {
      "identifierType": "FINESS",
      "identifierValue": "888888888"
    }
]

the attribut identifierType is of type enum, so i implemented a custom bridge like in this link .

@Enumerated
@Field(name = "identifierType", bridge = @FieldBridge(impl = EnumAsIntegerBridge.class))
private IdentifierTypeEnum identifierType;

I want to search all ExerciseEntity by identifierValue which identifierType equals FINESS. I tried with the below query, but it doesn't work correctly, when i test with identifierValue=333333333, i get a result, but i should get nothing because its type is AM not FINESS.

List<Query> listOfQuery = new ArrayList<>();
listOfQuery.add(getQueryBuilder().keyword().onField("identifiers.identifierType").matching(IdentifierTypeEnum.FINESS.ordinal()).createQuery());
listOfQuery.add(getQueryBuilder().keyword().onField("identifiers.identifierValue").matching(identifierValue).createQuery());
Builder finalLuceneQuery = new BooleanQuery.Builder();
listOfQuery.stream().forEach(query -> finalLuceneQuery.add(query, BooleanClause.Occur.MUST));

FullTextQuery fullTextQuery = getFullTextEntityManager().createFullTextQuery(finalLuceneQuery.build(), ExerciseEntity.class);

Solution

  • There are two solutions. Upgrading to Hibernate Search 6.0 or later is mandatory for the first solution only, but I'd recommend upgrading anyway, because Hibernate Search 5.x is no longer getting new features.

    Solution 1: nested documents

    I'll show how to do this with Hibernate Search 6.x, because there is no equivalent in Hibernate Search 5.x.

    In Hibernate Search 6+, you can mark your @IndexedEmbedded as NESTED.

    @ElementCollection
    @IndexedEmbedded(structure = ObjectStructure.NESTED)
    private Set<IdentifierEntity> identifiers;
    

    Then the structure of your IdentifierEntity objects will be preserved in the index, and you'll be able to specify during search that you want two predicates to apply to the same object by wrapping them in a nested predicate:

    List<ExerciseEntity> hits = searchSession.search( ExerciseEntity.class )
            .where( f -> f.nested().objectField( "identifiers" ) 
                    .nest( f.bool()
                            .must( f.match().field( "identifiers.identifierType" )
                                    .matching( IdentifierTypeEnum.FINESS ) ) 
                            .must( f.match().field( "identifiers.identifierValue" )
                                    .matching( identifierValue ) ) 
                    ) )
            .fetchHits( 20 ); 
    

    Solution 2: single field for both values

    There's a hack to replicate the behavior above without using nested documents: just merge the two fields into one.

    I'll show how to do this with Hibernate Search 5.x, since you seem to be using that, but the same solution can perfectly well be implemented with Hibernate Search 6.x (with a value bridge).

    Implement a custom bridge that does exactly that:

    public class IdentifierAsStringBridge implements MetadataProvidingFieldBridge, StringBridge {
        public static String toString(IdentifierTypeEnum type, String value) {
            return type.name() + ":" + value;
        }
    
        @Override
        public void configureFieldMetadata(String name, FieldMetadataBuilder builder) {
            builder.field( name, FieldType.String );
        }
    
        @Override
        public void set(String name, Object value, Document document, LuceneOptions luceneOptions) {
            if ( value == null ) {
                return;
            }
    
            Collection<IdentifierEntity> idCollection = (Collection<IdentifierEntity>) value;
    
            for ( IdentifierEntity id : idCollection ) {
                luceneOptions.addFieldToDocument( name, toString( id.getIdentifierType(), id.getIdentifierValue() ), document );
            }
        }
    
        @Override
        public String objectToString(Object value) {
            if ( value instanceof String ) {
                return (String) value;
            }
            else if ( value instanceof IdentifierEntity ) {
                IdentifierEntity id = (IdentifierEntity) value;
                return toString( id.getIdentifierType(), id.getIdentifierValue() );
            else {
                throw new IllegalArgumentException( "This bridge only supports passing arguments of type String or IdentifierEntity" );
            }
        }
    
    }
    

    Use that new bridge on your "identifiers" collection:

    @ElementCollection
    @Field(name = "identifiers_strings", bridge = @FieldBridge(impl = IdentifierAsStringBridge.class))
    private Set<IdentifierEntity> identifiers;
    

    And finally, update your query to target the single field you just added:

    List<Query> listOfQuery = new ArrayList<>();
    listOfQuery.add(getQueryBuilder().keyword()
            .onField("identifiers_strings")
            .matching(IdentifierAsStringBridge.toString(
                    IdentifierTypeEnum.FINESS, identifierValue
            ))
            .createQuery());
    
    Builder finalLuceneQuery = new BooleanQuery.Builder();
    listOfQuery.stream().forEach(query -> finalLuceneQuery.add(query, BooleanClause.Occur.MUST));
    
    FullTextQuery fullTextQuery = getFullTextEntityManager()
            .createFullTextQuery(finalLuceneQuery.build(), ExerciseEntity.class);