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