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