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