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