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