
001/* 002 * #%L 003 * HAPI FHIR JPA Server 004 * %% 005 * Copyright (C) 2014 - 2023 Smile CDR, Inc. 006 * %% 007 * Licensed under the Apache License, Version 2.0 (the "License"); 008 * you may not use this file except in compliance with the License. 009 * You may obtain a copy of the License at 010 * 011 * http://www.apache.org/licenses/LICENSE-2.0 012 * 013 * Unless required by applicable law or agreed to in writing, software 014 * distributed under the License is distributed on an "AS IS" BASIS, 015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 016 * See the License for the specific language governing permissions and 017 * limitations under the License. 018 * #L% 019 */ 020package ca.uhn.fhir.jpa.dao; 021 022import ca.uhn.fhir.context.FhirContext; 023import ca.uhn.fhir.i18n.Msg; 024import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; 025import ca.uhn.fhir.jpa.api.svc.IIdHelperService; 026import ca.uhn.fhir.jpa.dao.search.ExtendedHSearchClauseBuilder; 027import ca.uhn.fhir.jpa.dao.search.ExtendedHSearchIndexExtractor; 028import ca.uhn.fhir.jpa.dao.search.ExtendedHSearchResourceProjection; 029import ca.uhn.fhir.jpa.dao.search.ExtendedHSearchSearchBuilder; 030import ca.uhn.fhir.jpa.dao.search.IHSearchSortHelper; 031import ca.uhn.fhir.jpa.dao.search.LastNOperation; 032import ca.uhn.fhir.jpa.model.dao.JpaPid; 033import ca.uhn.fhir.jpa.model.entity.ResourceTable; 034import ca.uhn.fhir.jpa.model.search.ExtendedHSearchIndexData; 035import ca.uhn.fhir.jpa.search.autocomplete.ValueSetAutocompleteOptions; 036import ca.uhn.fhir.jpa.search.autocomplete.ValueSetAutocompleteSearch; 037import ca.uhn.fhir.jpa.search.builder.ISearchQueryExecutor; 038import ca.uhn.fhir.jpa.search.builder.SearchQueryExecutors; 039import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; 040import ca.uhn.fhir.jpa.searchparam.extractor.ISearchParamExtractor; 041import ca.uhn.fhir.jpa.searchparam.extractor.ResourceIndexedSearchParams; 042import ca.uhn.fhir.model.api.IQueryParameterType; 043import ca.uhn.fhir.parser.IParser; 044import ca.uhn.fhir.rest.api.Constants; 045import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId; 046import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; 047import ca.uhn.fhir.rest.server.util.ResourceSearchParams; 048import com.google.common.collect.Ordering; 049import org.hibernate.search.backend.elasticsearch.ElasticsearchExtension; 050import org.hibernate.search.engine.search.predicate.dsl.PredicateFinalStep; 051import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory; 052import org.hibernate.search.engine.search.projection.dsl.CompositeProjectionOptionsStep; 053import org.hibernate.search.engine.search.projection.dsl.SearchProjectionFactory; 054import org.hibernate.search.engine.search.query.dsl.SearchQueryOptionsStep; 055import org.hibernate.search.mapper.orm.Search; 056import org.hibernate.search.mapper.orm.common.EntityReference; 057import org.hibernate.search.mapper.orm.search.loading.dsl.SearchLoadingOptionsStep; 058import org.hibernate.search.mapper.orm.session.SearchSession; 059import org.hibernate.search.mapper.orm.work.SearchIndexingPlan; 060import org.hibernate.search.util.common.SearchException; 061import org.hl7.fhir.instance.model.api.IBaseResource; 062import org.springframework.beans.factory.annotation.Autowired; 063import org.springframework.transaction.PlatformTransactionManager; 064import org.springframework.transaction.annotation.Transactional; 065import org.springframework.transaction.support.TransactionTemplate; 066 067import javax.annotation.Nonnull; 068import javax.persistence.EntityManager; 069import javax.persistence.PersistenceContext; 070import javax.persistence.PersistenceContextType; 071import java.util.ArrayList; 072import java.util.Collection; 073import java.util.Collections; 074import java.util.List; 075import java.util.Spliterators; 076import java.util.stream.Collectors; 077import java.util.stream.StreamSupport; 078 079import static ca.uhn.fhir.rest.server.BasePagingProvider.DEFAULT_MAX_PAGE_SIZE; 080import static org.apache.commons.lang3.StringUtils.isNotBlank; 081 082public class FulltextSearchSvcImpl implements IFulltextSearchSvc { 083 private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(FulltextSearchSvcImpl.class); 084 private static final int DEFAULT_MAX_NON_PAGED_SIZE = 500; 085 final private ExtendedHSearchSearchBuilder myAdvancedIndexQueryBuilder = new ExtendedHSearchSearchBuilder(); 086 @Autowired 087 ISearchParamExtractor mySearchParamExtractor; 088 @Autowired 089 IIdHelperService myIdHelperService; 090 @PersistenceContext(type = PersistenceContextType.TRANSACTION) 091 private EntityManager myEntityManager; 092 @Autowired 093 private PlatformTransactionManager myTxManager; 094 @Autowired 095 private FhirContext myFhirContext; 096 @Autowired 097 private ISearchParamRegistry mySearchParamRegistry; 098 @Autowired 099 private JpaStorageSettings myStorageSettings; 100 @Autowired 101 private IHSearchSortHelper myExtendedFulltextSortHelper; 102 @Autowired(required = false) 103 private IHSearchEventListener myHSearchEventListener; 104 105 private Boolean ourDisabled; 106 107 /** 108 * Constructor 109 */ 110 public FulltextSearchSvcImpl() { 111 super(); 112 } 113 114 @Override 115 public ExtendedHSearchIndexData extractLuceneIndexData(IBaseResource theResource, ResourceIndexedSearchParams theNewParams) { 116 String resourceType = myFhirContext.getResourceType(theResource); 117 ResourceSearchParams activeSearchParams = mySearchParamRegistry.getActiveSearchParams(resourceType); 118 ExtendedHSearchIndexExtractor extractor = new ExtendedHSearchIndexExtractor( 119 myStorageSettings, myFhirContext, activeSearchParams, mySearchParamExtractor); 120 return extractor.extract(theResource, theNewParams); 121 } 122 123 @Override 124 public boolean supportsSomeOf(SearchParameterMap myParams) { 125 126 // keep this in sync with the guts of doSearch 127 boolean requiresHibernateSearchAccess = myParams.containsKey(Constants.PARAM_CONTENT) || myParams.containsKey(Constants.PARAM_TEXT) || myParams.isLastN(); 128 129 requiresHibernateSearchAccess |= myStorageSettings.isAdvancedHSearchIndexing() && myAdvancedIndexQueryBuilder.isSupportsSomeOf(myParams); 130 131 return requiresHibernateSearchAccess; 132 } 133 134 @Override 135 public void reindex(ResourceTable theEntity) { 136 validateHibernateSearchIsEnabled(); 137 138 SearchIndexingPlan plan = getSearchSession().indexingPlan(); 139 plan.addOrUpdate(theEntity); 140 } 141 142 @Override 143 public ISearchQueryExecutor searchNotScrolled(String theResourceName, SearchParameterMap theParams, Integer theMaxResultsToFetch) { 144 validateHibernateSearchIsEnabled(); 145 146 return doSearch(theResourceName, theParams, null, theMaxResultsToFetch); 147 } 148 149 150 // keep this in sync with supportsSomeOf(); 151 private ISearchQueryExecutor doSearch(String theResourceType, SearchParameterMap theParams, 152 IResourcePersistentId theReferencingPid, Integer theMaxResultsToFetch) { 153 154 int offset = theParams.getOffset() == null ? 0 : theParams.getOffset(); 155 int count = getMaxFetchSize(theParams, theMaxResultsToFetch); 156 157 // perform an offset search instead of a scroll one, which doesn't allow for offset 158 List<Long> queryFetchResult = getSearchQueryOptionsStep(theResourceType, theParams, theReferencingPid).fetchHits(offset, count); 159 160 // indicate param was already processed, otherwise queries DB to process it 161 theParams.setOffset(null); 162 return SearchQueryExecutors.from(queryFetchResult); 163 } 164 165 166 private int getMaxFetchSize(SearchParameterMap theParams, Integer theMax) { 167 if (theMax != null) { 168 return theMax; 169 } 170 171 // todo mb we should really pass this in. 172 if (theParams.getCount() != null) { 173 return theParams.getCount(); 174 } 175 176 return DEFAULT_MAX_NON_PAGED_SIZE; 177 } 178 179 180 private SearchQueryOptionsStep<?, Long, SearchLoadingOptionsStep, ?, ?> getSearchQueryOptionsStep( 181 String theResourceType, SearchParameterMap theParams, IResourcePersistentId theReferencingPid) { 182 183 dispatchEvent(IHSearchEventListener.HSearchEventType.SEARCH); 184 var query = getSearchSession().search(ResourceTable.class) 185 // The document id is the PK which is pid. We use this instead of _myId to avoid fetching the doc body. 186 .select( 187 // adapt the String docRef.id() to the Long that it really is. 188 f -> f.composite( 189 docRef -> Long.valueOf(docRef.id()), 190 f.documentReference()) 191 ) 192 .where( 193 f -> buildWhereClause(f, theResourceType, theParams, theReferencingPid) 194 ); 195 196 if (theParams.getSort() != null) { 197 query.sort( 198 f -> myExtendedFulltextSortHelper.getSortClauses(f, theParams.getSort(), theResourceType)); 199 200 // indicate parameter was processed 201 theParams.setSort(null); 202 } 203 204 return query; 205 } 206 207 208 private PredicateFinalStep buildWhereClause(SearchPredicateFactory f, String theResourceType, 209 SearchParameterMap theParams, IResourcePersistentId theReferencingPid) { 210 return f.bool(b -> { 211 ExtendedHSearchClauseBuilder builder = new ExtendedHSearchClauseBuilder(myFhirContext, myStorageSettings, b, f); 212 213 /* 214 * Handle _content parameter (resource body content) 215 * 216 * Posterity: 217 * We do not want the HAPI-FHIR dao's to process the 218 * _content parameter, so we remove it from the map here 219 */ 220 List<List<IQueryParameterType>> contentAndTerms = theParams.remove(Constants.PARAM_CONTENT); 221 builder.addStringTextSearch(Constants.PARAM_CONTENT, contentAndTerms); 222 223 /* 224 * Handle _text parameter (resource narrative content) 225 * 226 * Posterity: 227 * We do not want the HAPI-FHIR dao's to process the 228 * _text parameter, so we remove it from the map here 229 */ 230 List<List<IQueryParameterType>> textAndTerms = theParams.remove(Constants.PARAM_TEXT); 231 builder.addStringTextSearch(Constants.PARAM_TEXT, textAndTerms); 232 233 if (theReferencingPid != null) { 234 b.must(f.match().field("myResourceLinksField").matching(theReferencingPid.toString())); 235 } 236 237 if (isNotBlank(theResourceType)) { 238 builder.addResourceTypeClause(theResourceType); 239 } 240 241 /* 242 * Handle other supported parameters 243 */ 244 if (myStorageSettings.isAdvancedHSearchIndexing() && theParams.getEverythingMode() == null) { 245 myAdvancedIndexQueryBuilder.addAndConsumeAdvancedQueryClauses(builder, theResourceType, theParams, mySearchParamRegistry); 246 } 247 //DROP EARLY HERE IF BOOL IS EMPTY? 248 }); 249 } 250 251 252 @Nonnull 253 private SearchSession getSearchSession() { 254 return Search.session(myEntityManager); 255 } 256 257 private List<IResourcePersistentId> convertLongsToResourcePersistentIds(List<Long> theLongPids) { 258 return theLongPids.stream() 259 .map(JpaPid::fromId) 260 .collect(Collectors.toList()); 261 } 262 263 @Override 264 public List<IResourcePersistentId> everything(String theResourceName, SearchParameterMap theParams, IResourcePersistentId theReferencingPid) { 265 validateHibernateSearchIsEnabled(); 266 267 // todo mb what about max results here? 268 List<IResourcePersistentId> retVal = toList(doSearch(null, theParams, theReferencingPid, 10_000), 10_000); 269 if (theReferencingPid != null) { 270 retVal.add(theReferencingPid); 271 } 272 return retVal; 273 } 274 275 private void validateHibernateSearchIsEnabled() { 276 if (isDisabled()) { 277 throw new UnsupportedOperationException(Msg.code(2137) + "Hibernate search is not enabled!"); 278 } 279 } 280 281 @Override 282 public boolean isDisabled() { 283 Boolean retVal = ourDisabled; 284 285 if (retVal == null) { 286 retVal = new TransactionTemplate(myTxManager).execute(t -> { 287 try { 288 SearchSession searchSession = getSearchSession(); 289 searchSession.search(ResourceTable.class); 290 return Boolean.FALSE; 291 } catch (Exception e) { 292 ourLog.trace("FullText test failed", e); 293 ourLog.debug("Hibernate Search (Lucene) appears to be disabled on this server, fulltext will be disabled"); 294 return Boolean.TRUE; 295 } 296 }); 297 ourDisabled = retVal; 298 } 299 300 assert retVal != null; 301 return retVal; 302 } 303 304 @Transactional() 305 @Override 306 public List<IResourcePersistentId> search(String theResourceName, SearchParameterMap theParams) { 307 validateHibernateSearchIsEnabled(); 308 return toList(doSearch(theResourceName, theParams, null, DEFAULT_MAX_NON_PAGED_SIZE), DEFAULT_MAX_NON_PAGED_SIZE); 309 } 310 311 /** 312 * Adapt our async interface to the legacy concrete List 313 */ 314 private List<IResourcePersistentId> toList(ISearchQueryExecutor theSearchResultStream, long theMaxSize) { 315 return StreamSupport.stream(Spliterators.spliteratorUnknownSize(theSearchResultStream, 0), false) 316 .map(JpaPid::fromId) 317 .limit(theMaxSize) 318 .collect(Collectors.toList()); 319 } 320 321 @Transactional() 322 @Override 323 public IBaseResource tokenAutocompleteValueSetSearch(ValueSetAutocompleteOptions theOptions) { 324 validateHibernateSearchIsEnabled(); 325 ensureElastic(); 326 327 ValueSetAutocompleteSearch autocomplete = new ValueSetAutocompleteSearch(myFhirContext, myStorageSettings, getSearchSession()); 328 329 dispatchEvent(IHSearchEventListener.HSearchEventType.SEARCH); 330 return autocomplete.search(theOptions); 331 } 332 333 /** 334 * Throws an error if configured with Lucene. 335 * <p> 336 * Some features only work with Elasticsearch. 337 * Lastn and the autocomplete search use nested aggregations which are Elasticsearch-only 338 */ 339 private void ensureElastic() { 340 try { 341 getSearchSession().scope(ResourceTable.class) 342 .aggregation() 343 .extension(ElasticsearchExtension.get()); 344 } catch (SearchException e) { 345 // unsupported. we are probably running Lucene. 346 throw new IllegalStateException(Msg.code(2070) + "This operation requires Elasticsearch. Lucene is not supported."); 347 } 348 349 } 350 351 @Override 352 public List<IResourcePersistentId> lastN(SearchParameterMap theParams, Integer theMaximumResults) { 353 ensureElastic(); 354 dispatchEvent(IHSearchEventListener.HSearchEventType.SEARCH); 355 List<Long> pidList = new LastNOperation(getSearchSession(), myFhirContext, myStorageSettings, mySearchParamRegistry) 356 .executeLastN(theParams, theMaximumResults); 357 return convertLongsToResourcePersistentIds(pidList); 358 } 359 360 @Override 361 public List<IBaseResource> getResources(Collection<Long> thePids) { 362 if (thePids.isEmpty()) { 363 return Collections.emptyList(); 364 } 365 366 SearchSession session = getSearchSession(); 367 dispatchEvent(IHSearchEventListener.HSearchEventType.SEARCH); 368 List<ExtendedHSearchResourceProjection> rawResourceDataList = session.search(ResourceTable.class) 369 .select( 370 this::buildResourceSelectClause 371 ) 372 .where( 373 f -> f.id().matchingAny(thePids) // matches '_id' from resource index 374 ).fetchAllHits(); 375 376 // order resource projections as per thePids 377 ArrayList<Long> pidList = new ArrayList<>(thePids); 378 List<ExtendedHSearchResourceProjection> orderedAsPidsResourceDataList = rawResourceDataList.stream() 379 .sorted(Ordering.explicit(pidList).onResultOf(ExtendedHSearchResourceProjection::getPid)).collect(Collectors.toList()); 380 381 return resourceProjectionsToResources(orderedAsPidsResourceDataList); 382 } 383 384 385 @Nonnull 386 private List<IBaseResource> resourceProjectionsToResources(List<ExtendedHSearchResourceProjection> theResourceDataList) { 387 IParser parser = myFhirContext.newJsonParser(); 388 return theResourceDataList.stream() 389 .map(p -> p.toResource(parser)) 390 .collect(Collectors.toList()); 391 } 392 393 394 private CompositeProjectionOptionsStep<?, ExtendedHSearchResourceProjection> buildResourceSelectClause( 395 SearchProjectionFactory<EntityReference, ResourceTable> f) { 396 return f.composite( 397 ExtendedHSearchResourceProjection::new, 398 f.field("myId", Long.class), 399 f.field("myForcedId", String.class), 400 f.field("myRawResource", String.class)); 401 } 402 403 404 @Override 405 public long count(String theResourceName, SearchParameterMap theParams) { 406 SearchQueryOptionsStep<?, Long, SearchLoadingOptionsStep, ?, ?> queryOptionsStep = 407 getSearchQueryOptionsStep(theResourceName, theParams, null); 408 409 return queryOptionsStep.fetchTotalHitCount(); 410 } 411 412 413 @Override 414 @Transactional(readOnly = true) 415 public List<IBaseResource> searchForResources(String theResourceType, SearchParameterMap theParams) { 416 int offset = 0; 417 int limit = theParams.getCount() == null ? DEFAULT_MAX_PAGE_SIZE : theParams.getCount(); 418 419 if (theParams.getOffset() != null && theParams.getOffset() != 0) { 420 offset = theParams.getOffset(); 421 // indicate param was already processed, otherwise queries DB to process it 422 theParams.setOffset(null); 423 } 424 425 dispatchEvent(IHSearchEventListener.HSearchEventType.SEARCH); 426 427 var query = getSearchSession().search(ResourceTable.class) 428 .select(this::buildResourceSelectClause) 429 .where(f -> buildWhereClause(f, theResourceType, theParams, null)); 430 431 if (theParams.getSort() != null) { 432 query.sort( 433 f -> myExtendedFulltextSortHelper.getSortClauses(f, theParams.getSort(), theResourceType)); 434 } 435 436 List<ExtendedHSearchResourceProjection> extendedLuceneResourceProjections = query.fetchHits(offset, limit); 437 438 return resourceProjectionsToResources(extendedLuceneResourceProjections); 439 } 440 441 442 @Override 443 public boolean supportsAllOf(SearchParameterMap theParams) { 444 return myAdvancedIndexQueryBuilder.isSupportsAllOf(theParams); 445 } 446 447 448 private void dispatchEvent(IHSearchEventListener.HSearchEventType theEventType) { 449 if (myHSearchEventListener != null) { 450 myHSearchEventListener.hsearchEvent(theEventType); 451 } 452 } 453 454 455 @Override 456 public void deleteIndexedDocumentsByTypeAndId(Class theClazz, List<Object> theGivenIds) { 457 SearchSession session = Search.session(myEntityManager); 458 SearchIndexingPlan indexingPlan = session.indexingPlan(); 459 for (Object givenId : theGivenIds) { 460 indexingPlan.purge(theClazz, givenId, null); 461 } 462 indexingPlan.process(); 463 indexingPlan.execute(); 464 } 465}