001/*-
002 * #%L
003 * HAPI FHIR JPA Server
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.jpa.dao;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.context.RuntimeResourceDefinition;
024import ca.uhn.fhir.interceptor.model.RequestPartitionId;
025import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
026import ca.uhn.fhir.jpa.api.dao.IFhirSystemDao;
027import ca.uhn.fhir.jpa.api.model.DaoMethodOutcome;
028import ca.uhn.fhir.jpa.api.svc.IIdHelperService;
029import ca.uhn.fhir.jpa.api.svc.ResolveIdentityMode;
030import ca.uhn.fhir.jpa.config.HapiFhirHibernateJpaDialect;
031import ca.uhn.fhir.jpa.model.cross.IResourceLookup;
032import ca.uhn.fhir.jpa.model.dao.JpaPid;
033import ca.uhn.fhir.jpa.model.entity.ResourceIndexedSearchParamToken;
034import ca.uhn.fhir.jpa.model.entity.ResourceTable;
035import ca.uhn.fhir.jpa.model.entity.StorageSettings;
036import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc;
037import ca.uhn.fhir.jpa.search.ResourceSearchUrlSvc;
038import ca.uhn.fhir.jpa.searchparam.MatchUrlService;
039import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
040import ca.uhn.fhir.jpa.util.MemoryCacheService;
041import ca.uhn.fhir.jpa.util.QueryChunker;
042import ca.uhn.fhir.model.api.IQueryParameterType;
043import ca.uhn.fhir.rest.api.server.RequestDetails;
044import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId;
045import ca.uhn.fhir.rest.api.server.storage.TransactionDetails;
046import ca.uhn.fhir.rest.param.TokenParam;
047import ca.uhn.fhir.util.FhirTerser;
048import ca.uhn.fhir.util.ResourceReferenceInfo;
049import ca.uhn.fhir.util.StopWatch;
050import ca.uhn.fhir.util.TaskChunker;
051import com.google.common.annotations.VisibleForTesting;
052import com.google.common.collect.ArrayListMultimap;
053import com.google.common.collect.ListMultimap;
054import jakarta.annotation.Nonnull;
055import jakarta.persistence.EntityManager;
056import jakarta.persistence.FlushModeType;
057import jakarta.persistence.PersistenceContext;
058import jakarta.persistence.PersistenceContextType;
059import jakarta.persistence.PersistenceException;
060import jakarta.persistence.Tuple;
061import jakarta.persistence.TypedQuery;
062import jakarta.persistence.criteria.CriteriaBuilder;
063import jakarta.persistence.criteria.CriteriaQuery;
064import jakarta.persistence.criteria.Predicate;
065import jakarta.persistence.criteria.Root;
066import org.apache.commons.lang3.Validate;
067import org.hibernate.internal.SessionImpl;
068import org.hl7.fhir.instance.model.api.IBase;
069import org.hl7.fhir.instance.model.api.IBaseBundle;
070import org.hl7.fhir.instance.model.api.IBaseResource;
071import org.hl7.fhir.instance.model.api.IIdType;
072import org.slf4j.Logger;
073import org.slf4j.LoggerFactory;
074import org.springframework.beans.factory.annotation.Autowired;
075import org.springframework.context.ApplicationContext;
076
077import java.util.ArrayList;
078import java.util.Collection;
079import java.util.HashMap;
080import java.util.HashSet;
081import java.util.IdentityHashMap;
082import java.util.Iterator;
083import java.util.List;
084import java.util.Map;
085import java.util.Set;
086import java.util.TreeMap;
087import java.util.regex.Pattern;
088import java.util.stream.Collectors;
089
090import static ca.uhn.fhir.util.UrlUtil.determineResourceTypeInResourceUrl;
091import static org.apache.commons.lang3.StringUtils.countMatches;
092import static org.apache.commons.lang3.StringUtils.isNotBlank;
093
094public class TransactionProcessor extends BaseTransactionProcessor {
095
096        /**
097         * Matches conditional URLs in the form of [resourceType]?[paramName]=[paramValue]{...more params...}
098         */
099        public static final Pattern MATCH_URL_PATTERN = Pattern.compile("^[^?]++[?][a-z0-9-]+=[^&,]++");
100
101        public static final int CONDITIONAL_URL_FETCH_CHUNK_SIZE = 100;
102        private static final Logger ourLog = LoggerFactory.getLogger(TransactionProcessor.class);
103
104        @Autowired
105        private ApplicationContext myApplicationContext;
106
107        @PersistenceContext(type = PersistenceContextType.TRANSACTION)
108        private EntityManager myEntityManager;
109
110        @Autowired(required = false)
111        private HapiFhirHibernateJpaDialect myHapiFhirHibernateJpaDialect;
112
113        @Autowired
114        private IIdHelperService<JpaPid> myIdHelperService;
115
116        @Autowired
117        private JpaStorageSettings myStorageSettings;
118
119        @Autowired
120        private FhirContext myFhirContext;
121
122        @Autowired
123        private MatchResourceUrlService<JpaPid> myMatchResourceUrlService;
124
125        @Autowired
126        private MatchUrlService myMatchUrlService;
127
128        @Autowired
129        private ResourceSearchUrlSvc myResourceSearchUrlSvc;
130
131        @Autowired
132        private MemoryCacheService myMemoryCacheService;
133
134        @Autowired
135        private IRequestPartitionHelperSvc myRequestPartitionHelperSvc;
136
137        public void setEntityManagerForUnitTest(EntityManager theEntityManager) {
138                myEntityManager = theEntityManager;
139        }
140
141        @Override
142        protected void validateDependencies() {
143                super.validateDependencies();
144
145                Validate.notNull(myEntityManager, "EntityManager must not be null");
146        }
147
148        @VisibleForTesting
149        public void setFhirContextForUnitTest(FhirContext theFhirContext) {
150                myFhirContext = theFhirContext;
151        }
152
153        @Override
154        public void setStorageSettings(StorageSettings theStorageSettings) {
155                myStorageSettings = (JpaStorageSettings) theStorageSettings;
156                super.setStorageSettings(theStorageSettings);
157        }
158
159        @Override
160        protected EntriesToProcessMap doTransactionWriteOperations(
161                        final RequestDetails theRequest,
162                        String theActionName,
163                        TransactionDetails theTransactionDetails,
164                        Set<IIdType> theAllIds,
165                        IdSubstitutionMap theIdSubstitutions,
166                        Map<IIdType, DaoMethodOutcome> theIdToPersistedOutcome,
167                        IBaseBundle theResponse,
168                        IdentityHashMap<IBase, Integer> theOriginalRequestOrder,
169                        List<IBase> theEntries,
170                        StopWatch theTransactionStopWatch) {
171
172                /*
173                 * We temporarily set the flush mode for the duration of the DB transaction
174                 * from the default of AUTO to the temporary value of COMMIT here. We do this
175                 * because in AUTO mode, if any SQL SELECTs are required during the
176                 * processing of an individual transaction entry, the server will flush the
177                 * pending INSERTs/UPDATEs to the database before executing the SELECT.
178                 * This hurts performance since we don't get the benefit of batching those
179                 * write operations as much as possible. The tradeoff here is that we
180                 * could theoretically have transaction operations which try to read
181                 * data previously written in the same transaction, and they won't see it.
182                 * This shouldn't actually be an issue anyhow - we pre-fetch conditional
183                 * URLs and reference targets at the start of the transaction. But this
184                 * tradeoff still feels worth it, since the most common use of transactions
185                 * is for fast writing of data.
186                 *
187                 * Note that it's probably not necessary to reset it back, it should
188                 * automatically go back to the default value after the transaction, but
189                 * we reset it just to be safe.
190                 */
191                FlushModeType initialFlushMode = myEntityManager.getFlushMode();
192                try {
193                        myEntityManager.setFlushMode(FlushModeType.COMMIT);
194
195                        ITransactionProcessorVersionAdapter<?, ?> versionAdapter = getVersionAdapter();
196                        RequestPartitionId requestPartitionId =
197                                        super.determineRequestPartitionIdForWriteEntries(theRequest, theEntries);
198
199                        if (requestPartitionId != null) {
200                                preFetch(theRequest, theTransactionDetails, theEntries, versionAdapter, requestPartitionId);
201                        }
202
203                        return super.doTransactionWriteOperations(
204                                        theRequest,
205                                        theActionName,
206                                        theTransactionDetails,
207                                        theAllIds,
208                                        theIdSubstitutions,
209                                        theIdToPersistedOutcome,
210                                        theResponse,
211                                        theOriginalRequestOrder,
212                                        theEntries,
213                                        theTransactionStopWatch);
214                } finally {
215                        myEntityManager.setFlushMode(initialFlushMode);
216                }
217        }
218
219        @SuppressWarnings("rawtypes")
220        private void preFetch(
221                        RequestDetails theRequestDetails,
222                        TransactionDetails theTransactionDetails,
223                        List<IBase> theEntries,
224                        ITransactionProcessorVersionAdapter theVersionAdapter,
225                        RequestPartitionId theRequestPartitionId) {
226                Set<String> foundIds = new HashSet<>();
227                Set<JpaPid> idsToPreFetchBodiesFor = new HashSet<>();
228                Set<JpaPid> idsToPreFetchVersionsFor = new HashSet<>();
229
230                /*
231                 * Pre-Fetch any resources that are referred to normally by ID, e.g.
232                 * regular FHIR updates within the transaction.
233                 */
234                preFetchResourcesById(
235                                theTransactionDetails,
236                                theEntries,
237                                theVersionAdapter,
238                                theRequestPartitionId,
239                                foundIds,
240                                idsToPreFetchBodiesFor);
241
242                /*
243                 * Pre-resolve any conditional URLs we can
244                 */
245                preFetchConditionalUrls(
246                                theRequestDetails,
247                                theTransactionDetails,
248                                theEntries,
249                                theVersionAdapter,
250                                theRequestPartitionId,
251                                idsToPreFetchBodiesFor,
252                                idsToPreFetchVersionsFor);
253
254                /*
255                 * Pre-Fetch Resource Bodies (this will happen for any resources we are potentially
256                 * going to update)
257                 */
258                IFhirSystemDao<?, ?> systemDao = myApplicationContext.getBean(IFhirSystemDao.class);
259                systemDao.preFetchResources(List.copyOf(idsToPreFetchBodiesFor), true);
260
261                /*
262                 * Pre-Fetch Resource Versions (this will happen for any resources we are doing a
263                 * conditional create on, meaning we don't actually care about the contents, just
264                 * the ID and version)
265                 */
266                preFetchResourceVersions(idsToPreFetchVersionsFor);
267        }
268
269        /**
270         * Given a collection of {@link JpaPid}, loads the current version associated with
271         * each PID and puts it into the {@link JpaPid#setVersion(Long)} field.
272         */
273        private void preFetchResourceVersions(Set<JpaPid> theIds) {
274                ourLog.trace("Versions to fetch: {}", theIds);
275
276                for (Iterator<JpaPid> it = theIds.iterator(); it.hasNext(); ) {
277                        JpaPid pid = it.next();
278                        Long version = myMemoryCacheService.getIfPresent(
279                                        MemoryCacheService.CacheEnum.RESOURCE_CONDITIONAL_CREATE_VERSION, pid);
280                        if (version != null) {
281                                it.remove();
282                                pid.setVersion(version);
283                        }
284                }
285
286                if (!theIds.isEmpty()) {
287                        Map<JpaPid, JpaPid> idMap = theIds.stream().collect(Collectors.toMap(t -> t, t -> t));
288
289                        QueryChunker.chunk(theIds, ids -> {
290                                CriteriaBuilder cb = myEntityManager.getCriteriaBuilder();
291                                CriteriaQuery<Tuple> cq = cb.createTupleQuery();
292                                Root<ResourceTable> from = cq.from(ResourceTable.class);
293                                cq.multiselect(from.get("myPid"), from.get("myVersion"));
294                                cq.where(from.get("myPid").in(ids));
295                                TypedQuery<Tuple> query = myEntityManager.createQuery(cq);
296                                List<Tuple> results = query.getResultList();
297
298                                for (Tuple tuple : results) {
299                                        JpaPid pid = tuple.get(0, JpaPid.class);
300                                        Long version = tuple.get(1, Long.class);
301                                        idMap.get(pid).setVersion(version);
302
303                                        myMemoryCacheService.putAfterCommit(
304                                                        MemoryCacheService.CacheEnum.RESOURCE_CONDITIONAL_CREATE_VERSION, pid, version);
305                                }
306                        });
307                }
308        }
309
310        @Override
311        @SuppressWarnings("rawtypes")
312        protected void postTransactionProcess(TransactionDetails theTransactionDetails) {
313                Set<IResourcePersistentId> resourceIds = theTransactionDetails.getUpdatedResourceIds();
314                if (resourceIds != null && !resourceIds.isEmpty()) {
315                        List<JpaPid> ids = resourceIds.stream().map(r -> (JpaPid) r).collect(Collectors.toList());
316                        myResourceSearchUrlSvc.deleteByResIds(ids);
317                }
318        }
319
320        @SuppressWarnings({"unchecked", "rawtypes"})
321        private void preFetchResourcesById(
322                        TransactionDetails theTransactionDetails,
323                        List<IBase> theEntries,
324                        ITransactionProcessorVersionAdapter theVersionAdapter,
325                        RequestPartitionId theRequestPartitionId,
326                        Set<String> foundIds,
327                        Set<JpaPid> theIdsToPreFetchBodiesFor) {
328
329                FhirTerser terser = myFhirContext.newTerser();
330
331                enum PrefetchReasonEnum {
332                        /**
333                         * The ID is being prefetched because it is the ID in a resource reference
334                         * within a resource being updated. In this case, we care whether the resource
335                         * is deleted (since you can't reference a deleted resource), but we don't
336                         * need to fetch the body since we don't actually care about its contents.
337                         */
338                        REFERENCE_TARGET,
339                        /**
340                         * The ID is being prefetched because it is the ID of a resource being
341                         * updated directly by the transaction. In this case we don't care if it's
342                         * deleted (since it's fine to update a deleted resource), and we do need
343                         * to prefetch the current body so we can tell how it has changed.
344                         */
345                        DIRECT_TARGET
346                }
347                Map<IIdType, PrefetchReasonEnum> idsToPreResolve = new HashMap<>(theEntries.size() * 3);
348
349                for (IBase nextEntry : theEntries) {
350                        IBaseResource resource = theVersionAdapter.getResource(nextEntry);
351                        if (resource != null) {
352                                String verb = theVersionAdapter.getEntryRequestVerb(myFhirContext, nextEntry);
353
354                                /*
355                                 * Pre-fetch any resources that are being updated or patched within
356                                 * the transaction
357                                 */
358                                if ("PUT".equals(verb) || "PATCH".equals(verb)) {
359                                        String requestUrl = theVersionAdapter.getEntryRequestUrl(nextEntry);
360                                        if (countMatches(requestUrl, '?') == 0) {
361                                                IIdType id = myFhirContext.getVersion().newIdType();
362                                                id.setValue(requestUrl);
363                                                IIdType unqualifiedVersionless = id.toUnqualifiedVersionless();
364                                                idsToPreResolve.put(unqualifiedVersionless, PrefetchReasonEnum.DIRECT_TARGET);
365                                        }
366                                }
367
368                                /*
369                                 * If there are any resource references anywhere in any resources being
370                                 * created or updated that point to another target resource directly by
371                                 * ID, we also want to prefetch the identity of that target ID
372                                 */
373                                if ("PUT".equals(verb) || "POST".equals(verb)) {
374                                        for (ResourceReferenceInfo referenceInfo : terser.getAllResourceReferences(resource)) {
375                                                IIdType reference = referenceInfo.getResourceReference().getReferenceElement();
376                                                if (reference != null
377                                                                && !reference.isLocal()
378                                                                && !reference.isUuid()
379                                                                && reference.hasResourceType()
380                                                                && reference.hasIdPart()
381                                                                && !reference.getValue().contains("?")) {
382
383                                                        // We use putIfAbsent here because if we're already fetching
384                                                        // as a direct target we don't want to downgrade to just a
385                                                        // reference target
386                                                        idsToPreResolve.putIfAbsent(
387                                                                        reference.toUnqualifiedVersionless(), PrefetchReasonEnum.REFERENCE_TARGET);
388                                                }
389                                        }
390                                }
391                        }
392                }
393
394                /*
395                 * If any of the entries in the pre-fetch ID map have a value of REFERENCE_TARGET,
396                 * this means we can't rely on cached identities because we need to know the
397                 * current deleted status of at least one of them. This is because another thread
398                 * (or potentially even another process elsewhere) could have moved the resource
399                 * to "deleted", and we can't allow someone to add a reference to a deleted
400                 * resource. If deletes are disabled on this server though, we can trust that
401                 * nothing has been moved to "deleted" status since it was put in the cache, and
402                 * it's safe to use the cache.
403                 *
404                 * On the other hand, if all resource IDs we want to prefetch have a value of
405                 * DIRECT_UPDATE, that means these IDs are all resources we're about to
406                 * modify. In that case it doesn't even matter if the resource is currently
407                 * deleted because we're going to resurrect it in that case.
408                 */
409                boolean preFetchIncludesReferences =
410                                idsToPreResolve.values().stream().anyMatch(t -> t == PrefetchReasonEnum.REFERENCE_TARGET);
411                ResolveIdentityMode resolveMode = preFetchIncludesReferences
412                                ? ResolveIdentityMode.includeDeleted().noCacheUnlessDeletesDisabled()
413                                : ResolveIdentityMode.includeDeleted().cacheOk();
414
415                Map<IIdType, IResourceLookup<JpaPid>> outcomes = myIdHelperService.resolveResourceIdentities(
416                                theRequestPartitionId, idsToPreResolve.keySet(), resolveMode);
417                for (Iterator<Map.Entry<IIdType, IResourceLookup<JpaPid>>> iterator =
418                                                outcomes.entrySet().iterator();
419                                iterator.hasNext(); ) {
420                        Map.Entry<IIdType, IResourceLookup<JpaPid>> entry = iterator.next();
421                        JpaPid next = entry.getValue().getPersistentId();
422                        IIdType unqualifiedVersionlessId = entry.getKey();
423                        switch (idsToPreResolve.get(unqualifiedVersionlessId)) {
424                                case DIRECT_TARGET -> {
425                                        if (myStorageSettings.getResourceClientIdStrategy() != JpaStorageSettings.ClientIdStrategyEnum.ANY
426                                                        || (next.getAssociatedResourceId() != null
427                                                                        && !next.getAssociatedResourceId().isIdPartValidLong())) {
428                                                theIdsToPreFetchBodiesFor.add(next);
429                                        }
430                                }
431                                case REFERENCE_TARGET -> {
432                                        if (entry.getValue().getDeleted() != null) {
433                                                iterator.remove();
434                                                continue;
435                                        }
436                                }
437                        }
438
439                        foundIds.add(unqualifiedVersionlessId.getValue());
440                        theTransactionDetails.addResolvedResourceId(unqualifiedVersionlessId, next);
441                }
442
443                // Any IDs that could not be resolved are presumably not there, so
444                // cache that fact so we don't look again later
445                for (IIdType next : idsToPreResolve.keySet()) {
446                        if (!foundIds.contains(next.getValue())) {
447                                theTransactionDetails.addResolvedResourceId(next.toUnqualifiedVersionless(), null);
448                        }
449                }
450        }
451
452        @Override
453        protected void handleVerbChangeInTransactionWriteOperations() {
454                super.handleVerbChangeInTransactionWriteOperations();
455
456                myEntityManager.flush();
457        }
458
459        @SuppressWarnings({"rawtypes", "unchecked"})
460        private void preFetchConditionalUrls(
461                        RequestDetails theRequestDetails,
462                        TransactionDetails theTransactionDetails,
463                        List<IBase> theEntries,
464                        ITransactionProcessorVersionAdapter theVersionAdapter,
465                        RequestPartitionId theRequestPartitionId,
466                        Set<JpaPid> theIdsToPreFetchBodiesFor,
467                        Set<JpaPid> theIdsToPreFetchVersionsFor) {
468
469                List<MatchUrlToResolve> searchParameterMapsToResolve = new ArrayList<>();
470                for (IBase nextEntry : theEntries) {
471                        IBaseResource resource = theVersionAdapter.getResource(nextEntry);
472                        if (resource != null) {
473                                String verb = theVersionAdapter.getEntryRequestVerb(myFhirContext, nextEntry);
474                                String requestUrl = theVersionAdapter.getEntryRequestUrl(nextEntry);
475                                String requestIfNoneExist = theVersionAdapter.getEntryIfNoneExist(nextEntry);
476                                String resourceType = determineResourceTypeInResourceUrl(myFhirContext, requestUrl);
477                                if (resourceType == null) {
478                                        resourceType = myFhirContext.getResourceType(resource);
479                                }
480                                if (("PUT".equals(verb) || "PATCH".equals(verb)) && requestUrl != null && requestUrl.contains("?")) {
481                                        processConditionalUrlForPreFetching(
482                                                        theRequestPartitionId,
483                                                        resourceType,
484                                                        requestUrl,
485                                                        true,
486                                                        false,
487                                                        theIdsToPreFetchBodiesFor,
488                                                        searchParameterMapsToResolve);
489                                } else if ("POST".equals(verb) && requestIfNoneExist != null && requestIfNoneExist.contains("?")) {
490                                        processConditionalUrlForPreFetching(
491                                                        theRequestPartitionId,
492                                                        resourceType,
493                                                        requestIfNoneExist,
494                                                        false,
495                                                        true,
496                                                        theIdsToPreFetchBodiesFor,
497                                                        searchParameterMapsToResolve);
498                                }
499
500                                if (myStorageSettings.isAllowInlineMatchUrlReferences()) {
501                                        List<ResourceReferenceInfo> references =
502                                                        myFhirContext.newTerser().getAllResourceReferences(resource);
503                                        for (ResourceReferenceInfo next : references) {
504                                                String referenceUrl = next.getResourceReference()
505                                                                .getReferenceElement()
506                                                                .getValue();
507                                                String refResourceType = determineResourceTypeInResourceUrl(myFhirContext, referenceUrl);
508                                                if (refResourceType != null) {
509                                                        processConditionalUrlForPreFetching(
510                                                                        theRequestPartitionId,
511                                                                        refResourceType,
512                                                                        referenceUrl,
513                                                                        false,
514                                                                        false,
515                                                                        theIdsToPreFetchBodiesFor,
516                                                                        searchParameterMapsToResolve);
517                                                }
518                                        }
519                                }
520                        }
521                }
522
523                TaskChunker.chunk(
524                                searchParameterMapsToResolve,
525                                CONDITIONAL_URL_FETCH_CHUNK_SIZE,
526                                map -> preFetchSearchParameterMaps(
527                                                theRequestDetails,
528                                                theTransactionDetails,
529                                                theRequestPartitionId,
530                                                map,
531                                                theIdsToPreFetchBodiesFor,
532                                                theIdsToPreFetchVersionsFor));
533        }
534
535        /**
536         * This method attempts to resolve a collection of conditional URLs that were found
537         * in a FHIR transaction bundle being processed.
538         *
539         * @param theRequestDetails              The active request
540         * @param theTransactionDetails          The active transaction details
541         * @param theRequestPartitionId          The active partition
542         * @param theInputParameters             These are the conditional URLs that will actually be resolved
543         * @param theOutputPidsToLoadBodiesFor   This list will be added to with any resource PIDs that need to be fully
544         *                                       preloaded (i.e. fetch the actual resource body since we're presumably
545         *                                       going to update it and will need to see its current state eventually)
546         * @param theOutputPidsToLoadVersionsFor This list will be added to with any resource PIDs that need to have
547         *                                       their current version resolved. This is used for conditional creates,
548         *                                       where we don't actually care about the body of the resource, only
549         *                                       the version it has (since the version is returned in the response,
550         *                                       and potentially used if we're auto-versioning references).
551         */
552        @VisibleForTesting
553        public void preFetchSearchParameterMaps(
554                        RequestDetails theRequestDetails,
555                        TransactionDetails theTransactionDetails,
556                        RequestPartitionId theRequestPartitionId,
557                        List<MatchUrlToResolve> theInputParameters,
558                        Set<JpaPid> theOutputPidsToLoadBodiesFor,
559                        Set<JpaPid> theOutputPidsToLoadVersionsFor) {
560
561                Set<Long> systemAndValueHashes = new HashSet<>();
562                Set<Long> valueHashes = new HashSet<>();
563
564                for (MatchUrlToResolve next : theInputParameters) {
565                        Collection<List<List<IQueryParameterType>>> values = next.myMatchUrlSearchMap.values();
566
567                        /*
568                         * Any conditional URLs that consist of a single token parameter are batched
569                         * up into a single query against the HFJ_SPIDX_TOKEN table so that we only
570                         * perform one SQL query for all of them.
571                         *
572                         * We could potentially add other patterns in the future, but it's much more
573                         * tricky to implement this when there are multiple parameters, and non-token
574                         * parameter types aren't often used on their own in conditional URLs. So for
575                         * now we handle single-token only, and that's probably good enough.
576                         */
577                        boolean canBeHandledInAggregateQuery = false;
578
579                        if (values.size() == 1) {
580                                List<List<IQueryParameterType>> andList = values.iterator().next();
581                                IQueryParameterType param = andList.get(0).get(0);
582
583                                if (param instanceof TokenParam tokenParam) {
584                                        canBeHandledInAggregateQuery = buildHashPredicateFromTokenParam(
585                                                        tokenParam, theRequestPartitionId, next, systemAndValueHashes, valueHashes);
586                                }
587                        }
588
589                        if (!canBeHandledInAggregateQuery) {
590                                Set<JpaPid> matchUrlResults = myMatchResourceUrlService.processMatchUrl(
591                                                next.myRequestUrl,
592                                                next.myResourceDefinition.getImplementingClass(),
593                                                theTransactionDetails,
594                                                theRequestDetails,
595                                                theRequestPartitionId);
596                                for (JpaPid matchUrlResult : matchUrlResults) {
597                                        handleFoundPreFetchResourceId(
598                                                        theTransactionDetails,
599                                                        theOutputPidsToLoadBodiesFor,
600                                                        theOutputPidsToLoadVersionsFor,
601                                                        next,
602                                                        matchUrlResult);
603                                }
604                        }
605                }
606
607                preFetchSearchParameterMapsToken(
608                                "myHashSystemAndValue",
609                                systemAndValueHashes,
610                                theTransactionDetails,
611                                theRequestPartitionId,
612                                theInputParameters,
613                                theOutputPidsToLoadBodiesFor,
614                                theOutputPidsToLoadVersionsFor);
615                preFetchSearchParameterMapsToken(
616                                "myHashValue",
617                                valueHashes,
618                                theTransactionDetails,
619                                theRequestPartitionId,
620                                theInputParameters,
621                                theOutputPidsToLoadBodiesFor,
622                                theOutputPidsToLoadVersionsFor);
623
624                // For each SP Map which did not return a result, tag it as not found.
625                theInputParameters.stream()
626                                // No matches
627                                .filter(match -> !match.myResolved)
628                                .forEach(match -> {
629                                        ourLog.debug("Was unable to match url {} from database", match.myRequestUrl);
630                                        theTransactionDetails.addResolvedMatchUrl(
631                                                        myFhirContext, match.myRequestUrl, TransactionDetails.NOT_FOUND);
632                                });
633        }
634
635        /**
636         * Here we do a select against the {@link ResourceIndexedSearchParamToken} table for any rows that have the
637         * specific sys+val or val hashes we know we need to pre-fetch.
638         * <p>
639         * Note that we do a tuple query for only 2 columns in order to ensure that we can get by with only
640         * the data in the index (ie no need to load the actual table rows).
641         */
642        public void preFetchSearchParameterMapsToken(
643                        String theIndexColumnName,
644                        Set<Long> theHashesForIndexColumn,
645                        TransactionDetails theTransactionDetails,
646                        RequestPartitionId theRequestPartitionId,
647                        List<MatchUrlToResolve> theInputParameters,
648                        Set<JpaPid> theOutputPidsToLoadFully,
649                        Set<JpaPid> theOutputPidsToLoadVersionsFor) {
650                if (!theHashesForIndexColumn.isEmpty()) {
651                        ListMultimap<Long, MatchUrlToResolve> hashToSearchMap =
652                                        buildHashToSearchMap(theInputParameters, theIndexColumnName);
653                        CriteriaBuilder cb = myEntityManager.getCriteriaBuilder();
654                        CriteriaQuery<Tuple> cq = cb.createTupleQuery();
655                        Root<ResourceIndexedSearchParamToken> from = cq.from(ResourceIndexedSearchParamToken.class);
656                        cq.multiselect(from.get("myPartitionIdValue"), from.get("myResourcePid"), from.get(theIndexColumnName));
657
658                        Predicate masterPredicate;
659                        if (theHashesForIndexColumn.size() == 1) {
660                                masterPredicate = cb.equal(
661                                                from.get(theIndexColumnName),
662                                                theHashesForIndexColumn.iterator().next());
663                        } else {
664                                masterPredicate = from.get(theIndexColumnName).in(theHashesForIndexColumn);
665                        }
666
667                        if (myPartitionSettings.isPartitioningEnabled()
668                                        && !myPartitionSettings.isIncludePartitionInSearchHashes()) {
669                                if (myRequestPartitionHelperSvc.isDefaultPartition(theRequestPartitionId)
670                                                && myPartitionSettings.getDefaultPartitionId() == null) {
671                                        Predicate partitionIdCriteria = cb.isNull(from.get("myPartitionIdValue"));
672                                        masterPredicate = cb.and(partitionIdCriteria, masterPredicate);
673                                } else if (!theRequestPartitionId.isAllPartitions()) {
674                                        Predicate partitionIdCriteria =
675                                                        from.get("myPartitionIdValue").in(theRequestPartitionId.getPartitionIds());
676                                        masterPredicate = cb.and(partitionIdCriteria, masterPredicate);
677                                }
678                        }
679
680                        cq.where(masterPredicate);
681
682                        TypedQuery<Tuple> query = myEntityManager.createQuery(cq);
683
684                        /*
685                         * If we have 10 unique conditional URLs we're resolving, each one should
686                         * resolve to 0..1 resources if they are valid as conditional URLs. So we would
687                         * expect this query to return 0..10 rows, since conditional URLs for all
688                         * conditional operations except DELETE (which isn't being applied here) are
689                         * only allowed to resolve to 0..1 resources.
690                         *
691                         * If a conditional URL matches 2+ resources that is an error, and we'll
692                         * be throwing an exception below. This limit is here for safety just to
693                         * ensure that if someone uses a conditional URL that matches a million resources,
694                         * we don't do a super-expensive fetch.
695                         */
696                        query.setMaxResults(theHashesForIndexColumn.size() + 1);
697
698                        List<Tuple> results = query.getResultList();
699
700                        for (Tuple nextResult : results) {
701                                Integer nextPartitionId = nextResult.get(0, Integer.class);
702                                Long nextResourcePid = nextResult.get(1, Long.class);
703                                Long nextHash = nextResult.get(2, Long.class);
704
705                                List<MatchUrlToResolve> matchedSearch = hashToSearchMap.get(nextHash);
706                                matchedSearch.forEach(matchUrl -> {
707                                        ourLog.debug("Matched url {} from database", matchUrl.myRequestUrl);
708                                        JpaPid pid = JpaPid.fromId(nextResourcePid, nextPartitionId);
709                                        handleFoundPreFetchResourceId(
710                                                        theTransactionDetails,
711                                                        theOutputPidsToLoadFully,
712                                                        theOutputPidsToLoadVersionsFor,
713                                                        matchUrl,
714                                                        pid);
715                                });
716                        }
717                }
718        }
719
720        private void handleFoundPreFetchResourceId(
721                        TransactionDetails theTransactionDetails,
722                        Set<JpaPid> theOutputPidsToLoadFully,
723                        Set<JpaPid> theOutputPidsToLoadVersionsFor,
724                        MatchUrlToResolve theMatchUrl,
725                        JpaPid theFoundPid) {
726                if (theMatchUrl.myShouldPreFetchResourceBody) {
727                        theOutputPidsToLoadFully.add(theFoundPid);
728                }
729                if (theMatchUrl.myShouldPreFetchResourceVersion) {
730                        theOutputPidsToLoadVersionsFor.add(theFoundPid);
731                }
732                myMatchResourceUrlService.matchUrlResolved(
733                                theTransactionDetails,
734                                theMatchUrl.myResourceDefinition.getName(),
735                                theMatchUrl.myRequestUrl,
736                                theFoundPid);
737                theTransactionDetails.addResolvedMatchUrl(myFhirContext, theMatchUrl.myRequestUrl, theFoundPid);
738                theMatchUrl.setResolved(true);
739        }
740
741        /**
742         * Examines a conditional URL, and potentially adds it to either {@literal theOutputIdsToPreFetchBodiesFor}
743         * or {@literal theOutputSearchParameterMapsToResolve}.
744         * <p>
745         * Note that if {@literal theShouldPreFetchResourceBody} is false, then we'll check if a given match
746         * URL resolves to a resource PID, but we won't actually try to load that resource. If we're resolving
747         * a match URL because it's there for a conditional update, we'll eagerly fetch the
748         * actual resource because we need to know its current state in order to update it. However, if
749         * the match URL is from an inline match URL in a resource body, we really only care about
750         * the PID and don't need the body so we don't load it. This does have a security implication, since
751         * it means that the {@link ca.uhn.fhir.interceptor.api.Pointcut#STORAGE_PRESHOW_RESOURCES} pointcut
752         * isn't fired even though the user has resolved the URL (meaning they may be able to test for
753         * the existence of a resource using a match URL). There is a test for this called
754         * {@literal testTransactionCreateInlineMatchUrlWithAuthorizationDenied()}. This security tradeoff
755         * is acceptable since we're only prefetching things with very simple match URLs (nothing with
756         * a reference in it for example) so it's not really possible to doing anything useful with this.
757         * </p>
758         *
759         * @param thePartitionId                        The partition ID of the associated resource (can be null)
760         * @param theResourceType                       The resource type associated with the match URL (ie what resource type should it resolve to)
761         * @param theRequestUrl                         The actual match URL, which could be as simple as just parameters or could include the resource type too
762         * @param theShouldPreFetchResourceBody         Should we also fetch the actual resource body, or just figure out the PID associated with it? See the method javadoc above for some context.
763         * @param theOutputIdsToPreFetchBodiesFor       This will be populated with any resource PIDs that need to be pre-fetched
764         * @param theOutputSearchParameterMapsToResolve This will be populated with any {@link SearchParameterMap} instances corresponding to match URLs we need to resolve
765         */
766        private void processConditionalUrlForPreFetching(
767                        RequestPartitionId thePartitionId,
768                        String theResourceType,
769                        String theRequestUrl,
770                        boolean theShouldPreFetchResourceBody,
771                        boolean theShouldPreFetchResourceVersion,
772                        Set<JpaPid> theOutputIdsToPreFetchBodiesFor,
773                        List<MatchUrlToResolve> theOutputSearchParameterMapsToResolve) {
774                JpaPid cachedId =
775                                myMatchResourceUrlService.processMatchUrlUsingCacheOnly(theResourceType, theRequestUrl, thePartitionId);
776                if (cachedId != null) {
777                        if (theShouldPreFetchResourceBody) {
778                                theOutputIdsToPreFetchBodiesFor.add(cachedId);
779                        }
780                } else if (MATCH_URL_PATTERN.matcher(theRequestUrl).find()) {
781                        RuntimeResourceDefinition resourceDefinition = myFhirContext.getResourceDefinition(theResourceType);
782                        SearchParameterMap matchUrlSearchMap =
783                                        myMatchUrlService.translateMatchUrl(theRequestUrl, resourceDefinition);
784                        theOutputSearchParameterMapsToResolve.add(new MatchUrlToResolve(
785                                        theRequestUrl,
786                                        matchUrlSearchMap,
787                                        resourceDefinition,
788                                        theShouldPreFetchResourceBody,
789                                        theShouldPreFetchResourceVersion));
790                }
791        }
792
793        /**
794         * Given a token parameter, build the query predicate based on its hash. Uses system and value if both are available, otherwise just value.
795         * If neither are available, it returns null.
796         *
797         * @return Returns {@literal true} if the param was added to one of the output lists
798         */
799        private boolean buildHashPredicateFromTokenParam(
800                        TokenParam theTokenParam,
801                        RequestPartitionId theRequestPartitionId,
802                        MatchUrlToResolve theMatchUrl,
803                        Set<Long> theOutputSysAndValuePredicates,
804                        Set<Long> theOutputValuePredicates) {
805                if (isNotBlank(theTokenParam.getValue()) && isNotBlank(theTokenParam.getSystem())) {
806                        theMatchUrl.myHashSystemAndValue = ResourceIndexedSearchParamToken.calculateHashSystemAndValue(
807                                        myPartitionSettings,
808                                        theRequestPartitionId,
809                                        theMatchUrl.myResourceDefinition.getName(),
810                                        theMatchUrl.myMatchUrlSearchMap.keySet().iterator().next(),
811                                        theTokenParam.getSystem(),
812                                        theTokenParam.getValue());
813                        theOutputSysAndValuePredicates.add(theMatchUrl.myHashSystemAndValue);
814                        return true;
815                } else if (isNotBlank(theTokenParam.getValue())) {
816                        theMatchUrl.myHashValue = ResourceIndexedSearchParamToken.calculateHashValue(
817                                        myPartitionSettings,
818                                        theRequestPartitionId,
819                                        theMatchUrl.myResourceDefinition.getName(),
820                                        theMatchUrl.myMatchUrlSearchMap.keySet().iterator().next(),
821                                        theTokenParam.getValue());
822                        theOutputValuePredicates.add(theMatchUrl.myHashValue);
823                        return true;
824                }
825
826                return false;
827        }
828
829        private ListMultimap<Long, MatchUrlToResolve> buildHashToSearchMap(
830                        List<MatchUrlToResolve> searchParameterMapsToResolve, String theIndex) {
831                ListMultimap<Long, MatchUrlToResolve> hashToSearch = ArrayListMultimap.create();
832                // Build a lookup map so we don't have to iterate over the searches repeatedly.
833                for (MatchUrlToResolve nextSearchParameterMap : searchParameterMapsToResolve) {
834                        if (nextSearchParameterMap.myHashSystemAndValue != null && theIndex.equals("myHashSystemAndValue")) {
835                                hashToSearch.put(nextSearchParameterMap.myHashSystemAndValue, nextSearchParameterMap);
836                        }
837                        if (nextSearchParameterMap.myHashValue != null && theIndex.equals("myHashValue")) {
838                                hashToSearch.put(nextSearchParameterMap.myHashValue, nextSearchParameterMap);
839                        }
840                }
841                return hashToSearch;
842        }
843
844        @Override
845        protected void flushSession(Map<IIdType, DaoMethodOutcome> theIdToPersistedOutcome) {
846                try {
847                        int insertionCount;
848                        int updateCount;
849                        SessionImpl session = myEntityManager.unwrap(SessionImpl.class);
850                        if (session != null) {
851                                insertionCount = session.getActionQueue().numberOfInsertions();
852                                updateCount = session.getActionQueue().numberOfUpdates();
853                        } else {
854                                insertionCount = -1;
855                                updateCount = -1;
856                        }
857
858                        StopWatch sw = new StopWatch();
859                        myEntityManager.flush();
860                        ourLog.debug(
861                                        "Session flush took {}ms for {} inserts and {} updates",
862                                        sw.getMillis(),
863                                        insertionCount,
864                                        updateCount);
865                } catch (PersistenceException e) {
866                        if (myHapiFhirHibernateJpaDialect != null) {
867                                String transactionTypes = createDescriptionOfResourceTypesInBundle(theIdToPersistedOutcome);
868                                String message = "Error flushing transaction with resource types: " + transactionTypes;
869                                throw myHapiFhirHibernateJpaDialect.translate(e, message);
870                        }
871                        throw e;
872                }
873        }
874
875        @VisibleForTesting
876        public void setIdHelperServiceForUnitTest(IIdHelperService<JpaPid> theIdHelperService) {
877                myIdHelperService = theIdHelperService;
878        }
879
880        @VisibleForTesting
881        public void setApplicationContextForUnitTest(ApplicationContext theAppCtx) {
882                myApplicationContext = theAppCtx;
883        }
884
885        /**
886         * Creates a description of resource types in the provided bundle, indicating the types of resources
887         * and their counts within the input map. This is intended only to be helpful for troubleshooting, since
888         * it can be helpful to see details about the transaction which failed in the logs.
889         * <p>
890         * Example output: <code>[Patient (x3), Observation (x14)]</code>
891         * </p>
892         *
893         * @param theIdToPersistedOutcome A map where the key is an {@code IIdType} object representing a resource ID
894         *                                and the value is a {@code DaoMethodOutcome} object representing the outcome
895         *                                of the persistence operation for that resource.
896         * @return A string describing the resource types and their respective counts in a formatted list.
897         */
898        @Nonnull
899        private static String createDescriptionOfResourceTypesInBundle(
900                        Map<IIdType, DaoMethodOutcome> theIdToPersistedOutcome) {
901                TreeMap<String, Integer> types = new TreeMap<>();
902                for (IIdType t : theIdToPersistedOutcome.keySet()) {
903                        if (t != null) {
904                                String resourceType = t.getResourceType();
905                                int count = types.getOrDefault(resourceType, 0);
906                                types.put(resourceType, count + 1);
907                        }
908                }
909
910                StringBuilder typesBuilder = new StringBuilder();
911                typesBuilder.append("[");
912                for (Iterator<Map.Entry<String, Integer>> iter = types.entrySet().iterator(); iter.hasNext(); ) {
913                        Map.Entry<String, Integer> entry = iter.next();
914                        typesBuilder.append(entry.getKey());
915                        if (entry.getValue() > 1) {
916                                typesBuilder.append(" (x").append(entry.getValue()).append(")");
917                        }
918                        if (iter.hasNext()) {
919                                typesBuilder.append(", ");
920                        }
921                }
922                typesBuilder.append("]");
923                return typesBuilder.toString();
924        }
925
926        public static class MatchUrlToResolve {
927
928                private final String myRequestUrl;
929                private final SearchParameterMap myMatchUrlSearchMap;
930                private final RuntimeResourceDefinition myResourceDefinition;
931                private final boolean myShouldPreFetchResourceBody;
932                private final boolean myShouldPreFetchResourceVersion;
933                public boolean myResolved;
934                private Long myHashValue;
935                private Long myHashSystemAndValue;
936
937                public MatchUrlToResolve(
938                                String theRequestUrl,
939                                SearchParameterMap theMatchUrlSearchMap,
940                                RuntimeResourceDefinition theResourceDefinition,
941                                boolean theShouldPreFetchResourceBody,
942                                boolean theShouldPreFetchResourceVersion) {
943                        myRequestUrl = theRequestUrl;
944                        myMatchUrlSearchMap = theMatchUrlSearchMap;
945                        myResourceDefinition = theResourceDefinition;
946                        myShouldPreFetchResourceBody = theShouldPreFetchResourceBody;
947                        myShouldPreFetchResourceVersion = theShouldPreFetchResourceVersion;
948                }
949
950                public void setResolved(boolean theResolved) {
951                        myResolved = theResolved;
952                }
953        }
954}