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                assert theResourceId.getValue() != 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}