
001/*- 002 * #%L 003 * HAPI FHIR Storage api 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.i18n.Msg; 023import ca.uhn.fhir.interceptor.model.ReadPartitionIdRequestDetails; 024import ca.uhn.fhir.interceptor.model.RequestPartitionId; 025import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; 026import ca.uhn.fhir.jpa.api.dao.IJpaDao; 027import ca.uhn.fhir.jpa.api.model.DaoMethodOutcome; 028import ca.uhn.fhir.jpa.api.model.DeleteMethodOutcome; 029import ca.uhn.fhir.jpa.dao.tx.HapiTransactionService; 030import ca.uhn.fhir.jpa.model.cross.IBasePersistedResource; 031import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc; 032import ca.uhn.fhir.jpa.patch.FhirPatch; 033import ca.uhn.fhir.jpa.patch.JsonPatchUtils; 034import ca.uhn.fhir.jpa.patch.XmlPatchUtils; 035import ca.uhn.fhir.jpa.searchparam.MatchUrlService; 036import ca.uhn.fhir.jpa.update.UpdateParameters; 037import ca.uhn.fhir.parser.StrictErrorHandler; 038import ca.uhn.fhir.rest.api.DeleteCascadeModeEnum; 039import ca.uhn.fhir.rest.api.PatchTypeEnum; 040import ca.uhn.fhir.rest.api.RestOperationTypeEnum; 041import ca.uhn.fhir.rest.api.server.RequestDetails; 042import ca.uhn.fhir.rest.api.server.storage.IDeleteExpungeJobSubmitter; 043import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId; 044import ca.uhn.fhir.rest.api.server.storage.TransactionDetails; 045import ca.uhn.fhir.rest.server.RestfulServerUtils; 046import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; 047import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException; 048import ca.uhn.fhir.rest.server.exceptions.PreconditionFailedException; 049import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException; 050import ca.uhn.fhir.rest.server.exceptions.ResourceVersionConflictException; 051import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; 052import jakarta.annotation.Nonnull; 053import org.hl7.fhir.instance.model.api.IBaseParameters; 054import org.hl7.fhir.instance.model.api.IBaseResource; 055import org.hl7.fhir.instance.model.api.IIdType; 056import org.springframework.beans.factory.annotation.Autowired; 057import org.springframework.transaction.support.TransactionSynchronizationManager; 058 059import java.util.Collections; 060import java.util.List; 061import java.util.Set; 062 063import static org.apache.commons.lang3.StringUtils.isNotBlank; 064 065public abstract class BaseStorageResourceDao<T extends IBaseResource> extends BaseStorageDao 066 implements IFhirResourceDao<T>, IJpaDao<T> { 067 public static final StrictErrorHandler STRICT_ERROR_HANDLER = new StrictErrorHandler(); 068 069 @Autowired 070 protected abstract HapiTransactionService getTransactionService(); 071 072 @Autowired 073 protected abstract MatchResourceUrlService getMatchResourceUrlService(); 074 075 @Autowired 076 protected abstract IStorageResourceParser getStorageResourceParser(); 077 078 @Autowired 079 protected abstract IDeleteExpungeJobSubmitter getDeleteExpungeJobSubmitter(); 080 081 @Autowired 082 protected abstract IRequestPartitionHelperSvc getRequestPartitionHelperService(); 083 084 @Autowired 085 protected abstract MatchUrlService getMatchUrlService(); 086 087 @Override 088 public DaoMethodOutcome patch( 089 IIdType theId, 090 String theConditionalUrl, 091 PatchTypeEnum thePatchType, 092 String thePatchBody, 093 IBaseParameters theFhirPatchBody, 094 RequestDetails theRequestDetails) { 095 096 ReadPartitionIdRequestDetails details; 097 if (isNotBlank(theConditionalUrl)) { 098 MatchUrlService.ResourceTypeAndSearchParameterMap parsed = 099 getMatchUrlService().parseAndTranslateMatchUrl(theConditionalUrl); 100 details = ReadPartitionIdRequestDetails.forPatch( 101 parsed.resourceDefinition().getName(), parsed.searchParameterMap()); 102 } else { 103 details = ReadPartitionIdRequestDetails.forPatch(getResourceName(), theId); 104 } 105 RequestPartitionId requestPartitionId = 106 getRequestPartitionHelperService().determineReadPartitionForRequest(theRequestDetails, details); 107 108 TransactionDetails transactionDetails = new TransactionDetails(); 109 return getTransactionService() 110 .withRequest(theRequestDetails) 111 .withTransactionDetails(transactionDetails) 112 .withRequestPartitionId(requestPartitionId) 113 .execute(tx -> patchInTransaction( 114 theId, 115 theConditionalUrl, 116 true, 117 thePatchType, 118 thePatchBody, 119 theFhirPatchBody, 120 theRequestDetails, 121 transactionDetails, 122 requestPartitionId)); 123 } 124 125 @Override 126 public DaoMethodOutcome patchInTransaction( 127 IIdType theId, 128 String theConditionalUrl, 129 boolean thePerformIndexing, 130 PatchTypeEnum thePatchType, 131 String thePatchBody, 132 IBaseParameters theFhirPatchBody, 133 RequestDetails theRequestDetails, 134 TransactionDetails theTransactionDetails, 135 RequestPartitionId theRequestPartitionId) { 136 assert TransactionSynchronizationManager.isActualTransactionActive(); 137 138 boolean isHistoryRewrite = myStorageSettings.isUpdateWithHistoryRewriteEnabled() 139 && theRequestDetails != null 140 && theRequestDetails.isRewriteHistory(); 141 142 if (isHistoryRewrite && !theId.hasVersionIdPart()) { 143 throw new InvalidRequestException( 144 Msg.code(2717) + "Invalid resource ID for rewrite history: ID must contain a history version"); 145 } 146 147 IBasePersistedResource entityToUpdate; 148 IIdType resourceId; 149 if (isNotBlank(theConditionalUrl)) { 150 entityToUpdate = getEntityToPatchWithMatchUrlCache( 151 theConditionalUrl, theRequestDetails, theTransactionDetails, theRequestPartitionId); 152 resourceId = entityToUpdate.getIdDt(); 153 } else { 154 resourceId = theId; 155 156 if (isHistoryRewrite) { 157 entityToUpdate = readEntity(theId, theRequestDetails); 158 } else { 159 entityToUpdate = readEntityLatestVersion(theId, theRequestDetails, theTransactionDetails); 160 validateIsCurrentVersionOrThrow(theId, entityToUpdate); 161 } 162 } 163 164 validateResourceIsNotDeletedOrThrow(entityToUpdate); 165 validateResourceType(entityToUpdate, getResourceName()); 166 167 IBaseResource resourceToUpdate = getStorageResourceParser().toResource(entityToUpdate, false); 168 if (resourceToUpdate == null) { 169 // If this is null, we are presumably in a FHIR transaction bundle with both a create and a patch on the 170 // same 171 // resource. This is weird but not impossible. 172 resourceToUpdate = theTransactionDetails.getResolvedResource(resourceId); 173 } 174 175 IBaseResource destination = 176 applyPatchToResource(thePatchType, thePatchBody, theFhirPatchBody, resourceToUpdate); 177 @SuppressWarnings("unchecked") 178 T destinationCasted = (T) destination; 179 180 preProcessResourceForStorage(destinationCasted, theRequestDetails, theTransactionDetails, true); 181 182 if (isHistoryRewrite) { 183 return getTransactionService() 184 .withRequest(theRequestDetails) 185 .withTransactionDetails(theTransactionDetails) 186 .execute(tx -> doUpdateWithHistoryRewrite( 187 destinationCasted, 188 theRequestDetails, 189 theTransactionDetails, 190 theRequestPartitionId, 191 RestOperationTypeEnum.PATCH)); 192 } 193 194 UpdateParameters updateParameters = new UpdateParameters<>() 195 .setRequestDetails(theRequestDetails) 196 .setResourceIdToUpdate(resourceId) 197 .setMatchUrl(theConditionalUrl) 198 .setShouldPerformIndexing(thePerformIndexing) 199 .setShouldForceUpdateVersion(false) 200 .setResource(destinationCasted) 201 .setEntity(entityToUpdate) 202 .setOperationType(RestOperationTypeEnum.PATCH) 203 .setTransactionDetails(theTransactionDetails) 204 .setShouldForcePopulateOldResourceForProcessing(false); 205 206 return doUpdateForUpdateOrPatch(updateParameters); 207 } 208 209 private static void validateIsCurrentVersionOrThrow(IIdType theId, IBasePersistedResource theEntityToUpdate) { 210 if (theId.hasVersionIdPart() && theId.getVersionIdPartAsLong() != theEntityToUpdate.getVersion()) { 211 throw new ResourceVersionConflictException(Msg.code(974) + "Version " + theId.getVersionIdPart() 212 + " is not the most recent version of this resource, unable to apply patch"); 213 } 214 } 215 216 DaoMethodOutcome doUpdateWithHistoryRewrite( 217 T theResource, 218 RequestDetails theRequest, 219 TransactionDetails theTransactionDetails, 220 RequestPartitionId theRequestPartitionId, 221 RestOperationTypeEnum theRestOperationType) { 222 223 throw new UnsupportedOperationException(Msg.code(2718) + "Patch with history rewrite is unsupported."); 224 } 225 226 private void validateResourceIsNotDeletedOrThrow(IBasePersistedResource theEntityToUpdate) { 227 if (theEntityToUpdate.isDeleted()) { 228 throw createResourceGoneException(theEntityToUpdate); 229 } 230 } 231 232 private IBaseResource applyPatchToResource( 233 PatchTypeEnum thePatchType, 234 String thePatchBody, 235 IBaseParameters theFhirPatchBody, 236 IBaseResource theResourceToUpdate) { 237 IBaseResource destination; 238 switch (thePatchType) { 239 case JSON_PATCH: 240 destination = JsonPatchUtils.apply(getContext(), theResourceToUpdate, thePatchBody); 241 break; 242 case XML_PATCH: 243 destination = XmlPatchUtils.apply(getContext(), theResourceToUpdate, thePatchBody); 244 break; 245 case FHIR_PATCH_XML: 246 case FHIR_PATCH_JSON: 247 default: 248 IBaseParameters fhirPatchJson = theFhirPatchBody; 249 new FhirPatch(getContext()).apply(theResourceToUpdate, fhirPatchJson); 250 destination = theResourceToUpdate; 251 break; 252 } 253 return destination; 254 } 255 256 private IBasePersistedResource getEntityToPatchWithMatchUrlCache( 257 String theConditionalUrl, 258 RequestDetails theRequestDetails, 259 TransactionDetails theTransactionDetails, 260 RequestPartitionId theRequestPartitionId) { 261 IBasePersistedResource theEntityToUpdate; 262 263 Set<IResourcePersistentId> match = getMatchResourceUrlService() 264 .processMatchUrl( 265 theConditionalUrl, 266 getResourceType(), 267 theTransactionDetails, 268 theRequestDetails, 269 theRequestPartitionId); 270 if (match.size() > 1) { 271 String msg = getContext() 272 .getLocalizer() 273 .getMessageSanitized( 274 BaseStorageDao.class, 275 "transactionOperationWithMultipleMatchFailure", 276 "PATCH", 277 getResourceName(), 278 theConditionalUrl, 279 match.size()); 280 throw new PreconditionFailedException(Msg.code(972) + msg); 281 } else if (match.size() == 1) { 282 IResourcePersistentId pid = match.iterator().next(); 283 theEntityToUpdate = readEntityLatestVersion(pid, theRequestDetails, theTransactionDetails); 284 285 } else { 286 String msg = getContext() 287 .getLocalizer() 288 .getMessageSanitized(BaseStorageDao.class, "invalidMatchUrlNoMatches", theConditionalUrl); 289 throw new ResourceNotFoundException(Msg.code(973) + msg); 290 } 291 return theEntityToUpdate; 292 } 293 294 @Override 295 @Nonnull 296 public abstract Class<T> getResourceType(); 297 298 @Override 299 @Nonnull 300 protected abstract String getResourceName(); 301 302 protected abstract IBasePersistedResource readEntityLatestVersion( 303 IResourcePersistentId thePersistentId, 304 RequestDetails theRequestDetails, 305 TransactionDetails theTransactionDetails); 306 307 protected abstract IBasePersistedResource readEntityLatestVersion( 308 IIdType theId, RequestDetails theRequestDetails, TransactionDetails theTransactionDetails); 309 310 protected DaoMethodOutcome doUpdateForUpdateOrPatch(UpdateParameters<T> theUpdateParameters) { 311 312 if (theUpdateParameters.getResourceIdToUpdate().hasVersionIdPart() 313 && Long.parseLong(theUpdateParameters.getResourceIdToUpdate().getVersionIdPart()) 314 != theUpdateParameters.getEntity().getVersion()) { 315 throw new ResourceVersionConflictException(Msg.code(989) + "Trying to update " 316 + theUpdateParameters.getResourceIdToUpdate() + " but this is not the current version"); 317 } 318 319 if (theUpdateParameters.getResourceIdToUpdate().hasResourceType() 320 && !theUpdateParameters 321 .getResourceIdToUpdate() 322 .getResourceType() 323 .equals(getResourceName())) { 324 throw new UnprocessableEntityException(Msg.code(990) + "Invalid resource ID[" 325 + theUpdateParameters.getEntity().getIdDt().toUnqualifiedVersionless() + "] of type[" 326 + theUpdateParameters.getEntity().getResourceType() 327 + "] - Does not match expected [" + getResourceName() + "]"); 328 } 329 330 IBaseResource oldResource; 331 if (getStorageSettings().isMassIngestionMode() 332 && !theUpdateParameters.shouldForcePopulateOldResourceForProcessing()) { 333 oldResource = null; 334 } else { 335 oldResource = getStorageResourceParser().toResource(theUpdateParameters.getEntity(), false); 336 } 337 338 /* 339 * Mark the entity as not deleted - This is also done in the actual updateInternal() 340 * method later on so it usually doesn't matter whether we do it here, but in the 341 * case of a transaction with multiple PUTs we don't get there until later so 342 * having this here means that a transaction can have a reference in one 343 * resource to another resource in the same transaction that is being 344 * un-deleted by the transaction. Wacky use case, sure. But it's real. 345 * 346 * See SystemProviderR4Test#testTransactionReSavesPreviouslyDeletedResources 347 * for a test that needs this. 348 */ 349 boolean wasDeleted = theUpdateParameters.getEntity().isDeleted(); 350 theUpdateParameters.getEntity().setNotDeleted(); 351 352 /* 353 * If we aren't indexing, that means we're doing this inside a transaction. 354 * The transaction will do the actual storage to the database a bit later on, 355 * after placeholder IDs have been replaced, by calling {@link #updateInternal} 356 * directly. So we just bail now. 357 */ 358 if (!theUpdateParameters.shouldPerformIndexing()) { 359 theUpdateParameters 360 .getResource() 361 .setId(theUpdateParameters.getEntity().getIdDt().getValue()); 362 DaoMethodOutcome outcome = toMethodOutcome( 363 theUpdateParameters.getRequest(), 364 theUpdateParameters.getEntity(), 365 theUpdateParameters.getResource(), 366 theUpdateParameters.getMatchUrl(), 367 theUpdateParameters.getOperationType()) 368 .setCreated(wasDeleted); 369 outcome.setPreviousResource(oldResource); 370 if (!outcome.isNop()) { 371 // Technically this may not end up being right since we might not increment if the 372 // contents turn out to be the same 373 outcome.setId(outcome.getId() 374 .withVersion(Long.toString(outcome.getId().getVersionIdPartAsLong() + 1))); 375 } 376 return outcome; 377 } 378 379 /* 380 * Otherwise, we're not in a transaction 381 */ 382 return updateInternal( 383 theUpdateParameters.getRequest(), 384 theUpdateParameters.getResource(), 385 theUpdateParameters.getMatchUrl(), 386 theUpdateParameters.shouldPerformIndexing(), 387 theUpdateParameters.shouldForceUpdateVersion(), 388 theUpdateParameters.getEntity(), 389 theUpdateParameters.getResourceIdToUpdate(), 390 oldResource, 391 theUpdateParameters.getOperationType(), 392 theUpdateParameters.getTransactionDetails()); 393 } 394 395 public static void validateResourceType(IBasePersistedResource<?> theEntity, String theResourceName) { 396 if (!theResourceName.equals(theEntity.getResourceType())) { 397 throw new ResourceNotFoundException(Msg.code(935) + "Resource with ID " 398 + theEntity.getIdDt().getIdPart() + " exists but it is not of type " + theResourceName 399 + ", found resource of type " + theEntity.getResourceType()); 400 } 401 } 402 403 protected DeleteMethodOutcome deleteExpunge(String theUrl, RequestDetails theRequest) { 404 if (!getStorageSettings().canDeleteExpunge()) { 405 throw new MethodNotAllowedException(Msg.code(963) + "_expunge is not enabled on this server: " 406 + getStorageSettings().cannotDeleteExpungeReason()); 407 } 408 409 RestfulServerUtils.DeleteCascadeDetails cascadeDelete = 410 RestfulServerUtils.extractDeleteCascadeParameter(theRequest); 411 boolean cascade = false; 412 Integer cascadeMaxRounds = null; 413 if (cascadeDelete.getMode() == DeleteCascadeModeEnum.DELETE) { 414 cascade = true; 415 cascadeMaxRounds = cascadeDelete.getMaxRounds(); 416 if (cascadeMaxRounds == null) { 417 cascadeMaxRounds = myStorageSettings.getMaximumDeleteConflictQueryCount(); 418 } else if (myStorageSettings.getMaximumDeleteConflictQueryCount() != null 419 && myStorageSettings.getMaximumDeleteConflictQueryCount() < cascadeMaxRounds) { 420 cascadeMaxRounds = myStorageSettings.getMaximumDeleteConflictQueryCount(); 421 } 422 } 423 424 List<String> urlsToDeleteExpunge = Collections.singletonList(theUrl); 425 try { 426 String jobId = getDeleteExpungeJobSubmitter() 427 .submitJob( 428 getStorageSettings().getExpungeBatchSize(), 429 urlsToDeleteExpunge, 430 cascade, 431 cascadeMaxRounds, 432 theRequest); 433 return new DeleteMethodOutcome(createInfoOperationOutcome("Delete job submitted with id " + jobId)); 434 } catch (InvalidRequestException e) { 435 throw new InvalidRequestException(Msg.code(965) + "Invalid Delete Expunge Request: " + e.getMessage(), e); 436 } 437 } 438}