001/*-
002 * #%L
003 * HAPI FHIR JPA Server
004 * %%
005 * Copyright (C) 2014 - 2024 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.StorageSettings;
035import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc;
036import ca.uhn.fhir.jpa.searchparam.MatchUrlService;
037import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
038import ca.uhn.fhir.model.api.IQueryParameterType;
039import ca.uhn.fhir.rest.api.server.RequestDetails;
040import ca.uhn.fhir.rest.api.server.storage.TransactionDetails;
041import ca.uhn.fhir.rest.param.TokenParam;
042import ca.uhn.fhir.util.FhirTerser;
043import ca.uhn.fhir.util.ResourceReferenceInfo;
044import ca.uhn.fhir.util.StopWatch;
045import ca.uhn.fhir.util.TaskChunker;
046import com.google.common.annotations.VisibleForTesting;
047import com.google.common.collect.ArrayListMultimap;
048import com.google.common.collect.ListMultimap;
049import jakarta.annotation.Nullable;
050import jakarta.persistence.EntityManager;
051import jakarta.persistence.FlushModeType;
052import jakarta.persistence.PersistenceContext;
053import jakarta.persistence.PersistenceContextType;
054import jakarta.persistence.PersistenceException;
055import jakarta.persistence.Tuple;
056import jakarta.persistence.TypedQuery;
057import jakarta.persistence.criteria.CriteriaBuilder;
058import jakarta.persistence.criteria.CriteriaQuery;
059import jakarta.persistence.criteria.Predicate;
060import jakarta.persistence.criteria.Root;
061import org.apache.commons.lang3.Validate;
062import org.hibernate.internal.SessionImpl;
063import org.hl7.fhir.instance.model.api.IBase;
064import org.hl7.fhir.instance.model.api.IBaseBundle;
065import org.hl7.fhir.instance.model.api.IBaseResource;
066import org.hl7.fhir.instance.model.api.IIdType;
067import org.slf4j.Logger;
068import org.slf4j.LoggerFactory;
069import org.springframework.beans.factory.annotation.Autowired;
070import org.springframework.context.ApplicationContext;
071
072import java.util.ArrayList;
073import java.util.Collection;
074import java.util.HashMap;
075import java.util.HashSet;
076import java.util.IdentityHashMap;
077import java.util.List;
078import java.util.Map;
079import java.util.Set;
080import java.util.regex.Pattern;
081import java.util.stream.Collectors;
082
083import static ca.uhn.fhir.util.UrlUtil.determineResourceTypeInResourceUrl;
084import static org.apache.commons.lang3.StringUtils.countMatches;
085import static org.apache.commons.lang3.StringUtils.isNotBlank;
086
087public class TransactionProcessor extends BaseTransactionProcessor {
088
089        public static final Pattern SINGLE_PARAMETER_MATCH_URL_PATTERN = Pattern.compile("^[^?]+[?][a-z0-9-]+=[^&,]+$");
090        private static final Logger ourLog = LoggerFactory.getLogger(TransactionProcessor.class);
091        public static final int CONDITIONAL_URL_FETCH_CHUNK_SIZE = 100;
092
093        @Autowired
094        private ApplicationContext myApplicationContext;
095
096        @PersistenceContext(type = PersistenceContextType.TRANSACTION)
097        private EntityManager myEntityManager;
098
099        @Autowired(required = false)
100        private HapiFhirHibernateJpaDialect myHapiFhirHibernateJpaDialect;
101
102        @Autowired
103        private IIdHelperService<JpaPid> myIdHelperService;
104
105        @Autowired
106        private JpaStorageSettings myStorageSettings;
107
108        @Autowired
109        private FhirContext myFhirContext;
110
111        @Autowired
112        private MatchResourceUrlService<JpaPid> myMatchResourceUrlService;
113
114        @Autowired
115        private MatchUrlService myMatchUrlService;
116
117        @Autowired
118        private IRequestPartitionHelperSvc myRequestPartitionSvc;
119
120        public void setEntityManagerForUnitTest(EntityManager theEntityManager) {
121                myEntityManager = theEntityManager;
122        }
123
124        @Override
125        protected void validateDependencies() {
126                super.validateDependencies();
127
128                Validate.notNull(myEntityManager);
129        }
130
131        @VisibleForTesting
132        public void setFhirContextForUnitTest(FhirContext theFhirContext) {
133                myFhirContext = theFhirContext;
134        }
135
136        @Override
137        public void setStorageSettings(StorageSettings theStorageSettings) {
138                myStorageSettings = (JpaStorageSettings) theStorageSettings;
139                super.setStorageSettings(theStorageSettings);
140        }
141
142        @Override
143        protected EntriesToProcessMap doTransactionWriteOperations(
144                        final RequestDetails theRequest,
145                        String theActionName,
146                        TransactionDetails theTransactionDetails,
147                        Set<IIdType> theAllIds,
148                        IdSubstitutionMap theIdSubstitutions,
149                        Map<IIdType, DaoMethodOutcome> theIdToPersistedOutcome,
150                        IBaseBundle theResponse,
151                        IdentityHashMap<IBase, Integer> theOriginalRequestOrder,
152                        List<IBase> theEntries,
153                        StopWatch theTransactionStopWatch) {
154
155                /*
156                 * We temporarily set the flush mode for the duration of the DB transaction
157                 * from the default of AUTO to the temporary value of COMMIT here. We do this
158                 * because in AUTO mode, if any SQL SELECTs are required during the
159                 * processing of an individual transaction entry, the server will flush the
160                 * pending INSERTs/UPDATEs to the database before executing the SELECT.
161                 * This hurts performance since we don't get the benefit of batching those
162                 * write operations as much as possible. The tradeoff here is that we
163                 * could theoretically have transaction operations which try to read
164                 * data previously written in the same transaction, and they won't see it.
165                 * This shouldn't actually be an issue anyhow - we pre-fetch conditional
166                 * URLs and reference targets at the start of the transaction. But this
167                 * tradeoff still feels worth it, since the most common use of transactions
168                 * is for fast writing of data.
169                 *
170                 * Note that it's probably not necessary to reset it back, it should
171                 * automatically go back to the default value after the transaction but
172                 * we reset it just to be safe.
173                 */
174                FlushModeType initialFlushMode = myEntityManager.getFlushMode();
175                try {
176                        myEntityManager.setFlushMode(FlushModeType.COMMIT);
177
178                        ITransactionProcessorVersionAdapter<?, ?> versionAdapter = getVersionAdapter();
179                        RequestPartitionId requestPartitionId =
180                                        super.determineRequestPartitionIdForWriteEntries(theRequest, theEntries);
181
182                        if (requestPartitionId != null) {
183                                preFetch(theTransactionDetails, theEntries, versionAdapter, requestPartitionId);
184                        }
185
186                        return super.doTransactionWriteOperations(
187                                        theRequest,
188                                        theActionName,
189                                        theTransactionDetails,
190                                        theAllIds,
191                                        theIdSubstitutions,
192                                        theIdToPersistedOutcome,
193                                        theResponse,
194                                        theOriginalRequestOrder,
195                                        theEntries,
196                                        theTransactionStopWatch);
197                } finally {
198                        myEntityManager.setFlushMode(initialFlushMode);
199                }
200        }
201
202        private void preFetch(
203                        TransactionDetails theTransactionDetails,
204                        List<IBase> theEntries,
205                        ITransactionProcessorVersionAdapter theVersionAdapter,
206                        RequestPartitionId theRequestPartitionId) {
207                Set<String> foundIds = new HashSet<>();
208                List<Long> idsToPreFetch = new ArrayList<>();
209
210                /*
211                 * Pre-Fetch any resources that are referred to normally by ID, e.g.
212                 * regular FHIR updates within the transaction.
213                 */
214                preFetchResourcesById(
215                                theTransactionDetails, theEntries, theVersionAdapter, theRequestPartitionId, foundIds, idsToPreFetch);
216
217                /*
218                 * Pre-resolve any conditional URLs we can
219                 */
220                preFetchConditionalUrls(
221                                theTransactionDetails, theEntries, theVersionAdapter, theRequestPartitionId, idsToPreFetch);
222
223                IFhirSystemDao<?, ?> systemDao = myApplicationContext.getBean(IFhirSystemDao.class);
224                systemDao.preFetchResources(JpaPid.fromLongList(idsToPreFetch), true);
225        }
226
227        private void preFetchResourcesById(
228                        TransactionDetails theTransactionDetails,
229                        List<IBase> theEntries,
230                        ITransactionProcessorVersionAdapter theVersionAdapter,
231                        RequestPartitionId theRequestPartitionId,
232                        Set<String> foundIds,
233                        List<Long> idsToPreFetch) {
234
235                FhirTerser terser = myFhirContext.newTerser();
236
237                // Key: The ID of the resource
238                // Value: TRUE if we should prefetch the existing resource details and all stored indexes,
239                //        FALSE if we should prefetch only the identity (resource ID and deleted status)
240                Map<IIdType, Boolean> idsToPreResolve = new HashMap<>(theEntries.size() * 3);
241
242                for (IBase nextEntry : theEntries) {
243                        IBaseResource resource = theVersionAdapter.getResource(nextEntry);
244                        if (resource != null) {
245                                String verb = theVersionAdapter.getEntryRequestVerb(myFhirContext, nextEntry);
246
247                                /*
248                                 * Pre-fetch any resources that are potentially being directly updated by ID
249                                 */
250                                if ("PUT".equals(verb) || "PATCH".equals(verb)) {
251                                        String requestUrl = theVersionAdapter.getEntryRequestUrl(nextEntry);
252                                        if (countMatches(requestUrl, '?') == 0) {
253                                                IIdType id = myFhirContext.getVersion().newIdType();
254                                                id.setValue(requestUrl);
255                                                IIdType unqualifiedVersionless = id.toUnqualifiedVersionless();
256                                                idsToPreResolve.put(unqualifiedVersionless, Boolean.TRUE);
257                                        }
258                                }
259
260                                /*
261                                 * Pre-fetch any resources that are referred to directly by ID (don't replace
262                                 * the TRUE flag with FALSE in case we're updating a resource but also
263                                 * pointing to that resource elsewhere in the bundle)
264                                 */
265                                if ("PUT".equals(verb) || "POST".equals(verb)) {
266                                        for (ResourceReferenceInfo referenceInfo : terser.getAllResourceReferences(resource)) {
267                                                IIdType reference = referenceInfo.getResourceReference().getReferenceElement();
268                                                if (reference != null
269                                                                && !reference.isLocal()
270                                                                && !reference.isUuid()
271                                                                && reference.hasResourceType()
272                                                                && reference.hasIdPart()
273                                                                && !reference.getValue().contains("?")) {
274                                                        idsToPreResolve.putIfAbsent(reference.toUnqualifiedVersionless(), Boolean.FALSE);
275                                                }
276                                        }
277                                }
278                        }
279                }
280
281                /*
282                 * If all the entries in the pre-fetch ID map have a value of TRUE, this
283                 * means we only have IDs associated with resources we're going to directly
284                 * update/patch within the transaction. In that case, it's fine to include
285                 * deleted resources, since updating them will bring them back to life.
286                 *
287                 * If we have any FALSE entries, we're also pre-fetching reference targets
288                 * which means we don't want deleted resources, because those are not OK
289                 * to reference.
290                 */
291                boolean preFetchIncludesReferences = idsToPreResolve.values().stream().anyMatch(t -> !t);
292                ResolveIdentityMode resolveMode = preFetchIncludesReferences
293                                ? ResolveIdentityMode.excludeDeleted().noCacheUnlessDeletesDisabled()
294                                : ResolveIdentityMode.includeDeleted().cacheOk();
295
296                Map<IIdType, IResourceLookup<JpaPid>> outcomes = myIdHelperService.resolveResourceIdentities(
297                                theRequestPartitionId, idsToPreResolve.keySet(), resolveMode);
298                for (Map.Entry<IIdType, IResourceLookup<JpaPid>> entry : outcomes.entrySet()) {
299                        JpaPid next = (JpaPid) entry.getValue().getPersistentId();
300                        IIdType unqualifiedVersionlessId = entry.getKey();
301                        foundIds.add(unqualifiedVersionlessId.getValue());
302                        theTransactionDetails.addResolvedResourceId(unqualifiedVersionlessId, next);
303                        if (idsToPreResolve.get(unqualifiedVersionlessId) == Boolean.TRUE) {
304                                if (myStorageSettings.getResourceClientIdStrategy() != JpaStorageSettings.ClientIdStrategyEnum.ANY
305                                                || (next.getAssociatedResourceId() != null
306                                                                && !next.getAssociatedResourceId().isIdPartValidLong())) {
307                                        idsToPreFetch.add(next.getId());
308                                }
309                        }
310                }
311
312                // Any IDs that could not be resolved are presumably not there, so
313                // cache that fact so we don't look again later
314                for (IIdType next : idsToPreResolve.keySet()) {
315                        if (!foundIds.contains(next.getValue())) {
316                                theTransactionDetails.addResolvedResourceId(next.toUnqualifiedVersionless(), null);
317                        }
318                }
319        }
320
321        @Override
322        protected void handleVerbChangeInTransactionWriteOperations() {
323                super.handleVerbChangeInTransactionWriteOperations();
324
325                myEntityManager.flush();
326        }
327
328        private void preFetchConditionalUrls(
329                        TransactionDetails theTransactionDetails,
330                        List<IBase> theEntries,
331                        ITransactionProcessorVersionAdapter theVersionAdapter,
332                        RequestPartitionId theRequestPartitionId,
333                        List<Long> idsToPreFetch) {
334                List<MatchUrlToResolve> searchParameterMapsToResolve = new ArrayList<>();
335                for (IBase nextEntry : theEntries) {
336                        IBaseResource resource = theVersionAdapter.getResource(nextEntry);
337                        if (resource != null) {
338                                String verb = theVersionAdapter.getEntryRequestVerb(myFhirContext, nextEntry);
339                                String requestUrl = theVersionAdapter.getEntryRequestUrl(nextEntry);
340                                String requestIfNoneExist = theVersionAdapter.getEntryIfNoneExist(nextEntry);
341                                String resourceType = determineResourceTypeInResourceUrl(myFhirContext, requestUrl);
342                                if (resourceType == null && resource != null) {
343                                        resourceType = myFhirContext.getResourceType(resource);
344                                }
345                                if (("PUT".equals(verb) || "PATCH".equals(verb)) && requestUrl != null && requestUrl.contains("?")) {
346                                        preFetchConditionalUrl(resourceType, requestUrl, true, idsToPreFetch, searchParameterMapsToResolve);
347                                } else if ("POST".equals(verb) && requestIfNoneExist != null && requestIfNoneExist.contains("?")) {
348                                        preFetchConditionalUrl(
349                                                        resourceType, requestIfNoneExist, false, idsToPreFetch, searchParameterMapsToResolve);
350                                }
351
352                                if (myStorageSettings.isAllowInlineMatchUrlReferences()) {
353                                        List<ResourceReferenceInfo> references =
354                                                        myFhirContext.newTerser().getAllResourceReferences(resource);
355                                        for (ResourceReferenceInfo next : references) {
356                                                String referenceUrl = next.getResourceReference()
357                                                                .getReferenceElement()
358                                                                .getValue();
359                                                String refResourceType = determineResourceTypeInResourceUrl(myFhirContext, referenceUrl);
360                                                if (refResourceType != null) {
361                                                        preFetchConditionalUrl(
362                                                                        refResourceType, referenceUrl, false, idsToPreFetch, searchParameterMapsToResolve);
363                                                }
364                                        }
365                                }
366                        }
367                }
368
369                TaskChunker.chunk(
370                                searchParameterMapsToResolve,
371                                CONDITIONAL_URL_FETCH_CHUNK_SIZE,
372                                map -> preFetchSearchParameterMaps(theTransactionDetails, theRequestPartitionId, map, idsToPreFetch));
373        }
374
375        /**
376         * @param theTransactionDetails    The active transaction details
377         * @param theRequestPartitionId    The active partition
378         * @param theInputParameters       These are the search parameter maps that will actually be resolved
379         * @param theOutputPidsToLoadFully This list will be added to with any resource PIDs that need to be fully
380         *                                 pre-loaded (ie. fetch the actual resource body since we're presumably
381         *                                 going to update it and will need to see its current state eventually)
382         */
383        private void preFetchSearchParameterMaps(
384                        TransactionDetails theTransactionDetails,
385                        RequestPartitionId theRequestPartitionId,
386                        List<MatchUrlToResolve> theInputParameters,
387                        List<Long> theOutputPidsToLoadFully) {
388                Set<Long> systemAndValueHashes = new HashSet<>();
389                Set<Long> valueHashes = new HashSet<>();
390                for (MatchUrlToResolve next : theInputParameters) {
391                        Collection<List<List<IQueryParameterType>>> values = next.myMatchUrlSearchMap.values();
392                        if (values.size() == 1) {
393                                List<List<IQueryParameterType>> andList = values.iterator().next();
394                                IQueryParameterType param = andList.get(0).get(0);
395
396                                if (param instanceof TokenParam) {
397                                        buildHashPredicateFromTokenParam(
398                                                        (TokenParam) param, theRequestPartitionId, next, systemAndValueHashes, valueHashes);
399                                }
400                        }
401                }
402
403                preFetchSearchParameterMapsToken(
404                                "myHashSystemAndValue",
405                                systemAndValueHashes,
406                                theTransactionDetails,
407                                theRequestPartitionId,
408                                theInputParameters,
409                                theOutputPidsToLoadFully);
410                preFetchSearchParameterMapsToken(
411                                "myHashValue",
412                                valueHashes,
413                                theTransactionDetails,
414                                theRequestPartitionId,
415                                theInputParameters,
416                                theOutputPidsToLoadFully);
417
418                // For each SP Map which did not return a result, tag it as not found.
419                if (!valueHashes.isEmpty() || !systemAndValueHashes.isEmpty()) {
420                        theInputParameters.stream()
421                                        // No matches
422                                        .filter(match -> !match.myResolved)
423                                        .forEach(match -> {
424                                                ourLog.debug("Was unable to match url {} from database", match.myRequestUrl);
425                                                theTransactionDetails.addResolvedMatchUrl(
426                                                                myFhirContext, match.myRequestUrl, TransactionDetails.NOT_FOUND);
427                                        });
428                }
429        }
430
431        /**
432         * Here we do a select against the {@link ResourceIndexedSearchParamToken} table for any rows that have the
433         * specific sys+val or val hashes we know we need to pre-fetch.
434         * <p>
435         * Note that we do a tuple query for only 2 columns in order to ensure that we can get by with only
436         * the data in the index (ie no need to load the actual table rows).
437         */
438        private void preFetchSearchParameterMapsToken(
439                        String theIndexColumnName,
440                        Set<Long> theHashesForIndexColumn,
441                        TransactionDetails theTransactionDetails,
442                        RequestPartitionId theRequestPartitionId,
443                        List<MatchUrlToResolve> theInputParameters,
444                        List<Long> theOutputPidsToLoadFully) {
445                if (!theHashesForIndexColumn.isEmpty()) {
446                        ListMultimap<Long, MatchUrlToResolve> hashToSearchMap =
447                                        buildHashToSearchMap(theInputParameters, theIndexColumnName);
448                        CriteriaBuilder cb = myEntityManager.getCriteriaBuilder();
449                        CriteriaQuery<Tuple> cq = cb.createTupleQuery();
450                        Root<ResourceIndexedSearchParamToken> from = cq.from(ResourceIndexedSearchParamToken.class);
451                        cq.multiselect(from.get("myResourcePid"), from.get(theIndexColumnName));
452
453                        Predicate masterPredicate;
454                        if (theHashesForIndexColumn.size() == 1) {
455                                masterPredicate = cb.equal(
456                                                from.get(theIndexColumnName),
457                                                theHashesForIndexColumn.iterator().next());
458                        } else {
459                                masterPredicate = from.get(theIndexColumnName).in(theHashesForIndexColumn);
460                        }
461
462                        if (myPartitionSettings.isPartitioningEnabled()
463                                        && !myPartitionSettings.isIncludePartitionInSearchHashes()) {
464                                if (theRequestPartitionId.isDefaultPartition()) {
465                                        Predicate partitionIdCriteria = cb.isNull(from.get("myPartitionIdValue"));
466                                        masterPredicate = cb.and(partitionIdCriteria, masterPredicate);
467                                } else if (!theRequestPartitionId.isAllPartitions()) {
468                                        Predicate partitionIdCriteria =
469                                                        from.get("myPartitionIdValue").in(theRequestPartitionId.getPartitionIds());
470                                        masterPredicate = cb.and(partitionIdCriteria, masterPredicate);
471                                }
472                        }
473
474                        cq.where(masterPredicate);
475
476                        TypedQuery<Tuple> query = myEntityManager.createQuery(cq);
477
478                        /*
479                         * If we have 10 unique conditional URLs we're resolving, each one should
480                         * resolve to 0..1 resources if they are valid as conditional URLs. So we would
481                         * expect this query to return 0..10 rows, since conditional URLs for all
482                         * conditional operations except DELETE (which isn't being applied here) are
483                         * only allowed to resolve to 0..1 resources.
484                         *
485                         * If a conditional URL matches 2+ resources that is an error, and we'll
486                         * be throwing an exception below. This limit is here for safety just to
487                         * ensure that if someone uses a conditional URL that matches a million resources,
488                         * we don't do a super-expensive fetch.
489                         */
490                        query.setMaxResults(theHashesForIndexColumn.size() + 1);
491
492                        List<Tuple> results = query.getResultList();
493
494                        for (Tuple nextResult : results) {
495                                Long nextResourcePid = nextResult.get(0, Long.class);
496                                Long nextHash = nextResult.get(1, Long.class);
497                                List<MatchUrlToResolve> matchedSearch = hashToSearchMap.get(nextHash);
498                                matchedSearch.forEach(matchUrl -> {
499                                        ourLog.debug("Matched url {} from database", matchUrl.myRequestUrl);
500                                        if (matchUrl.myShouldPreFetchResourceBody) {
501                                                theOutputPidsToLoadFully.add(nextResourcePid);
502                                        }
503                                        myMatchResourceUrlService.matchUrlResolved(
504                                                        theTransactionDetails,
505                                                        matchUrl.myResourceDefinition.getName(),
506                                                        matchUrl.myRequestUrl,
507                                                        JpaPid.fromId(nextResourcePid));
508                                        theTransactionDetails.addResolvedMatchUrl(
509                                                        myFhirContext, matchUrl.myRequestUrl, JpaPid.fromId(nextResourcePid));
510                                        matchUrl.setResolved(true);
511                                });
512                        }
513                }
514        }
515
516        /**
517         * Note that if {@literal theShouldPreFetchResourceBody} is false, then we'll check if a given match
518         * URL resolves to a resource PID, but we won't actually try to load that resource. If we're resolving
519         * a match URL because it's there for a conditional update, we'll eagerly fetch the
520         * actual resource because we need to know its current state in order to update it. However, if
521         * the match URL is from an inline match URL in a resource body, we really only care about
522         * the PID and don't need the body so we don't load it. This does have a security implication, since
523         * it means that the {@link ca.uhn.fhir.interceptor.api.Pointcut#STORAGE_PRESHOW_RESOURCES} pointcut
524         * isn't fired even though the user has resolved the URL (meaning they may be able to test for
525         * the existence of a resource using a match URL). There is a test for this called
526         * {@literal testTransactionCreateInlineMatchUrlWithAuthorizationDenied()}. This security tradeoff
527         * is acceptable since we're only prefetching things with very simple match URLs (nothing with
528         * a reference in it for example) so it's not really possible to doing anything useful with this.
529         *
530         * @param theResourceType                       The resource type associated with the match URL (ie what resource type should it resolve to)
531         * @param theRequestUrl                         The actual match URL, which could be as simple as just parameters or could include the resource type too
532         * @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.
533         * @param theOutputIdsToPreFetch                This will be populated with any resource PIDs that need to be pre-fetched
534         * @param theOutputSearchParameterMapsToResolve This will be populated with any {@link SearchParameterMap} instances corresponding to match URLs we need to resolve
535         */
536        private void preFetchConditionalUrl(
537                        String theResourceType,
538                        String theRequestUrl,
539                        boolean theShouldPreFetchResourceBody,
540                        List<Long> theOutputIdsToPreFetch,
541                        List<MatchUrlToResolve> theOutputSearchParameterMapsToResolve) {
542                JpaPid cachedId = myMatchResourceUrlService.processMatchUrlUsingCacheOnly(theResourceType, theRequestUrl);
543                if (cachedId != null) {
544                        if (theShouldPreFetchResourceBody) {
545                                theOutputIdsToPreFetch.add(cachedId.getId());
546                        }
547                } else if (SINGLE_PARAMETER_MATCH_URL_PATTERN.matcher(theRequestUrl).matches()) {
548                        RuntimeResourceDefinition resourceDefinition = myFhirContext.getResourceDefinition(theResourceType);
549                        SearchParameterMap matchUrlSearchMap =
550                                        myMatchUrlService.translateMatchUrl(theRequestUrl, resourceDefinition);
551                        theOutputSearchParameterMapsToResolve.add(new MatchUrlToResolve(
552                                        theRequestUrl, matchUrlSearchMap, resourceDefinition, theShouldPreFetchResourceBody));
553                }
554        }
555
556        /**
557         * Given a token parameter, build the query predicate based on its hash. Uses system and value if both are available, otherwise just value.
558         * If neither are available, it returns null.
559         */
560        @Nullable
561        private void buildHashPredicateFromTokenParam(
562                        TokenParam theTokenParam,
563                        RequestPartitionId theRequestPartitionId,
564                        MatchUrlToResolve theMatchUrl,
565                        Set<Long> theSysAndValuePredicates,
566                        Set<Long> theValuePredicates) {
567                if (isNotBlank(theTokenParam.getValue()) && isNotBlank(theTokenParam.getSystem())) {
568                        theMatchUrl.myHashSystemAndValue = ResourceIndexedSearchParamToken.calculateHashSystemAndValue(
569                                        myPartitionSettings,
570                                        theRequestPartitionId,
571                                        theMatchUrl.myResourceDefinition.getName(),
572                                        theMatchUrl.myMatchUrlSearchMap.keySet().iterator().next(),
573                                        theTokenParam.getSystem(),
574                                        theTokenParam.getValue());
575                        theSysAndValuePredicates.add(theMatchUrl.myHashSystemAndValue);
576                } else if (isNotBlank(theTokenParam.getValue())) {
577                        theMatchUrl.myHashValue = ResourceIndexedSearchParamToken.calculateHashValue(
578                                        myPartitionSettings,
579                                        theRequestPartitionId,
580                                        theMatchUrl.myResourceDefinition.getName(),
581                                        theMatchUrl.myMatchUrlSearchMap.keySet().iterator().next(),
582                                        theTokenParam.getValue());
583                        theValuePredicates.add(theMatchUrl.myHashValue);
584                }
585        }
586
587        private ListMultimap<Long, MatchUrlToResolve> buildHashToSearchMap(
588                        List<MatchUrlToResolve> searchParameterMapsToResolve, String theIndex) {
589                ListMultimap<Long, MatchUrlToResolve> hashToSearch = ArrayListMultimap.create();
590                // Build a lookup map so we don't have to iterate over the searches repeatedly.
591                for (MatchUrlToResolve nextSearchParameterMap : searchParameterMapsToResolve) {
592                        if (nextSearchParameterMap.myHashSystemAndValue != null && theIndex.equals("myHashSystemAndValue")) {
593                                hashToSearch.put(nextSearchParameterMap.myHashSystemAndValue, nextSearchParameterMap);
594                        }
595                        if (nextSearchParameterMap.myHashValue != null && theIndex.equals("myHashValue")) {
596                                hashToSearch.put(nextSearchParameterMap.myHashValue, nextSearchParameterMap);
597                        }
598                }
599                return hashToSearch;
600        }
601
602        @Override
603        protected void flushSession(Map<IIdType, DaoMethodOutcome> theIdToPersistedOutcome) {
604                try {
605                        int insertionCount;
606                        int updateCount;
607                        SessionImpl session = myEntityManager.unwrap(SessionImpl.class);
608                        if (session != null) {
609                                insertionCount = session.getActionQueue().numberOfInsertions();
610                                updateCount = session.getActionQueue().numberOfUpdates();
611                        } else {
612                                insertionCount = -1;
613                                updateCount = -1;
614                        }
615
616                        StopWatch sw = new StopWatch();
617                        myEntityManager.flush();
618                        ourLog.debug(
619                                        "Session flush took {}ms for {} inserts and {} updates",
620                                        sw.getMillis(),
621                                        insertionCount,
622                                        updateCount);
623                } catch (PersistenceException e) {
624                        if (myHapiFhirHibernateJpaDialect != null) {
625                                List<String> types = theIdToPersistedOutcome.keySet().stream()
626                                                .filter(t -> t != null)
627                                                .map(t -> t.getResourceType())
628                                                .collect(Collectors.toList());
629                                String message = "Error flushing transaction with resource types: " + types;
630                                throw myHapiFhirHibernateJpaDialect.translate(e, message);
631                        }
632                        throw e;
633                }
634        }
635
636        @VisibleForTesting
637        public void setIdHelperServiceForUnitTest(IIdHelperService theIdHelperService) {
638                myIdHelperService = theIdHelperService;
639        }
640
641        @VisibleForTesting
642        public void setApplicationContextForUnitTest(ApplicationContext theAppCtx) {
643                myApplicationContext = theAppCtx;
644        }
645
646        private static class MatchUrlToResolve {
647
648                private final String myRequestUrl;
649                private final SearchParameterMap myMatchUrlSearchMap;
650                private final RuntimeResourceDefinition myResourceDefinition;
651                private final boolean myShouldPreFetchResourceBody;
652                public boolean myResolved;
653                private Long myHashValue;
654                private Long myHashSystemAndValue;
655
656                public MatchUrlToResolve(
657                                String theRequestUrl,
658                                SearchParameterMap theMatchUrlSearchMap,
659                                RuntimeResourceDefinition theResourceDefinition,
660                                boolean theShouldPreFetchResourceBody) {
661                        myRequestUrl = theRequestUrl;
662                        myMatchUrlSearchMap = theMatchUrlSearchMap;
663                        myResourceDefinition = theResourceDefinition;
664                        myShouldPreFetchResourceBody = theShouldPreFetchResourceBody;
665                }
666
667                public void setResolved(boolean theResolved) {
668                        myResolved = theResolved;
669                }
670        }
671}