001/*
002 * #%L
003 * HAPI FHIR JPA Server
004 * %%
005 * Copyright (C) 2014 - 2026 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.interceptor.api.IInterceptorBroadcaster;
025import ca.uhn.fhir.interceptor.model.RequestPartitionId;
026import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
027import ca.uhn.fhir.jpa.api.dao.IFhirSystemDao;
028import ca.uhn.fhir.jpa.api.model.ExpungeOptions;
029import ca.uhn.fhir.jpa.api.model.ExpungeOutcome;
030import ca.uhn.fhir.jpa.dao.data.IResourceTableDao;
031import ca.uhn.fhir.jpa.dao.expunge.ExpungeService;
032import ca.uhn.fhir.jpa.dao.tx.HapiTransactionService;
033import ca.uhn.fhir.jpa.dao.tx.IHapiTransactionService;
034import ca.uhn.fhir.jpa.model.dao.JpaPid;
035import ca.uhn.fhir.jpa.model.entity.BaseHasResource;
036import ca.uhn.fhir.jpa.model.entity.ResourceHistoryTable;
037import ca.uhn.fhir.jpa.model.entity.ResourceTable;
038import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc;
039import ca.uhn.fhir.jpa.search.PersistedJpaBundleProviderFactory;
040import ca.uhn.fhir.jpa.search.SearchConstants;
041import ca.uhn.fhir.jpa.util.QueryChunker;
042import ca.uhn.fhir.jpa.util.ResourceCountCache;
043import ca.uhn.fhir.rest.api.server.IBundleProvider;
044import ca.uhn.fhir.rest.api.server.RequestDetails;
045import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId;
046import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException;
047import ca.uhn.fhir.util.StopWatch;
048import com.google.common.annotations.VisibleForTesting;
049import jakarta.annotation.Nonnull;
050import jakarta.annotation.Nullable;
051import jakarta.persistence.EntityManager;
052import jakarta.persistence.PersistenceContext;
053import jakarta.persistence.PersistenceContextType;
054import jakarta.persistence.Query;
055import jakarta.persistence.TypedQuery;
056import org.hl7.fhir.instance.model.api.IBaseBundle;
057import org.springframework.beans.factory.annotation.Autowired;
058import org.springframework.context.ApplicationContext;
059import org.springframework.transaction.annotation.Propagation;
060import org.springframework.transaction.annotation.Transactional;
061
062import java.util.Date;
063import java.util.HashMap;
064import java.util.List;
065import java.util.Map;
066import java.util.function.Predicate;
067import java.util.stream.Collectors;
068import java.util.stream.Stream;
069
070public abstract class BaseHapiFhirSystemDao<T extends IBaseBundle, MT> extends BaseStorageDao
071                implements IFhirSystemDao<T, MT> {
072        private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(BaseHapiFhirSystemDao.class);
073
074        public ResourceCountCache myResourceCountsCache;
075
076        @PersistenceContext(type = PersistenceContextType.TRANSACTION)
077        protected EntityManager myEntityManager;
078
079        @Autowired
080        private TransactionProcessor myTransactionProcessor;
081
082        @Autowired
083        private ApplicationContext myApplicationContext;
084
085        @Autowired
086        private ExpungeService myExpungeService;
087
088        @Autowired
089        private IResourceTableDao myResourceTableDao;
090
091        @Autowired
092        private PersistedJpaBundleProviderFactory myPersistedJpaBundleProviderFactory;
093
094        @Autowired
095        private IInterceptorBroadcaster myInterceptorBroadcaster;
096
097        @Autowired
098        private IRequestPartitionHelperSvc myRequestPartitionHelperService;
099
100        @Autowired
101        private IHapiTransactionService myTransactionService;
102
103        @VisibleForTesting
104        public void setTransactionProcessorForUnitTest(TransactionProcessor theTransactionProcessor) {
105                myTransactionProcessor = theTransactionProcessor;
106        }
107
108        @Override
109        @Transactional(propagation = Propagation.NEVER)
110        public ExpungeOutcome expunge(ExpungeOptions theExpungeOptions, RequestDetails theRequestDetails) {
111                validateExpungeEnabled(theExpungeOptions);
112                return myExpungeService.expunge(null, null, theExpungeOptions, theRequestDetails);
113        }
114
115        private void validateExpungeEnabled(ExpungeOptions theExpungeOptions) {
116                if (!getStorageSettings().isExpungeEnabled()) {
117                        throw new MethodNotAllowedException(Msg.code(2080) + "$expunge is not enabled on this server");
118                }
119
120                if (theExpungeOptions.isExpungeEverything() && !getStorageSettings().isAllowMultipleDelete()) {
121                        throw new MethodNotAllowedException(Msg.code(2081) + "Multiple delete is not enabled on this server");
122                }
123        }
124
125        @Transactional(propagation = Propagation.REQUIRED)
126        @Override
127        public Map<String, Long> getResourceCounts() {
128                Map<String, Long> retVal = new HashMap<>();
129
130                List<Map<?, ?>> counts = myResourceTableDao.getResourceCounts();
131                for (Map<?, ?> next : counts) {
132                        retVal.put(
133                                        next.get("type").toString(),
134                                        Long.parseLong(next.get("count").toString()));
135                }
136
137                return retVal;
138        }
139
140        @Nullable
141        @Override
142        public Map<String, Long> getResourceCountsFromCache() {
143                if (myResourceCountsCache == null) {
144                        // Lazy load this to avoid a circular dependency
145                        myResourceCountsCache = myApplicationContext.getBean("myResourceCountsCache", ResourceCountCache.class);
146                }
147                return myResourceCountsCache.get();
148        }
149
150        @Override
151        public IBundleProvider history(Date theSince, Date theUntil, Integer theOffset, RequestDetails theRequestDetails) {
152                StopWatch w = new StopWatch();
153                RequestPartitionId requestPartitionId =
154                                myRequestPartitionHelperService.determineReadPartitionForRequestForHistory(
155                                                theRequestDetails, null, null);
156                IBundleProvider retVal = myTransactionService
157                                .withRequest(theRequestDetails)
158                                .withRequestPartitionId(requestPartitionId)
159                                .execute(() -> myPersistedJpaBundleProviderFactory.history(
160                                                theRequestDetails, null, null, theSince, theUntil, theOffset, requestPartitionId));
161                ourLog.info("Processed global history in {}ms", w.getMillisAndRestart());
162                return retVal;
163        }
164
165        @Override
166        public T transaction(RequestDetails theRequestDetails, T theRequest) {
167                HapiTransactionService.noTransactionAllowed();
168                return myTransactionProcessor.transaction(theRequestDetails, theRequest, false);
169        }
170
171        @Override
172        public T transactionNested(RequestDetails theRequestDetails, T theRequest) {
173                HapiTransactionService.requireTransaction();
174                return myTransactionProcessor.transaction(theRequestDetails, theRequest, true);
175        }
176
177        /**
178         * Prefetch entities into the Hibernate session.
179         *
180         * When processing several resources (e.g. transaction bundle, $reindex chunk, etc.)
181         * it would be slow to fetch each piece of a resource (e.g. all token index rows)
182         * one resource at a time.
183         * Instead, we fetch all the linked resources for the entire batch and populate the Hibernate Session.
184         *
185         * @param theResolvedIds the pids
186         * @param thePreFetchIndexes Should resource indexes be loaded
187         */
188        @SuppressWarnings("rawtypes")
189        @Override
190        public <P extends IResourcePersistentId> void preFetchResources(
191                        List<P> theResolvedIds, boolean thePreFetchIndexes) {
192                HapiTransactionService.requireTransaction();
193                List<JpaPid> pids = theResolvedIds.stream().map(t -> ((JpaPid) t)).collect(Collectors.toList());
194
195                QueryChunker.chunk(pids, idChunk -> {
196
197                        /*
198                         * Pre-fetch the resources we're touching in this transaction in mass - this reduced the
199                         * number of database round trips.
200                         *
201                         * The thresholds below are kind of arbitrary. It's not
202                         * actually guaranteed that this pre-fetching will help (e.g. if a Bundle contains
203                         * a bundle of NOP conditional creates for example, the pre-fetching is actually loading
204                         * more data than would otherwise be loaded).
205                         *
206                         * However, for realistic average workloads, this should reduce the number of round trips.
207                         */
208                        if (!idChunk.isEmpty()) {
209                                List<ResourceTable> entityChunk = null;
210
211                                /*
212                                 * Unless we're in Mass Ingestion mode, we will pre-fetch the current
213                                 * saved resource text in HFJ_RES_VER (ResourceHistoryTable). If we're
214                                 * in Mass Ingestion Mode, we don't need to do that because every update
215                                 * will generate a new version anyway so the system never needs to know
216                                 * the current contents.
217                                 */
218                                if (!myStorageSettings.isMassIngestionMode()) {
219                                        entityChunk = prefetchResourceTableAndHistory(idChunk);
220                                }
221
222                                if (thePreFetchIndexes) {
223
224                                        /*
225                                         * If we're in mass ingestion mode, then we still need to load the resource
226                                         * entries in HFJ_RESOURCE (ResourceTable). We combine that with the search
227                                         * for tokens (since token is the most likely kind of index to be populated
228                                         * for any arbitrary resource type).
229                                         *
230                                         * For all other index types, we only load indexes if at least one
231                                         * HFJ_RESOURCE row indicates that a resource we care about actually has
232                                         * index rows of the given type.
233                                         */
234                                        if (entityChunk == null) {
235                                                String jqlQuery =
236                                                                "SELECT r FROM ResourceTable r LEFT JOIN FETCH r.myParamsToken WHERE r.myPid IN ( :IDS )";
237                                                TypedQuery<ResourceTable> query = myEntityManager.createQuery(jqlQuery, ResourceTable.class);
238                                                query.setParameter("IDS", idChunk);
239                                                entityChunk = query.getResultList();
240                                        } else {
241                                                prefetchByField("token", "myParamsToken", ResourceTable::isParamsTokenPopulated, entityChunk);
242                                        }
243
244                                        prefetchByField("string", "myParamsString", ResourceTable::isParamsStringPopulated, entityChunk);
245                                        prefetchByField("date", "myParamsDate", ResourceTable::isParamsDatePopulated, entityChunk);
246                                        prefetchByField(
247                                                        "quantity", "myParamsQuantity", ResourceTable::isParamsQuantityPopulated, entityChunk);
248
249                                        prefetchByJoinClause(
250                                                        "resourceLinks",
251                                                        "LEFT JOIN FETCH r.myResourceLinks l",
252                                                        ResourceTable::isHasLinks,
253                                                        entityChunk);
254
255                                        prefetchByJoinClause(
256                                                        "tags",
257                                                        // fetch the TagResources and the actual TagDefinitions
258                                                        "LEFT JOIN FETCH r.myTags t LEFT JOIN FETCH t.myTag",
259                                                        BaseHasResource::isHasTags,
260                                                        entityChunk);
261
262                                        prefetchByField(
263                                                        "comboStringUnique",
264                                                        "myParamsComboStringUnique",
265                                                        ResourceTable::isParamsComboStringUniquePresent,
266                                                        entityChunk);
267                                        prefetchByField(
268                                                        "comboTokenNonUnique",
269                                                        "myParamsComboTokensNonUnique",
270                                                        ResourceTable::isParamsComboTokensNonUniquePresent,
271                                                        entityChunk);
272
273                                        if (myStorageSettings.getIndexMissingFields() == JpaStorageSettings.IndexEnabledEnum.ENABLED) {
274                                                prefetchByField("searchParamPresence", "mySearchParamPresents", r -> true, entityChunk);
275                                        }
276                                }
277                        }
278                });
279        }
280
281        @Nonnull
282        private List<ResourceTable> prefetchResourceTableAndHistory(List<JpaPid> idChunk) {
283                assert idChunk.size() < SearchConstants.MAX_PAGE_SIZE : "assume pre-chunked";
284
285                Query query = myEntityManager.createQuery("select r, h "
286                                + " FROM ResourceTable r "
287                                + " LEFT JOIN fetch ResourceHistoryTable h "
288                                + "      on r.myVersion = h.myResourceVersion and r = h.myResourceTable "
289                                + " WHERE r.myPid IN ( :IDS ) ");
290                query.setParameter("IDS", idChunk);
291
292                @SuppressWarnings("unchecked")
293                Stream<Object[]> queryResultStream = query.getResultStream();
294                return queryResultStream
295                                .map(nextPair -> {
296                                        // Store the matching ResourceHistoryTable in the transient slot on ResourceTable
297                                        ResourceTable result = (ResourceTable) nextPair[0];
298                                        ResourceHistoryTable currentVersion = (ResourceHistoryTable) nextPair[1];
299                                        result.setCurrentVersionEntity(currentVersion);
300                                        return result;
301                                })
302                                .collect(Collectors.toList());
303        }
304
305        /**
306         * Prefetch a join field for the active subset of some ResourceTable entities.
307         * Convenience wrapper around prefetchByJoinClause() for simple fields.
308         *
309         * @param theDescription             for logging
310         * @param theJpaFieldName            the join field from ResourceTable
311         * @param theEntityPredicate         select which ResourceTable entities need this join
312         * @param theEntities                the ResourceTable entities to consider
313         */
314        private void prefetchByField(
315                        String theDescription,
316                        String theJpaFieldName,
317                        Predicate<ResourceTable> theEntityPredicate,
318                        List<ResourceTable> theEntities) {
319
320                String joinClause = "LEFT JOIN FETCH r." + theJpaFieldName;
321
322                prefetchByJoinClause(theDescription, joinClause, theEntityPredicate, theEntities);
323        }
324
325        /**
326         * Prefetch a join field for the active subset of some ResourceTable entities.
327         *
328         * @param theDescription             for logging
329         * @param theJoinClause              the JPA join expression to add to `ResourceTable r`
330         * @param theEntityPredicate         selects which entities need this prefetch
331         * @param theEntities                the ResourceTable entities to consider
332         */
333        private void prefetchByJoinClause(
334                        String theDescription,
335                        String theJoinClause,
336                        Predicate<ResourceTable> theEntityPredicate,
337                        List<ResourceTable> theEntities) {
338
339                // Which entities need this prefetch?
340                List<JpaPid> idSubset = theEntities.stream()
341                                .filter(theEntityPredicate)
342                                .map(ResourceTable::getId)
343                                .collect(Collectors.toList());
344
345                if (idSubset.isEmpty()) {
346                        // nothing to do
347                        return;
348                }
349
350                String jqlQuery = "FROM ResourceTable r " + theJoinClause + " WHERE r.myPid IN ( :IDS )";
351
352                TypedQuery<ResourceTable> query = myEntityManager.createQuery(jqlQuery, ResourceTable.class);
353                query.setParameter("IDS", idSubset);
354                List<ResourceTable> indexFetchOutcome = query.getResultList();
355
356                ourLog.debug("Pre-fetched {} {} indexes", indexFetchOutcome.size(), theDescription);
357        }
358
359        @Nullable
360        @Override
361        protected String getResourceName() {
362                return null;
363        }
364
365        @Override
366        protected IInterceptorBroadcaster getInterceptorBroadcaster() {
367                return myInterceptorBroadcaster;
368        }
369
370        @VisibleForTesting
371        public void setInterceptorBroadcasterForUnitTest(IInterceptorBroadcaster theInterceptorBroadcaster) {
372                myInterceptorBroadcaster = theInterceptorBroadcaster;
373        }
374
375        @Override
376        protected JpaStorageSettings getStorageSettings() {
377                return myStorageSettings;
378        }
379
380        @Override
381        public FhirContext getContext() {
382                return myFhirContext;
383        }
384
385        @VisibleForTesting
386        public void setStorageSettingsForUnitTest(JpaStorageSettings theStorageSettings) {
387                myStorageSettings = theStorageSettings;
388        }
389}