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