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.search;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.interceptor.api.HookParams;
024import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster;
025import ca.uhn.fhir.interceptor.api.Pointcut;
026import ca.uhn.fhir.interceptor.model.ReadPartitionIdRequestDetails;
027import ca.uhn.fhir.interceptor.model.RequestPartitionId;
028import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
029import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
030import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao;
031import ca.uhn.fhir.jpa.api.svc.ISearchCoordinatorSvc;
032import ca.uhn.fhir.jpa.dao.HistoryBuilder;
033import ca.uhn.fhir.jpa.dao.HistoryBuilderFactory;
034import ca.uhn.fhir.jpa.dao.IJpaStorageResourceParser;
035import ca.uhn.fhir.jpa.dao.ISearchBuilder;
036import ca.uhn.fhir.jpa.dao.SearchBuilderFactory;
037import ca.uhn.fhir.jpa.dao.tx.HapiTransactionService;
038import ca.uhn.fhir.jpa.entity.Search;
039import ca.uhn.fhir.jpa.entity.SearchTypeEnum;
040import ca.uhn.fhir.jpa.model.dao.JpaPid;
041import ca.uhn.fhir.jpa.model.entity.BaseHasResource;
042import ca.uhn.fhir.jpa.model.entity.ResourceHistoryTable;
043import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc;
044import ca.uhn.fhir.jpa.search.cache.ISearchCacheSvc;
045import ca.uhn.fhir.jpa.search.cache.SearchCacheStatusEnum;
046import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
047import ca.uhn.fhir.jpa.util.MemoryCacheService;
048import ca.uhn.fhir.jpa.util.QueryParameterUtils;
049import ca.uhn.fhir.model.api.Include;
050import ca.uhn.fhir.model.primitive.InstantDt;
051import ca.uhn.fhir.rest.api.server.IBundleProvider;
052import ca.uhn.fhir.rest.api.server.IPreResourceAccessDetails;
053import ca.uhn.fhir.rest.api.server.IPreResourceShowDetails;
054import ca.uhn.fhir.rest.api.server.RequestDetails;
055import ca.uhn.fhir.rest.api.server.SimplePreResourceAccessDetails;
056import ca.uhn.fhir.rest.api.server.SimplePreResourceShowDetails;
057import ca.uhn.fhir.rest.server.interceptor.ServerInterceptorUtil;
058import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
059import ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster;
060import com.google.common.annotations.VisibleForTesting;
061import org.hl7.fhir.instance.model.api.IBaseResource;
062import org.slf4j.Logger;
063import org.slf4j.LoggerFactory;
064import org.springframework.beans.factory.annotation.Autowired;
065
066import java.util.ArrayList;
067import java.util.Collections;
068import java.util.List;
069import java.util.Optional;
070import java.util.Set;
071import java.util.function.Function;
072import javax.annotation.Nonnull;
073import javax.persistence.EntityManager;
074import javax.persistence.PersistenceContext;
075
076public class PersistedJpaBundleProvider implements IBundleProvider {
077
078        private static final Logger ourLog = LoggerFactory.getLogger(PersistedJpaBundleProvider.class);
079
080        /*
081         * Autowired fields
082         */
083        protected final RequestDetails myRequest;
084
085        @Autowired
086        protected HapiTransactionService myTxService;
087
088        @PersistenceContext
089        private EntityManager myEntityManager;
090
091        @Autowired
092        private IInterceptorBroadcaster myInterceptorBroadcaster;
093
094        @Autowired
095        private SearchBuilderFactory<JpaPid> mySearchBuilderFactory;
096
097        @Autowired
098        private HistoryBuilderFactory myHistoryBuilderFactory;
099
100        @Autowired
101        private DaoRegistry myDaoRegistry;
102
103        @Autowired
104        private FhirContext myContext;
105
106        @Autowired
107        private ISearchCoordinatorSvc<JpaPid> mySearchCoordinatorSvc;
108
109        @Autowired
110        private ISearchCacheSvc mySearchCacheSvc;
111
112        @Autowired
113        private IRequestPartitionHelperSvc myRequestPartitionHelperSvc;
114
115        @Autowired
116        private JpaStorageSettings myStorageSettings;
117
118        @Autowired
119        private MemoryCacheService myMemoryCacheService;
120
121        @Autowired
122        private IJpaStorageResourceParser myJpaStorageResourceParser;
123        /*
124         * Non autowired fields (will be different for every instance
125         * of this class, since it's a prototype
126         */
127        private Search mySearchEntity;
128        private String myUuid;
129        private SearchCacheStatusEnum myCacheStatus;
130        private RequestPartitionId myRequestPartitionId;
131        /**
132         * Constructor
133         */
134        public PersistedJpaBundleProvider(RequestDetails theRequest, String theSearchUuid) {
135                myRequest = theRequest;
136                myUuid = theSearchUuid;
137        }
138
139        /**
140         * Constructor
141         */
142        public PersistedJpaBundleProvider(RequestDetails theRequest, Search theSearch) {
143                myRequest = theRequest;
144                mySearchEntity = theSearch;
145                myUuid = theSearch.getUuid();
146        }
147
148        @VisibleForTesting
149        public void setRequestPartitionHelperSvcForUnitTest(IRequestPartitionHelperSvc theRequestPartitionHelperSvc) {
150                myRequestPartitionHelperSvc = theRequestPartitionHelperSvc;
151        }
152
153        protected Search getSearchEntity() {
154                return mySearchEntity;
155        }
156
157        // Note: Leave as protected, HSPC depends on this
158        @SuppressWarnings("WeakerAccess")
159        protected void setSearchEntity(Search theSearchEntity) {
160                mySearchEntity = theSearchEntity;
161        }
162
163        /**
164         * Perform a history search
165         */
166        private List<IBaseResource> doHistoryInTransaction(Integer theOffset, int theFromIndex, int theToIndex) {
167
168                HistoryBuilder historyBuilder = myHistoryBuilderFactory.newHistoryBuilder(
169                                mySearchEntity.getResourceType(),
170                                mySearchEntity.getResourceId(),
171                                mySearchEntity.getLastUpdatedLow(),
172                                mySearchEntity.getLastUpdatedHigh());
173
174                RequestPartitionId partitionId = getRequestPartitionId();
175                List<ResourceHistoryTable> results = historyBuilder.fetchEntities(
176                                partitionId, theOffset, theFromIndex, theToIndex, mySearchEntity.getHistorySearchStyle());
177
178                List<IBaseResource> retVal = new ArrayList<>();
179                for (ResourceHistoryTable next : results) {
180                        BaseHasResource resource;
181                        resource = next;
182
183                        IFhirResourceDao<?> dao = myDaoRegistry.getResourceDao(next.getResourceType());
184                        retVal.add(myJpaStorageResourceParser.toResource(resource, true));
185                }
186
187                // Interceptor call: STORAGE_PREACCESS_RESOURCES
188                {
189                        SimplePreResourceAccessDetails accessDetails = new SimplePreResourceAccessDetails(retVal);
190                        HookParams params = new HookParams()
191                                        .add(IPreResourceAccessDetails.class, accessDetails)
192                                        .add(RequestDetails.class, myRequest)
193                                        .addIfMatchesType(ServletRequestDetails.class, myRequest);
194                        CompositeInterceptorBroadcaster.doCallHooks(
195                                        myInterceptorBroadcaster, myRequest, Pointcut.STORAGE_PREACCESS_RESOURCES, params);
196
197                        for (int i = retVal.size() - 1; i >= 0; i--) {
198                                if (accessDetails.isDontReturnResourceAtIndex(i)) {
199                                        retVal.remove(i);
200                                }
201                        }
202                }
203
204                // Interceptor broadcast: STORAGE_PRESHOW_RESOURCES
205                {
206                        SimplePreResourceShowDetails showDetails = new SimplePreResourceShowDetails(retVal);
207                        HookParams params = new HookParams()
208                                        .add(IPreResourceShowDetails.class, showDetails)
209                                        .add(RequestDetails.class, myRequest)
210                                        .addIfMatchesType(ServletRequestDetails.class, myRequest);
211                        CompositeInterceptorBroadcaster.doCallHooks(
212                                        myInterceptorBroadcaster, myRequest, Pointcut.STORAGE_PRESHOW_RESOURCES, params);
213                        retVal = showDetails.toList();
214                }
215
216                return retVal;
217        }
218
219        @Nonnull
220        protected final RequestPartitionId getRequestPartitionId() {
221                if (myRequestPartitionId == null) {
222                        ReadPartitionIdRequestDetails details;
223                        if (mySearchEntity == null) {
224                                details = ReadPartitionIdRequestDetails.forSearchUuid(myUuid);
225                        } else if (mySearchEntity.getSearchType() == SearchTypeEnum.HISTORY) {
226                                details = ReadPartitionIdRequestDetails.forHistory(mySearchEntity.getResourceType(), null);
227                        } else {
228                                SearchParameterMap params =
229                                                mySearchEntity.getSearchParameterMap().orElse(null);
230                                details = ReadPartitionIdRequestDetails.forSearchType(mySearchEntity.getResourceType(), params, null);
231                        }
232                        myRequestPartitionId = myRequestPartitionHelperSvc.determineReadPartitionForRequest(myRequest, details);
233                }
234                return myRequestPartitionId;
235        }
236
237        public void setRequestPartitionId(RequestPartitionId theRequestPartitionId) {
238                myRequestPartitionId = theRequestPartitionId;
239        }
240
241        protected List<IBaseResource> doSearchOrEverything(final int theFromIndex, final int theToIndex) {
242                if (mySearchEntity.getTotalCount() != null && mySearchEntity.getNumFound() <= 0) {
243                        // No resources to fetch (e.g. we did a _summary=count search)
244                        return Collections.emptyList();
245                }
246                String resourceName = mySearchEntity.getResourceType();
247                Class<? extends IBaseResource> resourceType =
248                                myContext.getResourceDefinition(resourceName).getImplementingClass();
249                IFhirResourceDao<?> dao = myDaoRegistry.getResourceDao(resourceName);
250
251                final ISearchBuilder sb = mySearchBuilderFactory.newSearchBuilder(dao, resourceName, resourceType);
252
253                RequestPartitionId requestPartitionId = getRequestPartitionId();
254                final List<JpaPid> pidsSubList =
255                                mySearchCoordinatorSvc.getResources(myUuid, theFromIndex, theToIndex, myRequest, requestPartitionId);
256                return myTxService
257                                .withRequest(myRequest)
258                                .withRequestPartitionId(requestPartitionId)
259                                .execute(() -> {
260                                        return toResourceList(sb, pidsSubList);
261                                });
262        }
263
264        /**
265         * Returns false if the entity can't be found
266         */
267        public boolean ensureSearchEntityLoaded() {
268                if (mySearchEntity == null) {
269                        Optional<Search> searchOpt = myTxService
270                                        .withRequest(myRequest)
271                                        .withRequestPartitionId(myRequestPartitionId)
272                                        .execute(() -> mySearchCacheSvc.fetchByUuid(myUuid, myRequestPartitionId));
273                        if (!searchOpt.isPresent()) {
274                                return false;
275                        }
276
277                        setSearchEntity(searchOpt.get());
278
279                        ourLog.trace(
280                                        "Retrieved search with version {} and total {}",
281                                        mySearchEntity.getVersion(),
282                                        mySearchEntity.getTotalCount());
283
284                        return true;
285                }
286
287                if (mySearchEntity.getSearchType() == SearchTypeEnum.HISTORY) {
288                        if (mySearchEntity.getTotalCount() == null) {
289                                calculateHistoryCount();
290                        }
291                }
292
293                return true;
294        }
295
296        /**
297         * Note that this method is called outside a DB transaction, and uses a loading cache
298         * (assuming the default {@literal COUNT_CACHED} mode) so this effectively throttles
299         * access to the database by preventing multiple concurrent DB calls for an expensive
300         * count operation.
301         */
302        private void calculateHistoryCount() {
303                MemoryCacheService.HistoryCountKey key;
304                if (mySearchEntity.getResourceId() != null) {
305                        key = MemoryCacheService.HistoryCountKey.forInstance(mySearchEntity.getResourceId());
306                } else if (mySearchEntity.getResourceType() != null) {
307                        key = MemoryCacheService.HistoryCountKey.forType(mySearchEntity.getResourceType());
308                } else {
309                        key = MemoryCacheService.HistoryCountKey.forSystem();
310                }
311
312                Function<MemoryCacheService.HistoryCountKey, Integer> supplier = k -> myTxService
313                                .withRequest(myRequest)
314                                .withRequestPartitionId(getRequestPartitionId())
315                                .execute(() -> {
316                                        HistoryBuilder historyBuilder = myHistoryBuilderFactory.newHistoryBuilder(
317                                                        mySearchEntity.getResourceType(),
318                                                        mySearchEntity.getResourceId(),
319                                                        mySearchEntity.getLastUpdatedLow(),
320                                                        mySearchEntity.getLastUpdatedHigh());
321                                        Long count = historyBuilder.fetchCount(getRequestPartitionId());
322                                        return count.intValue();
323                                });
324
325                boolean haveOffset = mySearchEntity.getLastUpdatedLow() != null || mySearchEntity.getLastUpdatedHigh() != null;
326
327                switch (myStorageSettings.getHistoryCountMode()) {
328                        case COUNT_ACCURATE: {
329                                int count = supplier.apply(key);
330                                mySearchEntity.setTotalCount(count);
331                                break;
332                        }
333                        case CACHED_ONLY_WITHOUT_OFFSET: {
334                                if (!haveOffset) {
335                                        int count = myMemoryCacheService.get(MemoryCacheService.CacheEnum.HISTORY_COUNT, key, supplier);
336                                        mySearchEntity.setTotalCount(count);
337                                }
338                                break;
339                        }
340                        case COUNT_DISABLED: {
341                                break;
342                        }
343                }
344        }
345
346        @Override
347        public InstantDt getPublished() {
348                ensureSearchEntityLoaded();
349                return new InstantDt(mySearchEntity.getCreated());
350        }
351
352        @Nonnull
353        @Override
354        public List<IBaseResource> getResources(final int theFromIndex, final int theToIndex) {
355                boolean entityLoaded = ensureSearchEntityLoaded();
356                assert entityLoaded;
357                assert mySearchEntity != null;
358                assert mySearchEntity.getSearchType() != null;
359
360                switch (mySearchEntity.getSearchType()) {
361                        case HISTORY:
362                                return myTxService
363                                                .withRequest(myRequest)
364                                                .withRequestPartitionId(getRequestPartitionId())
365                                                .execute(() -> doHistoryInTransaction(mySearchEntity.getOffset(), theFromIndex, theToIndex));
366                        case SEARCH:
367                        case EVERYTHING:
368                        default:
369                                List<IBaseResource> retVal = doSearchOrEverything(theFromIndex, theToIndex);
370                                /*
371                                 * If we got fewer resources back than we asked for, it's possible that the search
372                                 * completed. If that's the case, the cached version of the search entity is probably
373                                 * no longer valid so let's force a reload if it gets asked for again (most likely
374                                 * because someone is calling size() on us)
375                                 */
376                                if (retVal.size() < theToIndex - theFromIndex) {
377                                        mySearchEntity = null;
378                                }
379                                return retVal;
380                }
381        }
382
383        @Override
384        public String getUuid() {
385                return myUuid;
386        }
387
388        public SearchCacheStatusEnum getCacheStatus() {
389                return myCacheStatus;
390        }
391
392        void setCacheStatus(SearchCacheStatusEnum theSearchCacheStatusEnum) {
393                myCacheStatus = theSearchCacheStatusEnum;
394        }
395
396        @Override
397        public Integer preferredPageSize() {
398                ensureSearchEntityLoaded();
399                return mySearchEntity.getPreferredPageSize();
400        }
401
402        public void setContext(FhirContext theContext) {
403                myContext = theContext;
404        }
405
406        public void setEntityManager(EntityManager theEntityManager) {
407                myEntityManager = theEntityManager;
408        }
409
410        @VisibleForTesting
411        public void setSearchCoordinatorSvcForUnitTest(ISearchCoordinatorSvc theSearchCoordinatorSvc) {
412                mySearchCoordinatorSvc = theSearchCoordinatorSvc;
413        }
414
415        @VisibleForTesting
416        public void setTxServiceForUnitTest(HapiTransactionService theTxManager) {
417                myTxService = theTxManager;
418        }
419
420        @Override
421        public Integer size() {
422                ensureSearchEntityLoaded();
423                QueryParameterUtils.verifySearchHasntFailedOrThrowInternalErrorException(mySearchEntity);
424
425                Integer size = mySearchEntity.getTotalCount();
426                if (size != null) {
427                        return Math.max(0, size);
428                }
429
430                if (mySearchEntity.getSearchType() == SearchTypeEnum.HISTORY) {
431                        return null;
432                } else {
433                        return mySearchCoordinatorSvc
434                                        .getSearchTotal(myUuid, myRequest, myRequestPartitionId)
435                                        .orElse(null);
436                }
437        }
438
439        protected boolean hasIncludes() {
440                ensureSearchEntityLoaded();
441                return !mySearchEntity.getIncludes().isEmpty();
442        }
443
444        // Note: Leave as protected, HSPC depends on this
445        @SuppressWarnings("WeakerAccess")
446        protected List<IBaseResource> toResourceList(ISearchBuilder theSearchBuilder, List<JpaPid> thePids) {
447
448                List<JpaPid> includedPidList = new ArrayList<>();
449                if (mySearchEntity.getSearchType() == SearchTypeEnum.SEARCH) {
450                        Integer maxIncludes = myStorageSettings.getMaximumIncludesToLoadPerPage();
451
452                        // Decide whether to perform include or revincludes first based on which one has iterate.
453                        boolean performIncludesBeforeRevincludes = shouldPerformIncludesBeforeRevincudes();
454
455                        if (performIncludesBeforeRevincludes) {
456                                // Load _includes
457                                Set<JpaPid> includedPids = theSearchBuilder.loadIncludes(
458                                                myContext,
459                                                myEntityManager,
460                                                thePids,
461                                                mySearchEntity.toIncludesList(),
462                                                false,
463                                                mySearchEntity.getLastUpdated(),
464                                                myUuid,
465                                                myRequest,
466                                                maxIncludes);
467                                if (maxIncludes != null) {
468                                        maxIncludes -= includedPids.size();
469                                }
470                                thePids.addAll(includedPids);
471                                includedPidList.addAll(includedPids);
472
473                                // Load _revincludes
474                                Set<JpaPid> revIncludedPids = theSearchBuilder.loadIncludes(
475                                                myContext,
476                                                myEntityManager,
477                                                thePids,
478                                                mySearchEntity.toRevIncludesList(),
479                                                true,
480                                                mySearchEntity.getLastUpdated(),
481                                                myUuid,
482                                                myRequest,
483                                                maxIncludes);
484                                thePids.addAll(revIncludedPids);
485                                includedPidList.addAll(revIncludedPids);
486                        } else {
487                                // Load _revincludes
488                                Set<JpaPid> revIncludedPids = theSearchBuilder.loadIncludes(
489                                                myContext,
490                                                myEntityManager,
491                                                thePids,
492                                                mySearchEntity.toRevIncludesList(),
493                                                true,
494                                                mySearchEntity.getLastUpdated(),
495                                                myUuid,
496                                                myRequest,
497                                                maxIncludes);
498                                if (maxIncludes != null) {
499                                        maxIncludes -= revIncludedPids.size();
500                                }
501                                thePids.addAll(revIncludedPids);
502                                includedPidList.addAll(revIncludedPids);
503
504                                // Load _includes
505                                Set<JpaPid> includedPids = theSearchBuilder.loadIncludes(
506                                                myContext,
507                                                myEntityManager,
508                                                thePids,
509                                                mySearchEntity.toIncludesList(),
510                                                false,
511                                                mySearchEntity.getLastUpdated(),
512                                                myUuid,
513                                                myRequest,
514                                                maxIncludes);
515                                thePids.addAll(includedPids);
516                                includedPidList.addAll(includedPids);
517                        }
518                }
519
520                // Execute the query and make sure we return distinct results
521                List<IBaseResource> resources = new ArrayList<>();
522                theSearchBuilder.loadResourcesByPid(thePids, includedPidList, resources, false, myRequest);
523
524                resources = ServerInterceptorUtil.fireStoragePreshowResource(resources, myRequest, myInterceptorBroadcaster);
525
526                return resources;
527        }
528
529        private boolean shouldPerformIncludesBeforeRevincudes() {
530                // When revincludes contain a :iterate, we should perform them last so they can iterate through the includes
531                // found so far
532                boolean retval = false;
533
534                for (Include nextInclude : mySearchEntity.toRevIncludesList()) {
535                        if (nextInclude.isRecurse()) {
536                                retval = true;
537                                break;
538                        }
539                }
540                return retval;
541        }
542
543        public void setInterceptorBroadcaster(IInterceptorBroadcaster theInterceptorBroadcaster) {
544                myInterceptorBroadcaster = theInterceptorBroadcaster;
545        }
546
547        @VisibleForTesting
548        public void setSearchCacheSvcForUnitTest(ISearchCacheSvc theSearchCacheSvc) {
549                mySearchCacheSvc = theSearchCacheSvc;
550        }
551
552        @VisibleForTesting
553        public void setDaoRegistryForUnitTest(DaoRegistry theDaoRegistry) {
554                myDaoRegistry = theDaoRegistry;
555        }
556
557        @VisibleForTesting
558        public void setStorageSettingsForUnitTest(JpaStorageSettings theStorageSettings) {
559                myStorageSettings = theStorageSettings;
560        }
561
562        @VisibleForTesting
563        public void setSearchBuilderFactoryForUnitTest(SearchBuilderFactory theSearchBuilderFactory) {
564                mySearchBuilderFactory = theSearchBuilderFactory;
565        }
566}