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