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