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