SearchService.java

package com.taxonomy.catalog.service;

import com.taxonomy.dto.TaxonomyNodeDto;
import com.taxonomy.catalog.model.TaxonomyNode;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.search.mapper.orm.Search;
import org.hibernate.search.mapper.orm.session.SearchSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;

/**
 * Full-text taxonomy search backed by Hibernate Search (Lucene backend).
 *
 * <p>Replaces the previous raw-Lucene {@code ByteBuffersDirectory} + {@code IndexWriter}
 * approach. Hibernate Search auto-indexes {@link TaxonomyNode} entities on JPA persist
 * and commit, so no manual {@code buildIndex()} call is required.
 *
 * <h2>Query strategy</h2>
 * <ul>
 *   <li>Full-text match across {@code nameEn}, {@code descriptionEn}, {@code nameDe},
 *       {@code descriptionDe} using the configured English/German analyzers.</li>
 *   <li>Keyword prefix match on {@code code}, {@code uuid}, {@code externalId} for
 *       case-insensitive exact/prefix lookups (e.g. "BP", "BP.001").</li>
 * </ul>
 */
@Service
public class SearchService {

    private static final Logger log = LoggerFactory.getLogger(SearchService.class);

    @PersistenceContext
    private EntityManager entityManager;

    /**
     * Search the taxonomy index and return up to {@code maxResults} flat (no children) DTOs.
     */
    @Transactional(readOnly = true)
    public List<TaxonomyNodeDto> search(String queryString, int maxResults) {
        if (queryString == null || queryString.isBlank()) {
            return Collections.emptyList();
        }
        try {
            SearchSession session = Search.session(entityManager);
            String lower = queryString.toLowerCase(Locale.ROOT);

            List<TaxonomyNode> hits = session.search(TaxonomyNode.class)
                    .where(f -> f.bool()
                            .should(f.match()
                                    .fields("nameEn", "descriptionEn", "nameDe", "descriptionDe")
                                    .matching(queryString))
                            .should(f.wildcard()
                                    .fields("code", "uuid", "externalId")
                                    .matching(lower + "*"))
                            .should(f.match()
                                    .fields("code", "uuid", "externalId")
                                    .matching(lower)))
                    .sort(f -> f.score())
                    .fetchHits(maxResults);

            return hits.stream().map(this::toFlatDto).collect(Collectors.toList());
        } catch (Exception e) {
            log.error("Hibernate Search full-text search failed for '{}': {}", queryString, e.getMessage());
            return Collections.emptyList();
        }
    }

    /** Convert a {@link TaxonomyNode} to a flat DTO (no children, no relations). */
    private TaxonomyNodeDto toFlatDto(TaxonomyNode node) {
        TaxonomyNodeDto dto = new TaxonomyNodeDto();
        dto.setId(node.getId());
        dto.setCode(node.getCode());
        dto.setUuid(node.getUuid());
        dto.setNameEn(node.getNameEn());
        dto.setNameDe(node.getNameDe());
        dto.setDescriptionEn(node.getDescriptionEn());
        dto.setDescriptionDe(node.getDescriptionDe());
        dto.setParentCode(node.getParentCode());
        dto.setTaxonomyRoot(node.getTaxonomyRoot());
        dto.setLevel(node.getLevel());
        dto.setDataset(node.getDataset());
        dto.setExternalId(node.getExternalId());
        dto.setSource(node.getSource());
        dto.setReference(node.getReference());
        dto.setSortOrder(node.getSortOrder());
        dto.setState(node.getState());
        return dto;
    }
}