diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/5_1_0/2025-disable-referential-integrity-for-some-paths.yaml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/5_1_0/2025-disable-referential-integrity-for-some-paths.yaml new file mode 100644 index 00000000000..e70f23e29b8 --- /dev/null +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/5_1_0/2025-disable-referential-integrity-for-some-paths.yaml @@ -0,0 +1,5 @@ +--- +type: add +issue: 2025 +title: "A new interceptor has been added for the JPA server that selectively allows resource deletions to proceed even if + there are valid references to the candidate for deletion from other resources that are not being deleted." diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/docs/interceptors/built_in_server_interceptors.md b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/docs/interceptors/built_in_server_interceptors.md index 09b2bd28452..46fe776ec67 100644 --- a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/docs/interceptors/built_in_server_interceptors.md +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/docs/interceptors/built_in_server_interceptors.md @@ -180,8 +180,24 @@ The ResponseSizeCapturingInterceptor can be used to capture the number of charac # JPA Server: Allow Cascading Deletes +* [CascadingDeleteInterceptor JavaDoc](/apidocs/hapi-fhir-jpaserver-base/ca/uhn/fhir/jpa/interceptor/CascadingDeleteInterceptor.html) +* [CascadingDeleteInterceptor Source](https://github.com/jamesagnew/hapi-fhir/blob/master/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/interceptor/CascadingDeleteInterceptor.java) + The CascadingDeleteInterceptor allows clients to request deletes be cascaded to other resources that contain incoming references. See [Cascading Deletes](/docs/server_jpa/configuration.html#cascading-deletes) for more information. + + + +# JPA Server: Disable Referential Integrity for Some Paths + +* [OverridePathBasedReferentialIntegrityForDeletesInterceptor JavaDoc](/apidocs/hapi-fhir-jpaserver-base/ca/uhn/fhir/jpa/interceptor/OverridePathBasedReferentialIntegrityForDeletesInterceptor.html) +* [OverridePathBasedReferentialIntegrityForDeletesInterceptor Source](https://github.com/jamesagnew/hapi-fhir/blob/master/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/interceptor/OverridePathBasedReferentialIntegrityForDeletesInterceptor.java) + +The OverridePathBasedReferentialIntegrityForDeletesInterceptor can be registered and configured to allow resources to be deleted even if other resources have outgoing references to the deleted resource. While it is generally a bad idea to allow deletion of resources that are referred to from other resources, there are circumstances where it is desirable. For example, if you have Provenance or AuditEvent resources that refer to a Patient resource that was created in error, you might want to alow the Patient to be deleted while leaving the Provenance and AuditEvent resources intact (including the now-invalid outgoing references to that Patient). + +This interceptor uses FHIRPath expressions to indicate the resource paths that should not have referential integrity applied to them. For example, if this interceptor is configured with a path of `AuditEvent.agent.who`, a Patient resource would be allowed to be deleted even if one or more AuditEvents had references in that path to the given Patient (unless other resources also had references to the Patient). + + # JPA Server: Retry on Version Conflicts The UserRequestRetryVersionConflictsInterceptor allows clients to request that the server avoid version conflicts (HTTP 409) when two concurrent client requests attempt to modify the same resource. See [Version Conflicts](/docs/server_jpa/configuration.html#retry-on-version-conflict) for more information. diff --git a/hapi-fhir-jpaserver-api/src/main/java/ca/uhn/fhir/jpa/api/model/DeleteConflictList.java b/hapi-fhir-jpaserver-api/src/main/java/ca/uhn/fhir/jpa/api/model/DeleteConflictList.java index 74e40219307..84c2030a82d 100644 --- a/hapi-fhir-jpaserver-api/src/main/java/ca/uhn/fhir/jpa/api/model/DeleteConflictList.java +++ b/hapi-fhir-jpaserver-api/src/main/java/ca/uhn/fhir/jpa/api/model/DeleteConflictList.java @@ -34,6 +34,7 @@ import java.util.function.Predicate; public class DeleteConflictList implements Iterable { private final List myList = new ArrayList<>(); private final Set myResourceIdsMarkedForDeletion; + private final Set myResourceIdsToIgnoreConflict; private int myRemoveModCount; /** @@ -41,6 +42,7 @@ public class DeleteConflictList implements Iterable { */ public DeleteConflictList() { myResourceIdsMarkedForDeletion = new HashSet<>(); + myResourceIdsToIgnoreConflict = new HashSet<>(); } /** @@ -49,6 +51,7 @@ public class DeleteConflictList implements Iterable { */ public DeleteConflictList(DeleteConflictList theParentList) { myResourceIdsMarkedForDeletion = theParentList.myResourceIdsMarkedForDeletion; + myResourceIdsToIgnoreConflict = theParentList.myResourceIdsToIgnoreConflict; } @@ -64,6 +67,18 @@ public class DeleteConflictList implements Iterable { myResourceIdsMarkedForDeletion.add(theIdType.toUnqualifiedVersionless().getValue()); } + public boolean isResourceIdToIgnoreConflict(IIdType theIdType) { + Validate.notNull(theIdType); + Validate.notBlank(theIdType.toUnqualifiedVersionless().getValue()); + return myResourceIdsToIgnoreConflict.contains(theIdType.toUnqualifiedVersionless().getValue()); + } + + public void setResourceIdToIgnoreConflict(IIdType theIdType) { + Validate.notNull(theIdType); + Validate.notBlank(theIdType.toUnqualifiedVersionless().getValue()); + myResourceIdsToIgnoreConflict.add(theIdType.toUnqualifiedVersionless().getValue()); + } + public void add(DeleteConflict theDeleteConflict) { myList.add(theDeleteConflict); } diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/BaseConfig.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/BaseConfig.java index 36a02f2c08e..d6fc005a7c3 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/BaseConfig.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/BaseConfig.java @@ -2,6 +2,7 @@ package ca.uhn.fhir.jpa.config; import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.i18n.HapiLocalizer; +import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster; import ca.uhn.fhir.interceptor.api.IInterceptorService; import ca.uhn.fhir.interceptor.executor.InterceptorService; import ca.uhn.fhir.jpa.api.dao.DaoRegistry; @@ -24,7 +25,9 @@ import ca.uhn.fhir.jpa.dao.index.DaoResourceLinkResolver; import ca.uhn.fhir.jpa.dao.tx.HapiTransactionService; import ca.uhn.fhir.jpa.entity.Search; import ca.uhn.fhir.jpa.graphql.JpaStorageServices; +import ca.uhn.fhir.jpa.interceptor.CascadingDeleteInterceptor; import ca.uhn.fhir.jpa.interceptor.JpaConsentContextServices; +import ca.uhn.fhir.jpa.interceptor.OverridePathBasedReferentialIntegrityForDeletesInterceptor; import ca.uhn.fhir.jpa.model.sched.ISchedulerService; import ca.uhn.fhir.jpa.packages.IHapiPackageCacheManager; import ca.uhn.fhir.jpa.packages.IPackageInstallerSvc; @@ -167,6 +170,12 @@ public abstract class BaseConfig { return new BatchJobSubmitterImpl(); } + @Lazy + @Bean + public CascadingDeleteInterceptor cascadingDeleteInterceptor(FhirContext theFhirContext, DaoRegistry theDaoRegistry, IInterceptorBroadcaster theInterceptorBroadcaster) { + return new CascadingDeleteInterceptor(theFhirContext, theDaoRegistry, theInterceptorBroadcaster); + } + /** * This method should be overridden to provide an actual completed * bean, but it provides a partially completed entity manager @@ -295,6 +304,12 @@ public abstract class BaseConfig { return new HapiFhirHibernateJpaDialect(fhirContext().getLocalizer()); } + @Bean + @Lazy + public OverridePathBasedReferentialIntegrityForDeletesInterceptor overridePathBasedReferentialIntegrityForDeletesInterceptor() { + return new OverridePathBasedReferentialIntegrityForDeletesInterceptor(); + } + @Bean public IRequestPartitionHelperSvc requestPartitionHelperService() { return new RequestPartitionHelperSvc(); diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/delete/DeleteConflictService.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/delete/DeleteConflictService.java index f7f1fdbc687..2cbbaafa165 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/delete/DeleteConflictService.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/delete/DeleteConflictService.java @@ -44,27 +44,25 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.List; @Service public class DeleteConflictService { - private static final Logger ourLog = LoggerFactory.getLogger(DeleteConflictService.class); public static final int FIRST_QUERY_RESULT_COUNT = 1; + private static final Logger ourLog = LoggerFactory.getLogger(DeleteConflictService.class); public static int MAX_RETRY_ATTEMPTS = 10; public static String MAX_RETRY_ATTEMPTS_EXCEEDED_MSG = "Requested delete operation stopped before all conflicts were handled. May need to increase the configured Maximum Delete Conflict Query Count."; - + @Autowired + protected IResourceLinkDao myResourceLinkDao; + @Autowired + protected IInterceptorBroadcaster myInterceptorBroadcaster; @Autowired DeleteConflictFinderService myDeleteConflictFinderService; @Autowired DaoConfig myDaoConfig; @Autowired - protected IResourceLinkDao myResourceLinkDao; - @Autowired private FhirContext myFhirContext; - @Autowired - protected IInterceptorBroadcaster myInterceptorBroadcaster; public int validateOkToDelete(DeleteConflictList theDeleteConflicts, ResourceTable theEntity, boolean theForValidate, RequestDetails theRequest, TransactionDetails theTransactionDetails) { @@ -87,9 +85,9 @@ public class DeleteConflictService { ++retryCount; } theDeleteConflicts.addAll(newConflicts); - if(retryCount >= MAX_RETRY_ATTEMPTS && !theDeleteConflicts.isEmpty()) { + if (retryCount >= MAX_RETRY_ATTEMPTS && !theDeleteConflicts.isEmpty()) { IBaseOperationOutcome oo = OperationOutcomeUtil.newInstance(myFhirContext); - OperationOutcomeUtil.addIssue(myFhirContext, oo, BaseHapiFhirDao.OO_SEVERITY_ERROR, MAX_RETRY_ATTEMPTS_EXCEEDED_MSG,null, "processing"); + OperationOutcomeUtil.addIssue(myFhirContext, oo, BaseHapiFhirDao.OO_SEVERITY_ERROR, MAX_RETRY_ATTEMPTS_EXCEEDED_MSG, null, "processing"); throw new ResourceVersionConflictException(MAX_RETRY_ATTEMPTS_EXCEEDED_MSG, oo); } return retryCount; @@ -123,7 +121,7 @@ public class DeleteConflictService { .add(RequestDetails.class, theRequest) .addIfMatchesType(ServletRequestDetails.class, theRequest) .add(TransactionDetails.class, theTransactionDetails); - return (DeleteConflictOutcome)JpaInterceptorBroadcaster.doCallHooksAndReturnObject(myInterceptorBroadcaster, theRequest, Pointcut.STORAGE_PRESTORAGE_DELETE_CONFLICTS, hooks); + return (DeleteConflictOutcome) JpaInterceptorBroadcaster.doCallHooksAndReturnObject(myInterceptorBroadcaster, theRequest, Pointcut.STORAGE_PRESTORAGE_DELETE_CONFLICTS, hooks); } private void addConflictsToList(DeleteConflictList theDeleteConflicts, ResourceTable theEntity, List theResultList) { @@ -142,26 +140,33 @@ public class DeleteConflictService { } public static void validateDeleteConflictsEmptyOrThrowException(FhirContext theFhirContext, DeleteConflictList theDeleteConflicts) { - if (theDeleteConflicts.isEmpty()) { - return; - } - - IBaseOperationOutcome oo = OperationOutcomeUtil.newInstance(theFhirContext); + IBaseOperationOutcome oo = null; String firstMsg = null; for (DeleteConflict next : theDeleteConflicts) { + + if (theDeleteConflicts.isResourceIdToIgnoreConflict(next.getTargetId())) { + continue; + } + String msg = "Unable to delete " + next.getTargetId().toUnqualifiedVersionless().getValue() + " because at least one resource has a reference to this resource. First reference found was resource " + next.getSourceId().toUnqualifiedVersionless().getValue() + " in path " + next.getSourcePath(); + if (firstMsg == null) { firstMsg = msg; + oo = OperationOutcomeUtil.newInstance(theFhirContext); } OperationOutcomeUtil.addIssue(theFhirContext, oo, BaseHapiFhirDao.OO_SEVERITY_ERROR, msg, null, "processing"); } + if (firstMsg == null) { + return; + } + throw new ResourceVersionConflictException(firstMsg, oo); } diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/interceptor/CascadingDeleteInterceptor.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/interceptor/CascadingDeleteInterceptor.java index fc8aa12388c..a2875454e06 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/interceptor/CascadingDeleteInterceptor.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/interceptor/CascadingDeleteInterceptor.java @@ -71,6 +71,13 @@ import static org.apache.commons.lang3.StringUtils.isNotBlank; @Interceptor public class CascadingDeleteInterceptor { + /* + * We keep the orders for the various handlers of {@link Pointcut#STORAGE_PRESTORAGE_DELETE_CONFLICTS} in one place + * so it's easy to compare them + */ + public static final int OVERRIDE_PATH_BASED_REF_INTEGRITY_INTERCEPTOR_ORDER = 0; + public static final int CASCADING_DELETE_INTERCEPTOR_ORDER = 1; + private static final Logger ourLog = LoggerFactory.getLogger(CascadingDeleteInterceptor.class); private static final String CASCADED_DELETES_KEY = CascadingDeleteInterceptor.class.getName() + "_CASCADED_DELETES_KEY"; private static final String CASCADED_DELETES_FAILED_KEY = CascadingDeleteInterceptor.class.getName() + "_CASCADED_DELETES_FAILED_KEY"; @@ -94,7 +101,7 @@ public class CascadingDeleteInterceptor { myFhirContext = theFhirContext; } - @Hook(Pointcut.STORAGE_PRESTORAGE_DELETE_CONFLICTS) + @Hook(value = Pointcut.STORAGE_PRESTORAGE_DELETE_CONFLICTS, order = CASCADING_DELETE_INTERCEPTOR_ORDER) public DeleteConflictOutcome handleDeleteConflicts(DeleteConflictList theConflictList, RequestDetails theRequest, TransactionDetails theTransactionDetails) { ourLog.debug("Have delete conflicts: {}", theConflictList); diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/interceptor/OverridePathBasedReferentialIntegrityForDeletesInterceptor.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/interceptor/OverridePathBasedReferentialIntegrityForDeletesInterceptor.java new file mode 100644 index 00000000000..e89261cb1b7 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/interceptor/OverridePathBasedReferentialIntegrityForDeletesInterceptor.java @@ -0,0 +1,107 @@ +package ca.uhn.fhir.jpa.interceptor; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.fhirpath.IFhirPath; +import ca.uhn.fhir.interceptor.api.Hook; +import ca.uhn.fhir.interceptor.api.Interceptor; +import ca.uhn.fhir.interceptor.api.Pointcut; +import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import ca.uhn.fhir.jpa.api.model.DeleteConflict; +import ca.uhn.fhir.jpa.api.model.DeleteConflictList; +import ca.uhn.fhir.model.primitive.IdDt; +import org.hl7.fhir.instance.model.api.IBaseReference; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * This JPA interceptor can be configured with a collection of FHIRPath expressions, and will disable + * referential integrity for target resources at those paths. + *

