001/*-
002 * #%L
003 * HAPI FHIR - Server Framework
004 * %%
005 * Copyright (C) 2014 - 2025 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.rest.api.server.storage;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.i18n.Msg;
024import ca.uhn.fhir.interceptor.api.HookParams;
025import ca.uhn.fhir.interceptor.api.Pointcut;
026import ca.uhn.fhir.interceptor.model.RequestPartitionId;
027import ca.uhn.fhir.rest.api.InterceptorInvocationTimingEnum;
028import ca.uhn.fhir.rest.server.exceptions.PreconditionFailedException;
029import com.google.common.collect.ArrayListMultimap;
030import com.google.common.collect.ListMultimap;
031import jakarta.annotation.Nonnull;
032import jakarta.annotation.Nullable;
033import org.apache.commons.lang3.Validate;
034import org.hl7.fhir.instance.model.api.IBaseResource;
035import org.hl7.fhir.instance.model.api.IIdType;
036import org.slf4j.Logger;
037import org.slf4j.LoggerFactory;
038
039import java.util.ArrayList;
040import java.util.Collection;
041import java.util.Collections;
042import java.util.Date;
043import java.util.EnumSet;
044import java.util.HashMap;
045import java.util.HashSet;
046import java.util.List;
047import java.util.Map;
048import java.util.Set;
049import java.util.function.Supplier;
050
051/**
052 * This object contains runtime information that is gathered and relevant to a single <i>database transaction</i>.
053 * This doesn't mean a FHIR transaction necessarily, but rather any operation that happens within a single DB transaction
054 * (i.e. a FHIR create, read, transaction, etc.).
055 * <p>
056 * The intent with this class is to hold things we want to pass from operation to operation within a transaction in
057 * order to avoid looking things up multiple times, etc.
058 * </p>
059 *
060 * @since 5.0.0
061 */
062public class TransactionDetails {
063
064        private static final Logger ourLog = LoggerFactory.getLogger(TransactionDetails.class);
065        public static final IResourcePersistentId NOT_FOUND = IResourcePersistentId.NOT_FOUND;
066
067        private final Date myTransactionDate;
068        private List<Runnable> myRollbackUndoActions = Collections.emptyList();
069        private Map<String, RequestPartitionId> myResolvedPartitions = Collections.emptyMap();
070        private Map<String, IResourcePersistentId> myResolvedResourceIds = Collections.emptyMap();
071        /** The reverse of myResolvedResourceIds. Safe since id:pid is 1:1. */
072        private Map<IResourcePersistentId, IIdType> myReverseResolvedResourceIds = Collections.emptyMap();
073
074        private Map<String, IResourcePersistentId> myResolvedMatchUrls = Collections.emptyMap();
075        private Map<String, Supplier<IBaseResource>> myResolvedResources = Collections.emptyMap();
076        private Set<IResourcePersistentId> myDeletedResourceIds = Collections.emptySet();
077        private Set<IResourcePersistentId> myUpdatedResourceIds = Collections.emptySet();
078        private Map<String, Object> myUserData;
079        private ListMultimap<Pointcut, HookParams> myDeferredInterceptorBroadcasts;
080        private EnumSet<Pointcut> myDeferredInterceptorBroadcastPointcuts;
081        private boolean myFhirTransaction;
082        private List<IIdType> myAutoCreatedPlaceholderResources = Collections.emptyList();
083
084        /**
085         * Constructor
086         */
087        public TransactionDetails() {
088                this(new Date());
089        }
090
091        /**
092         * Constructor
093         */
094        public TransactionDetails(Date theTransactionDate) {
095                myTransactionDate = theTransactionDate;
096        }
097
098        /**
099         * Get the actions that should be executed if the transaction is rolled back
100         *
101         * @since 5.5.0
102         */
103        public List<Runnable> getRollbackUndoActions() {
104                return Collections.unmodifiableList(myRollbackUndoActions);
105        }
106
107        /**
108         * Add an action that should be executed if the transaction is rolled back. If a rollback is triggered, the
109         * actions will be executed in reverse order in order to leave .
110         *
111         * @since 5.5.0
112         */
113        public void addRollbackUndoAction(@Nonnull Runnable theRunnable) {
114                assert theRunnable != null;
115                if (myRollbackUndoActions.isEmpty()) {
116                        myRollbackUndoActions = new ArrayList<>();
117                }
118                myRollbackUndoActions.add(theRunnable);
119        }
120
121        /**
122         * Clears any previously added rollback actions
123         *
124         * @since 5.5.0
125         */
126        public void clearRollbackUndoActions() {
127                if (!myRollbackUndoActions.isEmpty()) {
128                        myRollbackUndoActions.clear();
129                }
130        }
131
132        /**
133         * @since 7.6.0
134         */
135        @SuppressWarnings("rawtypes")
136        public void addUpdatedResourceId(@Nonnull IResourcePersistentId theResourceId) {
137                Validate.notNull(theResourceId, "theResourceId must not be null");
138                if (myUpdatedResourceIds.isEmpty()) {
139                        myUpdatedResourceIds = new HashSet<>();
140                }
141                myUpdatedResourceIds.add(theResourceId);
142        }
143
144        /**
145         * @since 7.6.0
146         */
147        @SuppressWarnings("rawtypes")
148        public void addUpdatedResourceIds(Collection<? extends IResourcePersistentId> theResourceIds) {
149                for (IResourcePersistentId id : theResourceIds) {
150                        addUpdatedResourceId(id);
151                }
152        }
153
154        /**
155         * @since 7.6.0
156         */
157        @SuppressWarnings("rawtypes")
158        public Set<IResourcePersistentId> getUpdatedResourceIds() {
159                return myUpdatedResourceIds;
160        }
161
162        /**
163         * @since 6.8.0
164         */
165        @SuppressWarnings("rawtypes")
166        public void addDeletedResourceId(@Nonnull IResourcePersistentId theResourceId) {
167                Validate.notNull(theResourceId, "theResourceId must not be null");
168                if (myDeletedResourceIds.isEmpty()) {
169                        myDeletedResourceIds = new HashSet<>();
170                }
171                myDeletedResourceIds.add(theResourceId);
172        }
173
174        /**
175         * @since 6.8.0
176         */
177        @SuppressWarnings("rawtypes")
178        public void addDeletedResourceIds(Collection<? extends IResourcePersistentId> theResourceIds) {
179                for (IResourcePersistentId<?> next : theResourceIds) {
180                        addDeletedResourceId(next);
181                }
182        }
183
184        /**
185         * @since 6.8.0
186         */
187        @SuppressWarnings("rawtypes")
188        @Nonnull
189        public Set<IResourcePersistentId> getDeletedResourceIds() {
190                return Collections.unmodifiableSet(myDeletedResourceIds);
191        }
192
193        /**
194         * If a resource has been resolved within the current transaction to a specific partition, we
195         * cache it here to avoid repeated lookups.
196         */
197        public void addResolvedPartition(String theId, RequestPartitionId thePartitionId) {
198                Validate.notBlank(theId, "theId must not be blank");
199                if (myResolvedPartitions.isEmpty()) {
200                        myResolvedPartitions = new HashMap<>();
201                }
202                myResolvedPartitions.put(theId, thePartitionId);
203        }
204
205        /**
206         * If a resource has been resolved within the current transaction to a specific partition, we
207         * cache it here to avoid repeated lookups.
208         */
209        public RequestPartitionId getResolvedPartition(String theId) {
210                return myResolvedPartitions.get(theId);
211        }
212
213        /**
214         * A <b>Resolved Resource ID</b> is a mapping between a resource ID (e.g. "<code>Patient/ABC</code>" or
215         * "<code>Observation/123</code>") and a storage ID for that resource. Resources should only be placed within
216         * the TransactionDetails if they are known to exist and be valid targets for other resources to link to.
217         */
218        @Nullable
219        public IResourcePersistentId getResolvedResourceId(IIdType theId) {
220                String idValue = theId.toUnqualifiedVersionless().getValue();
221                return myResolvedResourceIds.get(idValue);
222        }
223
224        /**
225         * A <b>Resolved Resource ID</b> is a mapping between a resource ID (e.g. "<code>Patient/ABC</code>" or
226         * "<code>Observation/123</code>") and the actual persisted/resolved resource with this ID.
227         */
228        @Nullable
229        public IBaseResource getResolvedResource(IIdType theId) {
230                String idValue = theId.toUnqualifiedVersionless().getValue();
231                IBaseResource retVal = null;
232                Supplier<IBaseResource> supplier = myResolvedResources.get(idValue);
233                if (supplier != null) {
234                        retVal = supplier.get();
235                }
236                return retVal;
237        }
238
239        /**
240         * Was the given resource ID resolved previously in this transaction as not existing
241         */
242        public boolean isResolvedResourceIdEmpty(IIdType theId) {
243                if (myResolvedResourceIds != null) {
244                        if (myResolvedResourceIds.containsKey(theId.toVersionless().getValue())) {
245                                return myResolvedResourceIds.get(theId.toVersionless().getValue()) == null;
246                        }
247                }
248                return false;
249        }
250
251        /**
252         * Was the given resource ID resolved previously in this transaction
253         */
254        public boolean hasResolvedResourceId(IIdType theId) {
255                if (myResolvedResourceIds != null) {
256                        return myResolvedResourceIds.containsKey(theId.toVersionless().getValue());
257                }
258                return false;
259        }
260
261        /**
262         * Returns true if the given ID was marked as not existing (i.e. someone called
263         * {@link #addResolvedResourceId(IIdType, IResourcePersistentId)} with an
264         * ID of null).
265         *
266         * @param theId The resource ID
267         * @since 8.0.0
268         */
269        public boolean hasNullResolvedResourceId(IIdType theId) {
270                if (myResolvedResourceIds != null) {
271                        String key = theId.toVersionless().getValue();
272                        if (myResolvedResourceIds.containsKey(key)) {
273                                return myResolvedResourceIds.get(key) == null;
274                        }
275                }
276                return false;
277        }
278
279        /**
280         * A <b>Resolved Resource ID</b> is a mapping between a resource ID (e.g. "<code>Patient/ABC</code>" or
281         * "<code>Observation/123</code>") and a storage ID for that resource. Resources should only be placed within
282         * the TransactionDetails if they are known to exist and be valid targets for other resources to link to.
283         */
284        public void addResolvedResourceId(IIdType theResourceId, @Nullable IResourcePersistentId thePersistentId) {
285                assert theResourceId != null;
286
287                if (myResolvedResourceIds.isEmpty()) {
288                        myResolvedResourceIds = new HashMap<>();
289                        myReverseResolvedResourceIds = new HashMap<>();
290                }
291                String fhirId = theResourceId.toVersionless().getValue();
292                myResolvedResourceIds.put(fhirId, thePersistentId);
293                myReverseResolvedResourceIds.put(thePersistentId, theResourceId);
294        }
295
296        /**
297         * A <b>Resolved Resource ID</b> is a mapping between a resource ID (e.g. "<code>Patient/ABC</code>" or
298         * "<code>Observation/123</code>") and the actual persisted/resolved resource.
299         * This version takes a {@link Supplier} which will only be fetched if the
300         * resource is actually needed. This is good in cases where the resource is
301         * lazy loaded.
302         */
303        public void addResolvedResource(IIdType theResourceId, @Nonnull Supplier<IBaseResource> theResource) {
304                assert theResourceId != null;
305
306                if (myResolvedResources.isEmpty()) {
307                        myResolvedResources = new HashMap<>();
308                }
309                IIdType versionless = theResourceId.toVersionless();
310                myResolvedResources.put(versionless.getValue(), theResource);
311        }
312
313        /**
314         * A <b>Resolved Resource ID</b> is a mapping between a resource ID (e.g. "<code>Patient/ABC</code>" or
315         * "<code>Observation/123</code>") and the actual persisted/resolved resource.
316         */
317        public void addResolvedResource(IIdType theResourceId, @Nonnull IBaseResource theResource) {
318                addResolvedResource(theResourceId, () -> theResource);
319        }
320
321        public Map<String, IResourcePersistentId> getResolvedMatchUrls() {
322                return myResolvedMatchUrls;
323        }
324
325        /**
326         * A <b>Resolved Conditional URL</b> is a mapping between a conditional URL (e.g. "<code>Patient?identifier=foo|bar</code>" or
327         * "<code>Observation/123</code>") and a storage ID for that resource. Resources should only be placed within
328         * the TransactionDetails if they are known to exist and be valid targets for other resources to link to.
329         */
330        public void addResolvedMatchUrl(
331                        FhirContext theFhirContext, String theConditionalUrl, @Nonnull IResourcePersistentId<?> thePersistentId) {
332                Validate.notBlank(theConditionalUrl, "theConditionalUrl must not be blank");
333                Validate.notNull(thePersistentId, "thePersistentId must not be null");
334
335                if (myResolvedMatchUrls.isEmpty()) {
336                        myResolvedMatchUrls = new HashMap<>();
337                } else if (matchUrlWithDiffIdExists(theConditionalUrl, thePersistentId)) {
338                        String msg = theFhirContext
339                                        .getLocalizer()
340                                        .getMessage(TransactionDetails.class, "invalidMatchUrlMultipleMatches", theConditionalUrl);
341                        throw new PreconditionFailedException(Msg.code(2207) + msg);
342                }
343                myResolvedMatchUrls.put(theConditionalUrl, thePersistentId);
344        }
345
346        /**
347         * @see #addResolvedMatchUrl(FhirContext, String, IResourcePersistentId)
348         * @since 6.8.0
349         */
350        public void removeResolvedMatchUrl(String theMatchUrl) {
351                myResolvedMatchUrls.remove(theMatchUrl);
352        }
353
354        private boolean matchUrlWithDiffIdExists(String theConditionalUrl, @Nonnull IResourcePersistentId thePersistentId) {
355                if (myResolvedMatchUrls.containsKey(theConditionalUrl)
356                                && myResolvedMatchUrls.get(theConditionalUrl) != NOT_FOUND) {
357                        return !myResolvedMatchUrls.get(theConditionalUrl).getId().equals(thePersistentId.getId());
358                }
359                return false;
360        }
361
362        /**
363         * This is the wall-clock time that a given transaction started.
364         */
365        public Date getTransactionDate() {
366                return myTransactionDate;
367        }
368
369        /**
370         * Remove an item previously stored in user data
371         *
372         * @see #getUserData(String)
373         */
374        public void clearUserData(String theKey) {
375                if (myUserData != null) {
376                        myUserData.remove(theKey);
377                }
378        }
379
380        /**
381         * Sets an arbitrary object that will last the lifetime of the current transaction
382         *
383         * @see #getUserData(String)
384         */
385        public void putUserData(String theKey, Object theValue) {
386                if (myUserData == null) {
387                        myUserData = new HashMap<>();
388                }
389                myUserData.put(theKey, theValue);
390        }
391
392        /**
393         * Gets an arbitrary object that will last the lifetime of the current transaction
394         *
395         * @see #putUserData(String, Object)
396         */
397        @SuppressWarnings("unchecked")
398        public <T> T getUserData(String theKey) {
399                if (myUserData != null) {
400                        return (T) myUserData.get(theKey);
401                }
402                return null;
403        }
404
405        /**
406         * Fetches the existing value in the user data map, or uses {@literal theSupplier} to create a new object and
407         * puts that in the map, and returns it
408         */
409        @SuppressWarnings("unchecked")
410        public <T> T getOrCreateUserData(String theKey, Supplier<T> theSupplier) {
411                T retVal = getUserData(theKey);
412                if (retVal == null) {
413                        retVal = theSupplier.get();
414                        putUserData(theKey, retVal);
415                }
416                return retVal;
417        }
418
419        /**
420         * This can be used by processors for FHIR transactions to defer interceptor broadcasts on sub-requests if needed
421         *
422         * @since 5.2.0
423         */
424        public void beginAcceptingDeferredInterceptorBroadcasts(Pointcut... thePointcuts) {
425                Validate.isTrue(!isAcceptingDeferredInterceptorBroadcasts());
426                myDeferredInterceptorBroadcasts = ArrayListMultimap.create();
427                myDeferredInterceptorBroadcastPointcuts = EnumSet.of(thePointcuts[0], thePointcuts);
428        }
429
430        /**
431         * This can be used by processors for FHIR transactions to defer interceptor broadcasts on sub-requests if needed
432         *
433         * @since 5.2.0
434         */
435        public boolean isAcceptingDeferredInterceptorBroadcasts() {
436                return myDeferredInterceptorBroadcasts != null;
437        }
438
439        /**
440         * This can be used by processors for FHIR transactions to defer interceptor broadcasts on sub-requests if needed
441         *
442         * @since 5.2.0
443         */
444        public boolean isAcceptingDeferredInterceptorBroadcasts(Pointcut thePointcut) {
445                return myDeferredInterceptorBroadcasts != null && myDeferredInterceptorBroadcastPointcuts.contains(thePointcut);
446        }
447
448        /**
449         * This can be used by processors for FHIR transactions to defer interceptor broadcasts on sub-requests if needed
450         *
451         * @since 5.2.0
452         */
453        public ListMultimap<Pointcut, HookParams> endAcceptingDeferredInterceptorBroadcasts() {
454                Validate.isTrue(isAcceptingDeferredInterceptorBroadcasts());
455                ListMultimap<Pointcut, HookParams> retVal = myDeferredInterceptorBroadcasts;
456                myDeferredInterceptorBroadcasts = null;
457                myDeferredInterceptorBroadcastPointcuts = null;
458                return retVal;
459        }
460
461        /**
462         * This can be used by processors for FHIR transactions to defer interceptor broadcasts on sub-requests if needed
463         *
464         * @since 5.2.0
465         */
466        public void addDeferredInterceptorBroadcast(Pointcut thePointcut, HookParams theHookParams) {
467                Validate.isTrue(isAcceptingDeferredInterceptorBroadcasts(thePointcut));
468                myDeferredInterceptorBroadcasts.put(thePointcut, theHookParams);
469        }
470
471        public InterceptorInvocationTimingEnum getInvocationTiming(Pointcut thePointcut) {
472                if (myDeferredInterceptorBroadcasts == null) {
473                        return InterceptorInvocationTimingEnum.ACTIVE;
474                }
475                List<HookParams> hookParams = myDeferredInterceptorBroadcasts.get(thePointcut);
476                return hookParams == null ? InterceptorInvocationTimingEnum.ACTIVE : InterceptorInvocationTimingEnum.DEFERRED;
477        }
478
479        public void deferredBroadcastProcessingFinished() {}
480
481        public void clearResolvedItems() {
482                myResolvedResourceIds.clear();
483                myReverseResolvedResourceIds.clear();
484                myResolvedMatchUrls.clear();
485        }
486
487        public boolean hasResolvedResourceIds() {
488                return !myResolvedResourceIds.isEmpty();
489        }
490
491        public boolean isFhirTransaction() {
492                return myFhirTransaction;
493        }
494
495        public void setFhirTransaction(boolean theFhirTransaction) {
496                myFhirTransaction = theFhirTransaction;
497        }
498
499        public void addAutoCreatedPlaceholderResource(IIdType theResource) {
500                if (myAutoCreatedPlaceholderResources.isEmpty()) {
501                        myAutoCreatedPlaceholderResources = new ArrayList<>();
502                }
503                myAutoCreatedPlaceholderResources.add(theResource);
504        }
505
506        @Nonnull
507        public List<IIdType> getAutoCreatedPlaceholderResourcesAndClear() {
508                List<IIdType> retVal = myAutoCreatedPlaceholderResources;
509                if (!myAutoCreatedPlaceholderResources.isEmpty()) {
510                        myAutoCreatedPlaceholderResources = Collections.emptyList();
511                }
512                return retVal;
513        }
514
515        public <T extends IResourcePersistentId<?>> IIdType getReverseResolvedId(T thePid) {
516                return myReverseResolvedResourceIds.get(thePid);
517        }
518}