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