+ * For example, suppose this interceptor is configured with a path of AuditEvent.entity.what, + * and an AuditEvent resource exists in the repository that has a reference in that path to resource + * Patient/123. Normally this reference would prevent the Patient resource from being deleted unless + * the AuditEvent was first deleted as well (or a cascading delete was used). + * With this interceptor in place, the Patient resource could be deleted, and the AuditEvent would remain intact. + *

+ */ +@Interceptor +public class OverridePathBasedReferentialIntegrityForDeletesInterceptor { + + private static final Logger ourLog = LoggerFactory.getLogger(OverridePathBasedReferentialIntegrityForDeletesInterceptor.class); + private final Set myPaths = new HashSet<>(); + + @Autowired + private FhirContext myFhirContext; + @Autowired + private DaoRegistry myDaoRegistry; + + /** + * Constructor + */ + public OverridePathBasedReferentialIntegrityForDeletesInterceptor() { + super(); + } + + /** + * Adds a FHIRPath expression indicating a resource path that should be ignored when considering referential + * integrity for deletes. + * + * @param thePath The FHIRPath expression, e.g. AuditEvent.agent.who + */ + public void addPath(String thePath) { + getPaths().add(thePath); + } + + /** + * Remove all paths registered to this interceptor + */ + public void clearPaths() { + getPaths().clear(); + } + + /** + * Returns the paths that will be considered by this interceptor + * + * @see #addPath(String) + */ + private Set getPaths() { + return myPaths; + } + + /** + * Interceptor hook method. Do not invoke directly. + */ + @Hook(value = Pointcut.STORAGE_PRESTORAGE_DELETE_CONFLICTS, order = CascadingDeleteInterceptor.OVERRIDE_PATH_BASED_REF_INTEGRITY_INTERCEPTOR_ORDER) + public void handleDeleteConflicts(DeleteConflictList theDeleteConflictList) { + for (DeleteConflict nextConflict : theDeleteConflictList) { + ourLog.info("Ignoring referential integrity deleting {} - Referred to from {} at path {}", nextConflict.getTargetId(), nextConflict.getSourceId(), nextConflict.getSourcePath()); + + IdDt sourceId = nextConflict.getSourceId(); + IdDt targetId = nextConflict.getTargetId(); + String targetIdValue = targetId.toVersionless().getValue(); + + IBaseResource sourceResource = myDaoRegistry.getResourceDao(sourceId.getResourceType()).read(sourceId); + + IFhirPath fhirPath = myFhirContext.newFhirPath(); + for (String nextPath : myPaths) { + List selections = fhirPath.evaluate(sourceResource, nextPath, IBaseReference.class); + for (IBaseReference nextSelection : selections) { + String selectionTargetValue = nextSelection.getReferenceElement().toVersionless().getValue(); + if (Objects.equals(targetIdValue, selectionTargetValue)) { + theDeleteConflictList.setResourceIdToIgnoreConflict(nextConflict.getTargetId()); + break; + } + } + + } + + } + } +} diff --git a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/dao/r4/FhirResourceDaoR4DeleteTest.java b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/dao/r4/FhirResourceDaoR4DeleteTest.java index 97cc8d26b42..1cac6951212 100644 --- a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/dao/r4/FhirResourceDaoR4DeleteTest.java +++ b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/dao/r4/FhirResourceDaoR4DeleteTest.java @@ -99,7 +99,7 @@ public class FhirResourceDaoR4DeleteTest extends BaseJpaR4Test { @Test - public void testDeleteCircularReferenceInTransaction() throws IOException { + public void testDeleteCircularReferenceInTransaction() { // Create two resources with a circular reference Organization org1 = new Organization(); @@ -221,4 +221,12 @@ public class FhirResourceDaoR4DeleteTest extends BaseJpaR4Test { } + @Test + public void testDeleteIgnoreReferentialIntegrityForPaths() { + + + + } + + } diff --git a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/provider/r4/CascadingDeleteInterceptorR4Test.java b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/interceptor/CascadingDeleteInterceptorTest.java similarity index 82% rename from hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/provider/r4/CascadingDeleteInterceptorR4Test.java rename to hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/interceptor/CascadingDeleteInterceptorTest.java index 231d1ebc2de..afa85cc0522 100644 --- a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/provider/r4/CascadingDeleteInterceptorR4Test.java +++ b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/interceptor/CascadingDeleteInterceptorTest.java @@ -1,8 +1,8 @@ -package ca.uhn.fhir.jpa.provider.r4; +package ca.uhn.fhir.jpa.interceptor; import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster; import ca.uhn.fhir.jpa.api.dao.DaoRegistry; -import ca.uhn.fhir.jpa.interceptor.CascadingDeleteInterceptor; +import ca.uhn.fhir.jpa.provider.r4.BaseResourceProviderR4Test; import ca.uhn.fhir.rest.api.Constants; import ca.uhn.fhir.rest.server.exceptions.ResourceGoneException; import ca.uhn.fhir.rest.server.exceptions.ResourceVersionConflictException; @@ -20,7 +20,6 @@ import org.hl7.fhir.r4.model.Organization; import org.hl7.fhir.r4.model.Patient; import org.hl7.fhir.r4.model.Reference; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -31,9 +30,9 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.fail; -public class CascadingDeleteInterceptorR4Test extends BaseResourceProviderR4Test { +public class CascadingDeleteInterceptorTest extends BaseResourceProviderR4Test { - private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(CascadingDeleteInterceptorR4Test.class); + private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(CascadingDeleteInterceptorTest.class); private IIdType myDiagnosticReportId; @Autowired @@ -42,18 +41,13 @@ public class CascadingDeleteInterceptorR4Test extends BaseResourceProviderR4Test private IInterceptorBroadcaster myInterceptorBroadcaster; private IIdType myPatientId; + @Autowired private CascadingDeleteInterceptor myDeleteInterceptor; private IIdType myObservationId; private IIdType myConditionId; private IIdType myEncounterId; - - @Override - @BeforeEach - public void before() throws Exception { - super.before(); - - myDeleteInterceptor = new CascadingDeleteInterceptor(myFhirCtx, myDaoRegistry, myInterceptorBroadcaster); - } + @Autowired + private OverridePathBasedReferentialIntegrityForDeletesInterceptor myOverridePathBasedReferentialIntegrityForDeletesInterceptor; @Override @AfterEach @@ -161,6 +155,37 @@ public class CascadingDeleteInterceptorR4Test extends BaseResourceProviderR4Test } } + @Test + public void testDeleteCascadingWithOverridePathBasedReferentialIntegrityForDeletesInterceptorAlsoRegistered() throws IOException { + ourRestServer.getInterceptorService().registerInterceptor(myOverridePathBasedReferentialIntegrityForDeletesInterceptor); + try { + + createResources(); + + ourRestServer.getInterceptorService().registerInterceptor(myDeleteInterceptor); + + HttpDelete delete = new HttpDelete(ourServerBase + "/" + myPatientId.getValue() + "?" + Constants.PARAMETER_CASCADE_DELETE + "=" + Constants.CASCADE_DELETE + "&_pretty=true"); + delete.addHeader(Constants.HEADER_ACCEPT, Constants.CT_FHIR_JSON_NEW); + try (CloseableHttpResponse response = ourHttpClient.execute(delete)) { + assertEquals(200, response.getStatusLine().getStatusCode()); + String deleteResponse = IOUtils.toString(response.getEntity().getContent(), Charsets.UTF_8); + ourLog.info("Response: {}", deleteResponse); + assertThat(deleteResponse, containsString("Cascaded delete to ")); + } + + try { + ourLog.info("Reading {}", myPatientId); + myClient.read().resource(Patient.class).withId(myPatientId).execute(); + fail(); + } catch (ResourceGoneException e) { + // good + } + + } finally { + ourRestServer.getInterceptorService().unregisterInterceptor(myOverridePathBasedReferentialIntegrityForDeletesInterceptor); + } + } + @Test public void testDeleteCascadingWithCircularReference() throws IOException { diff --git a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/interceptor/OverridePathBasedReferentialIntegrityForDeletesInterceptorTest.java b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/interceptor/OverridePathBasedReferentialIntegrityForDeletesInterceptorTest.java new file mode 100644 index 00000000000..14363d30a1a --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/interceptor/OverridePathBasedReferentialIntegrityForDeletesInterceptorTest.java @@ -0,0 +1,141 @@ +package ca.uhn.fhir.jpa.interceptor; + +import ca.uhn.fhir.jpa.dao.r4.BaseJpaR4Test; +import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; +import ca.uhn.fhir.rest.api.server.IBundleProvider; +import ca.uhn.fhir.rest.param.ReferenceParam; +import ca.uhn.fhir.rest.server.exceptions.ResourceGoneException; +import ca.uhn.fhir.rest.server.exceptions.ResourceVersionConflictException; +import org.hl7.fhir.r4.model.AuditEvent; +import org.hl7.fhir.r4.model.IdType; +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +public class OverridePathBasedReferentialIntegrityForDeletesInterceptorTest extends BaseJpaR4Test { + + @Autowired + private OverridePathBasedReferentialIntegrityForDeletesInterceptor mySvc; + + @Autowired + private CascadingDeleteInterceptor myCascadingDeleteInterceptor; + + @AfterEach + public void after() { + myInterceptorRegistry.unregisterInterceptor(mySvc); + mySvc.clearPaths(); + } + + @Test + public void testDeleteBlockedIfNoInterceptorInPlace() { + Patient patient = new Patient(); + patient.setId("P"); + patient.setActive(true); + myPatientDao.update(patient); + + AuditEvent audit = new AuditEvent(); + audit.setId("A"); + audit.addAgent().getWho().setReference("Patient/P"); + myAuditEventDao.update(audit); + + try { + myPatientDao.delete(new IdType("Patient/P")); + fail(); + } catch (ResourceVersionConflictException e) { + // good + } + } + + + @Test + public void testAllowDelete() { + mySvc.addPath("AuditEvent.agent.who"); + myInterceptorRegistry.registerInterceptor(mySvc); + + Patient patient = new Patient(); + patient.setId("P"); + patient.setActive(true); + myPatientDao.update(patient); + + AuditEvent audit = new AuditEvent(); + audit.setId("A"); + audit.addAgent().getWho().setReference("Patient/P"); + myAuditEventDao.update(audit); + + // Delete should proceed + myPatientDao.delete(new IdType("Patient/P")); + + // Make sure we're deleted + try { + myPatientDao.read(new IdType("Patient/P")); + fail(); + } catch (ResourceGoneException e) { + // good + } + + // Search should still work + IBundleProvider searchOutcome = myAuditEventDao.search(SearchParameterMap.newSynchronous(AuditEvent.SP_AGENT, new ReferenceParam("Patient/P"))); + assertEquals(1, searchOutcome.size()); + + + } + + @Test + public void testWrongPath() { + mySvc.addPath("AuditEvent.identifier"); + mySvc.addPath("Patient.agent.who"); + myInterceptorRegistry.registerInterceptor(mySvc); + + Patient patient = new Patient(); + patient.setId("P"); + patient.setActive(true); + myPatientDao.update(patient); + + AuditEvent audit = new AuditEvent(); + audit.setId("A"); + audit.addAgent().getWho().setReference("Patient/P"); + myAuditEventDao.update(audit); + + // Delete should proceed + try { + myPatientDao.delete(new IdType("Patient/P")); + fail(); + } catch (ResourceVersionConflictException e) { + // good + } + + + } + + @Test + public void testCombineWithCascadeDeleteInterceptor() { + try { + myInterceptorRegistry.registerInterceptor(myCascadingDeleteInterceptor); + + mySvc.addPath("AuditEvent.agent.who"); + myInterceptorRegistry.registerInterceptor(mySvc); + + Patient patient = new Patient(); + patient.setId("P"); + patient.setActive(true); + myPatientDao.update(patient); + + AuditEvent audit = new AuditEvent(); + audit.setId("A"); + audit.addAgent().getWho().setReference("Patient/P"); + myAuditEventDao.update(audit); + + // Delete should proceed + myPatientDao.delete(new IdType("Patient/P")); + + } finally { + myInterceptorRegistry.unregisterInterceptor(myCascadingDeleteInterceptor); + } + + } + +}