
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 private IRequestPartitionHelperSvc myRequestPartitionHelperSvc; 081 082 protected IRequestPartitionHelperSvc getRequestPartitionHelperService() { 083 // TODO JD change this to an abstract autowired on the next bump 084 return myRequestPartitionHelperSvc; 085 } 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 TransactionDetails transactionDetails = new TransactionDetails(); 096 return getTransactionService() 097 .execute( 098 theRequestDetails, 099 transactionDetails, 100 tx -> patchInTransaction( 101 theId, 102 theConditionalUrl, 103 true, 104 thePatchType, 105 thePatchBody, 106 theFhirPatchBody, 107 theRequestDetails, 108 transactionDetails)); 109 } 110 111 @Override 112 public DaoMethodOutcome patchInTransaction( 113 IIdType theId, 114 String theConditionalUrl, 115 boolean thePerformIndexing, 116 PatchTypeEnum thePatchType, 117 String thePatchBody, 118 IBaseParameters theFhirPatchBody, 119 RequestDetails theRequestDetails, 120 TransactionDetails theTransactionDetails) { 121 assert TransactionSynchronizationManager.isActualTransactionActive(); 122 123 IBasePersistedResource entityToUpdate; 124 IIdType resourceId; 125 if (isNotBlank(theConditionalUrl)) { 126 127 RequestPartitionId theRequestPartitionId = getRequestPartitionHelperService() 128 .determineReadPartitionForRequestForSearchType( 129 theRequestDetails, getResourceType().getTypeName()); 130 131 Set<IResourcePersistentId> match = getMatchResourceUrlService() 132 .processMatchUrl( 133 theConditionalUrl, 134 getResourceType(), 135 theTransactionDetails, 136 theRequestDetails, 137 theRequestPartitionId); 138 if (match.size() > 1) { 139 String msg = getContext() 140 .getLocalizer() 141 .getMessageSanitized( 142 BaseStorageDao.class, 143 "transactionOperationWithMultipleMatchFailure", 144 "PATCH", 145 getResourceName(), 146 theConditionalUrl, 147 match.size()); 148 throw new PreconditionFailedException(Msg.code(972) + msg); 149 } else if (match.size() == 1) { 150 IResourcePersistentId pid = match.iterator().next(); 151 entityToUpdate = readEntityLatestVersion(pid, theRequestDetails, theTransactionDetails); 152 resourceId = entityToUpdate.getIdDt(); 153 } else { 154 String msg = getContext() 155 .getLocalizer() 156 .getMessageSanitized(BaseStorageDao.class, "invalidMatchUrlNoMatches", theConditionalUrl); 157 throw new ResourceNotFoundException(Msg.code(973) + msg); 158 } 159 160 } else { 161 resourceId = theId; 162 entityToUpdate = readEntityLatestVersion(theId, theRequestDetails, theTransactionDetails); 163 if (theId.hasVersionIdPart()) { 164 if (theId.getVersionIdPartAsLong() != entityToUpdate.getVersion()) { 165 throw new ResourceVersionConflictException(Msg.code(974) + "Version " + theId.getVersionIdPart() 166 + " is not the most recent version of this resource, unable to apply patch"); 167 } 168 } 169 } 170 171 validateResourceType(entityToUpdate, getResourceName()); 172 173 if (entityToUpdate.isDeleted()) { 174 throw createResourceGoneException(entityToUpdate); 175 } 176 177 IBaseResource resourceToUpdate = getStorageResourceParser().toResource(entityToUpdate, false); 178 if (resourceToUpdate == null) { 179 // If this is null, we are presumably in a FHIR transaction bundle with both a create and a patch on the 180 // same 181 // resource. This is weird but not impossible. 182 resourceToUpdate = theTransactionDetails.getResolvedResource(resourceId); 183 } 184 185 IBaseResource destination; 186 switch (thePatchType) { 187 case JSON_PATCH: 188 destination = JsonPatchUtils.apply(getContext(), resourceToUpdate, thePatchBody); 189 break; 190 case XML_PATCH: 191 destination = XmlPatchUtils.apply(getContext(), resourceToUpdate, thePatchBody); 192 break; 193 case FHIR_PATCH_XML: 194 case FHIR_PATCH_JSON: 195 default: 196 IBaseParameters fhirPatchJson = theFhirPatchBody; 197 new FhirPatch(getContext()).apply(resourceToUpdate, fhirPatchJson); 198 destination = resourceToUpdate; 199 break; 200 } 201 202 @SuppressWarnings("unchecked") 203 T destinationCasted = (T) destination; 204 myFhirContext 205 .newJsonParser() 206 .setParserErrorHandler(STRICT_ERROR_HANDLER) 207 .encodeResourceToString(destinationCasted); 208 209 preProcessResourceForStorage(destinationCasted, theRequestDetails, theTransactionDetails, true); 210 211 UpdateParameters updateParameters = new UpdateParameters<>() 212 .setRequestDetails(theRequestDetails) 213 .setResourceIdToUpdate(resourceId) 214 .setMatchUrl(theConditionalUrl) 215 .setShouldPerformIndexing(thePerformIndexing) 216 .setShouldForceUpdateVersion(false) 217 .setResource(destinationCasted) 218 .setEntity(entityToUpdate) 219 .setOperationType(RestOperationTypeEnum.PATCH) 220 .setTransactionDetails(theTransactionDetails) 221 .setShouldForcePopulateOldResourceForProcessing(false); 222 223 return doUpdateForUpdateOrPatch(updateParameters); 224 } 225 226 @Override 227 @Nonnull 228 public abstract Class<T> getResourceType(); 229 230 @Override 231 @Nonnull 232 protected abstract String getResourceName(); 233 234 protected abstract IBasePersistedResource readEntityLatestVersion( 235 IResourcePersistentId thePersistentId, 236 RequestDetails theRequestDetails, 237 TransactionDetails theTransactionDetails); 238 239 protected abstract IBasePersistedResource readEntityLatestVersion( 240 IIdType theId, RequestDetails theRequestDetails, TransactionDetails theTransactionDetails); 241 242 protected DaoMethodOutcome doUpdateForUpdateOrPatch(UpdateParameters<T> theUpdateParameters) { 243 244 if (theUpdateParameters.getResourceIdToUpdate().hasVersionIdPart() 245 && Long.parseLong(theUpdateParameters.getResourceIdToUpdate().getVersionIdPart()) 246 != theUpdateParameters.getEntity().getVersion()) { 247 throw new ResourceVersionConflictException(Msg.code(989) + "Trying to update " 248 + theUpdateParameters.getResourceIdToUpdate() + " but this is not the current version"); 249 } 250 251 if (theUpdateParameters.getResourceIdToUpdate().hasResourceType() 252 && !theUpdateParameters 253 .getResourceIdToUpdate() 254 .getResourceType() 255 .equals(getResourceName())) { 256 throw new UnprocessableEntityException(Msg.code(990) + "Invalid resource ID[" 257 + theUpdateParameters.getEntity().getIdDt().toUnqualifiedVersionless() + "] of type[" 258 + theUpdateParameters.getEntity().getResourceType() 259 + "] - Does not match expected [" + getResourceName() + "]"); 260 } 261 262 IBaseResource oldResource; 263 if (getStorageSettings().isMassIngestionMode() 264 && !theUpdateParameters.shouldForcePopulateOldResourceForProcessing()) { 265 oldResource = null; 266 } else { 267 oldResource = getStorageResourceParser().toResource(theUpdateParameters.getEntity(), false); 268 } 269 270 /* 271 * Mark the entity as not deleted - This is also done in the actual updateInternal() 272 * method later on so it usually doesn't matter whether we do it here, but in the 273 * case of a transaction with multiple PUTs we don't get there until later so 274 * having this here means that a transaction can have a reference in one 275 * resource to another resource in the same transaction that is being 276 * un-deleted by the transaction. Wacky use case, sure. But it's real. 277 * 278 * See SystemProviderR4Test#testTransactionReSavesPreviouslyDeletedResources 279 * for a test that needs this. 280 */ 281 boolean wasDeleted = theUpdateParameters.getEntity().isDeleted(); 282 theUpdateParameters.getEntity().setNotDeleted(); 283 284 /* 285 * If we aren't indexing, that means we're doing this inside a transaction. 286 * The transaction will do the actual storage to the database a bit later on, 287 * after placeholder IDs have been replaced, by calling {@link #updateInternal} 288 * directly. So we just bail now. 289 */ 290 if (!theUpdateParameters.shouldPerformIndexing()) { 291 theUpdateParameters 292 .getResource() 293 .setId(theUpdateParameters.getEntity().getIdDt().getValue()); 294 DaoMethodOutcome outcome = toMethodOutcome( 295 theUpdateParameters.getRequest(), 296 theUpdateParameters.getEntity(), 297 theUpdateParameters.getResource(), 298 theUpdateParameters.getMatchUrl(), 299 theUpdateParameters.getOperationType()) 300 .setCreated(wasDeleted); 301 outcome.setPreviousResource(oldResource); 302 if (!outcome.isNop()) { 303 // Technically this may not end up being right since we might not increment if the 304 // contents turn out to be the same 305 outcome.setId(outcome.getId() 306 .withVersion(Long.toString(outcome.getId().getVersionIdPartAsLong() + 1))); 307 } 308 return outcome; 309 } 310 311 /* 312 * Otherwise, we're not in a transaction 313 */ 314 return updateInternal( 315 theUpdateParameters.getRequest(), 316 theUpdateParameters.getResource(), 317 theUpdateParameters.getMatchUrl(), 318 theUpdateParameters.shouldPerformIndexing(), 319 theUpdateParameters.shouldForceUpdateVersion(), 320 theUpdateParameters.getEntity(), 321 theUpdateParameters.getResourceIdToUpdate(), 322 oldResource, 323 theUpdateParameters.getOperationType(), 324 theUpdateParameters.getTransactionDetails()); 325 } 326 327 public static void validateResourceType(IBasePersistedResource<?> theEntity, String theResourceName) { 328 if (!theResourceName.equals(theEntity.getResourceType())) { 329 throw new ResourceNotFoundException(Msg.code(935) + "Resource with ID " 330 + theEntity.getIdDt().getIdPart() + " exists but it is not of type " + theResourceName 331 + ", found resource of type " + theEntity.getResourceType()); 332 } 333 } 334 335 protected DeleteMethodOutcome deleteExpunge(String theUrl, RequestDetails theRequest) { 336 if (!getStorageSettings().canDeleteExpunge()) { 337 throw new MethodNotAllowedException(Msg.code(963) + "_expunge is not enabled on this server: " 338 + getStorageSettings().cannotDeleteExpungeReason()); 339 } 340 341 RestfulServerUtils.DeleteCascadeDetails cascadeDelete = 342 RestfulServerUtils.extractDeleteCascadeParameter(theRequest); 343 boolean cascade = false; 344 Integer cascadeMaxRounds = null; 345 if (cascadeDelete.getMode() == DeleteCascadeModeEnum.DELETE) { 346 cascade = true; 347 cascadeMaxRounds = cascadeDelete.getMaxRounds(); 348 if (cascadeMaxRounds == null) { 349 cascadeMaxRounds = myStorageSettings.getMaximumDeleteConflictQueryCount(); 350 } else if (myStorageSettings.getMaximumDeleteConflictQueryCount() != null 351 && myStorageSettings.getMaximumDeleteConflictQueryCount() < cascadeMaxRounds) { 352 cascadeMaxRounds = myStorageSettings.getMaximumDeleteConflictQueryCount(); 353 } 354 } 355 356 List<String> urlsToDeleteExpunge = Collections.singletonList(theUrl); 357 try { 358 String jobId = getDeleteExpungeJobSubmitter() 359 .submitJob( 360 getStorageSettings().getExpungeBatchSize(), 361 urlsToDeleteExpunge, 362 cascade, 363 cascadeMaxRounds, 364 theRequest); 365 return new DeleteMethodOutcome(createInfoOperationOutcome("Delete job submitted with id " + jobId)); 366 } catch (InvalidRequestException e) { 367 throw new InvalidRequestException(Msg.code(965) + "Invalid Delete Expunge Request: " + e.getMessage(), e); 368 } 369 } 370}