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