001/*
002 * #%L
003 * HAPI FHIR JPA Server
004 * %%
005 * Copyright (C) 2014 - 2024 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<Long> pids = theResolvedIds.stream().map(t -> ((JpaPid) t).getId()).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 = prefetchResourceTableAndHistory(idChunk);
210
211                                if (thePreFetchIndexes) {
212
213                                        prefetchByField("string", "myParamsString", ResourceTable::isParamsStringPopulated, entityChunk);
214                                        prefetchByField("token", "myParamsToken", ResourceTable::isParamsTokenPopulated, entityChunk);
215                                        prefetchByField("date", "myParamsDate", ResourceTable::isParamsDatePopulated, entityChunk);
216                                        prefetchByField(
217                                                        "quantity", "myParamsQuantity", ResourceTable::isParamsQuantityPopulated, entityChunk);
218                                        prefetchByField("resourceLinks", "myResourceLinks", ResourceTable::isHasLinks, entityChunk);
219
220                                        prefetchByJoinClause(
221                                                        "tags",
222                                                        // fetch the TagResources and the actual TagDefinitions
223                                                        "LEFT JOIN FETCH r.myTags t LEFT JOIN FETCH t.myTag",
224                                                        BaseHasResource::isHasTags,
225                                                        entityChunk);
226
227                                        prefetchByField(
228                                                        "comboStringUnique",
229                                                        "myParamsComboStringUnique",
230                                                        ResourceTable::isParamsComboStringUniquePresent,
231                                                        entityChunk);
232                                        prefetchByField(
233                                                        "comboTokenNonUnique",
234                                                        "myParamsComboTokensNonUnique",
235                                                        ResourceTable::isParamsComboTokensNonUniquePresent,
236                                                        entityChunk);
237
238                                        if (myStorageSettings.getIndexMissingFields() == JpaStorageSettings.IndexEnabledEnum.ENABLED) {
239                                                prefetchByField("searchParamPresence", "mySearchParamPresents", r -> true, entityChunk);
240                                        }
241                                }
242                        }
243                });
244        }
245
246        @Nonnull
247        private List<ResourceTable> prefetchResourceTableAndHistory(List<Long> idChunk) {
248                assert idChunk.size() < SearchConstants.MAX_PAGE_SIZE : "assume pre-chunked";
249
250                Query query = myEntityManager.createQuery("select r, h "
251                                + " FROM ResourceTable r "
252                                + " LEFT JOIN fetch ResourceHistoryTable h "
253                                + "      on r.myVersion = h.myResourceVersion and r.id = h.myResourceId "
254                                + " WHERE r.myId IN ( :IDS ) ");
255                query.setParameter("IDS", idChunk);
256
257                @SuppressWarnings("unchecked")
258                Stream<Object[]> queryResultStream = query.getResultStream();
259                return queryResultStream
260                                .map(nextPair -> {
261                                        // Store the matching ResourceHistoryTable in the transient slot on ResourceTable
262                                        ResourceTable result = (ResourceTable) nextPair[0];
263                                        ResourceHistoryTable currentVersion = (ResourceHistoryTable) nextPair[1];
264                                        result.setCurrentVersionEntity(currentVersion);
265                                        return result;
266                                })
267                                .collect(Collectors.toList());
268        }
269
270        /**
271         * Prefetch a join field for the active subset of some ResourceTable entities.
272         * Convenience wrapper around prefetchByJoinClause() for simple fields.
273         *
274         * @param theDescription             for logging
275         * @param theJpaFieldName            the join field from ResourceTable
276         * @param theEntityPredicate         select which ResourceTable entities need this join
277         * @param theEntities                the ResourceTable entities to consider
278         */
279        private void prefetchByField(
280                        String theDescription,
281                        String theJpaFieldName,
282                        Predicate<ResourceTable> theEntityPredicate,
283                        List<ResourceTable> theEntities) {
284
285                String joinClause = "LEFT JOIN FETCH r." + theJpaFieldName;
286
287                prefetchByJoinClause(theDescription, joinClause, theEntityPredicate, theEntities);
288        }
289
290        /**
291         * Prefetch a join field for the active subset of some ResourceTable entities.
292         *
293         * @param theDescription             for logging
294         * @param theJoinClause              the JPA join expression to add to `ResourceTable r`
295         * @param theEntityPredicate         selects which entities need this prefetch
296         * @param theEntities                the ResourceTable entities to consider
297         */
298        private void prefetchByJoinClause(
299                        String theDescription,
300                        String theJoinClause,
301                        Predicate<ResourceTable> theEntityPredicate,
302                        List<ResourceTable> theEntities) {
303
304                // Which entities need this prefetch?
305                List<Long> idSubset = theEntities.stream()
306                                .filter(theEntityPredicate)
307                                .map(ResourceTable::getId)
308                                .collect(Collectors.toList());
309
310                if (idSubset.isEmpty()) {
311                        // nothing to do
312                        return;
313                }
314
315                String jqlQuery = "FROM ResourceTable r " + theJoinClause + " WHERE r.myId IN ( :IDS )";
316
317                TypedQuery<ResourceTable> query = myEntityManager.createQuery(jqlQuery, ResourceTable.class);
318                query.setParameter("IDS", idSubset);
319                List<ResourceTable> indexFetchOutcome = query.getResultList();
320
321                ourLog.debug("Pre-fetched {} {} indexes", indexFetchOutcome.size(), theDescription);
322        }
323
324        @Nullable
325        @Override
326        protected String getResourceName() {
327                return null;
328        }
329
330        @Override
331        protected IInterceptorBroadcaster getInterceptorBroadcaster() {
332                return myInterceptorBroadcaster;
333        }
334
335        @Override
336        protected JpaStorageSettings getStorageSettings() {
337                return myStorageSettings;
338        }
339
340        @Override
341        public FhirContext getContext() {
342                return myFhirContext;
343        }
344
345        @VisibleForTesting
346        public void setStorageSettingsForUnitTest(JpaStorageSettings theStorageSettings) {
347                myStorageSettings = theStorageSettings;
348        }
349}