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