
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}