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}