Disable referential integrity for some paths (#2025)
* Disable referential integrity for some paths * Add changelog * Remove unneeded file
This commit is contained in:
parent
0de0b88aa0
commit
8e0023c385
|
@ -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."
|
|
@ -180,8 +180,24 @@ The ResponseSizeCapturingInterceptor can be used to capture the number of charac
|
||||||
|
|
||||||
# JPA Server: Allow Cascading Deletes
|
# 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.
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
<a name="overridepathbasedreferentialintegrityfordeletesinterceptor"/>
|
||||||
|
|
||||||
|
# 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
|
# 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.
|
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.
|
||||||
|
|
|
@ -34,6 +34,7 @@ import java.util.function.Predicate;
|
||||||
public class DeleteConflictList implements Iterable<DeleteConflict> {
|
public class DeleteConflictList implements Iterable<DeleteConflict> {
|
||||||
private final List<DeleteConflict> myList = new ArrayList<>();
|
private final List<DeleteConflict> myList = new ArrayList<>();
|
||||||
private final Set<String> myResourceIdsMarkedForDeletion;
|
private final Set<String> myResourceIdsMarkedForDeletion;
|
||||||
|
private final Set<String> myResourceIdsToIgnoreConflict;
|
||||||
private int myRemoveModCount;
|
private int myRemoveModCount;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -41,6 +42,7 @@ public class DeleteConflictList implements Iterable<DeleteConflict> {
|
||||||
*/
|
*/
|
||||||
public DeleteConflictList() {
|
public DeleteConflictList() {
|
||||||
myResourceIdsMarkedForDeletion = new HashSet<>();
|
myResourceIdsMarkedForDeletion = new HashSet<>();
|
||||||
|
myResourceIdsToIgnoreConflict = new HashSet<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -49,6 +51,7 @@ public class DeleteConflictList implements Iterable<DeleteConflict> {
|
||||||
*/
|
*/
|
||||||
public DeleteConflictList(DeleteConflictList theParentList) {
|
public DeleteConflictList(DeleteConflictList theParentList) {
|
||||||
myResourceIdsMarkedForDeletion = theParentList.myResourceIdsMarkedForDeletion;
|
myResourceIdsMarkedForDeletion = theParentList.myResourceIdsMarkedForDeletion;
|
||||||
|
myResourceIdsToIgnoreConflict = theParentList.myResourceIdsToIgnoreConflict;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -64,6 +67,18 @@ public class DeleteConflictList implements Iterable<DeleteConflict> {
|
||||||
myResourceIdsMarkedForDeletion.add(theIdType.toUnqualifiedVersionless().getValue());
|
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) {
|
public void add(DeleteConflict theDeleteConflict) {
|
||||||
myList.add(theDeleteConflict);
|
myList.add(theDeleteConflict);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package ca.uhn.fhir.jpa.config;
|
||||||
|
|
||||||
import ca.uhn.fhir.context.FhirContext;
|
import ca.uhn.fhir.context.FhirContext;
|
||||||
import ca.uhn.fhir.i18n.HapiLocalizer;
|
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.api.IInterceptorService;
|
||||||
import ca.uhn.fhir.interceptor.executor.InterceptorService;
|
import ca.uhn.fhir.interceptor.executor.InterceptorService;
|
||||||
import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
|
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.dao.tx.HapiTransactionService;
|
||||||
import ca.uhn.fhir.jpa.entity.Search;
|
import ca.uhn.fhir.jpa.entity.Search;
|
||||||
import ca.uhn.fhir.jpa.graphql.JpaStorageServices;
|
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.JpaConsentContextServices;
|
||||||
|
import ca.uhn.fhir.jpa.interceptor.OverridePathBasedReferentialIntegrityForDeletesInterceptor;
|
||||||
import ca.uhn.fhir.jpa.model.sched.ISchedulerService;
|
import ca.uhn.fhir.jpa.model.sched.ISchedulerService;
|
||||||
import ca.uhn.fhir.jpa.packages.IHapiPackageCacheManager;
|
import ca.uhn.fhir.jpa.packages.IHapiPackageCacheManager;
|
||||||
import ca.uhn.fhir.jpa.packages.IPackageInstallerSvc;
|
import ca.uhn.fhir.jpa.packages.IPackageInstallerSvc;
|
||||||
|
@ -167,6 +170,12 @@ public abstract class BaseConfig {
|
||||||
return new BatchJobSubmitterImpl();
|
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
|
* This method should be overridden to provide an actual completed
|
||||||
* bean, but it provides a partially completed entity manager
|
* bean, but it provides a partially completed entity manager
|
||||||
|
@ -295,6 +304,12 @@ public abstract class BaseConfig {
|
||||||
return new HapiFhirHibernateJpaDialect(fhirContext().getLocalizer());
|
return new HapiFhirHibernateJpaDialect(fhirContext().getLocalizer());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Lazy
|
||||||
|
public OverridePathBasedReferentialIntegrityForDeletesInterceptor overridePathBasedReferentialIntegrityForDeletesInterceptor() {
|
||||||
|
return new OverridePathBasedReferentialIntegrityForDeletesInterceptor();
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public IRequestPartitionHelperSvc requestPartitionHelperService() {
|
public IRequestPartitionHelperSvc requestPartitionHelperService() {
|
||||||
return new RequestPartitionHelperSvc();
|
return new RequestPartitionHelperSvc();
|
||||||
|
|
|
@ -44,27 +44,25 @@ import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class DeleteConflictService {
|
public class DeleteConflictService {
|
||||||
private static final Logger ourLog = LoggerFactory.getLogger(DeleteConflictService.class);
|
|
||||||
public static final int FIRST_QUERY_RESULT_COUNT = 1;
|
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 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.";
|
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
|
@Autowired
|
||||||
DeleteConflictFinderService myDeleteConflictFinderService;
|
DeleteConflictFinderService myDeleteConflictFinderService;
|
||||||
@Autowired
|
@Autowired
|
||||||
DaoConfig myDaoConfig;
|
DaoConfig myDaoConfig;
|
||||||
@Autowired
|
@Autowired
|
||||||
protected IResourceLinkDao myResourceLinkDao;
|
|
||||||
@Autowired
|
|
||||||
private FhirContext myFhirContext;
|
private FhirContext myFhirContext;
|
||||||
@Autowired
|
|
||||||
protected IInterceptorBroadcaster myInterceptorBroadcaster;
|
|
||||||
|
|
||||||
public int validateOkToDelete(DeleteConflictList theDeleteConflicts, ResourceTable theEntity, boolean theForValidate, RequestDetails theRequest, TransactionDetails theTransactionDetails) {
|
public int validateOkToDelete(DeleteConflictList theDeleteConflicts, ResourceTable theEntity, boolean theForValidate, RequestDetails theRequest, TransactionDetails theTransactionDetails) {
|
||||||
|
|
||||||
|
@ -87,9 +85,9 @@ public class DeleteConflictService {
|
||||||
++retryCount;
|
++retryCount;
|
||||||
}
|
}
|
||||||
theDeleteConflicts.addAll(newConflicts);
|
theDeleteConflicts.addAll(newConflicts);
|
||||||
if(retryCount >= MAX_RETRY_ATTEMPTS && !theDeleteConflicts.isEmpty()) {
|
if (retryCount >= MAX_RETRY_ATTEMPTS && !theDeleteConflicts.isEmpty()) {
|
||||||
IBaseOperationOutcome oo = OperationOutcomeUtil.newInstance(myFhirContext);
|
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);
|
throw new ResourceVersionConflictException(MAX_RETRY_ATTEMPTS_EXCEEDED_MSG, oo);
|
||||||
}
|
}
|
||||||
return retryCount;
|
return retryCount;
|
||||||
|
@ -123,7 +121,7 @@ public class DeleteConflictService {
|
||||||
.add(RequestDetails.class, theRequest)
|
.add(RequestDetails.class, theRequest)
|
||||||
.addIfMatchesType(ServletRequestDetails.class, theRequest)
|
.addIfMatchesType(ServletRequestDetails.class, theRequest)
|
||||||
.add(TransactionDetails.class, theTransactionDetails);
|
.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<ResourceLink> theResultList) {
|
private void addConflictsToList(DeleteConflictList theDeleteConflicts, ResourceTable theEntity, List<ResourceLink> theResultList) {
|
||||||
|
@ -142,26 +140,33 @@ public class DeleteConflictService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void validateDeleteConflictsEmptyOrThrowException(FhirContext theFhirContext, DeleteConflictList theDeleteConflicts) {
|
public static void validateDeleteConflictsEmptyOrThrowException(FhirContext theFhirContext, DeleteConflictList theDeleteConflicts) {
|
||||||
if (theDeleteConflicts.isEmpty()) {
|
IBaseOperationOutcome oo = null;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
IBaseOperationOutcome oo = OperationOutcomeUtil.newInstance(theFhirContext);
|
|
||||||
String firstMsg = null;
|
String firstMsg = null;
|
||||||
|
|
||||||
for (DeleteConflict next : theDeleteConflicts) {
|
for (DeleteConflict next : theDeleteConflicts) {
|
||||||
|
|
||||||
|
if (theDeleteConflicts.isResourceIdToIgnoreConflict(next.getTargetId())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
String msg = "Unable to delete " +
|
String msg = "Unable to delete " +
|
||||||
next.getTargetId().toUnqualifiedVersionless().getValue() +
|
next.getTargetId().toUnqualifiedVersionless().getValue() +
|
||||||
" because at least one resource has a reference to this resource. First reference found was resource " +
|
" because at least one resource has a reference to this resource. First reference found was resource " +
|
||||||
next.getSourceId().toUnqualifiedVersionless().getValue() +
|
next.getSourceId().toUnqualifiedVersionless().getValue() +
|
||||||
" in path " +
|
" in path " +
|
||||||
next.getSourcePath();
|
next.getSourcePath();
|
||||||
|
|
||||||
if (firstMsg == null) {
|
if (firstMsg == null) {
|
||||||
firstMsg = msg;
|
firstMsg = msg;
|
||||||
|
oo = OperationOutcomeUtil.newInstance(theFhirContext);
|
||||||
}
|
}
|
||||||
OperationOutcomeUtil.addIssue(theFhirContext, oo, BaseHapiFhirDao.OO_SEVERITY_ERROR, msg, null, "processing");
|
OperationOutcomeUtil.addIssue(theFhirContext, oo, BaseHapiFhirDao.OO_SEVERITY_ERROR, msg, null, "processing");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (firstMsg == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
throw new ResourceVersionConflictException(firstMsg, oo);
|
throw new ResourceVersionConflictException(firstMsg, oo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -71,6 +71,13 @@ import static org.apache.commons.lang3.StringUtils.isNotBlank;
|
||||||
@Interceptor
|
@Interceptor
|
||||||
public class CascadingDeleteInterceptor {
|
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 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_KEY = CascadingDeleteInterceptor.class.getName() + "_CASCADED_DELETES_KEY";
|
||||||
private static final String CASCADED_DELETES_FAILED_KEY = CascadingDeleteInterceptor.class.getName() + "_CASCADED_DELETES_FAILED_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;
|
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) {
|
public DeleteConflictOutcome handleDeleteConflicts(DeleteConflictList theConflictList, RequestDetails theRequest, TransactionDetails theTransactionDetails) {
|
||||||
ourLog.debug("Have delete conflicts: {}", theConflictList);
|
ourLog.debug("Have delete conflicts: {}", theConflictList);
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
* <p>
|
||||||
|
* For example, suppose this interceptor is configured with a path of <code>AuditEvent.entity.what</code>,
|
||||||
|
* and an AuditEvent resource exists in the repository that has a reference in that path to resource
|
||||||
|
* <code>Patient/123</code>. Normally this reference would prevent the Patient resource from being deleted unless
|
||||||
|
* the AuditEvent was first deleted as well (or a <a href="/hapi-fhir/docs/server_jpa/configuration.html#cascading-deletes">cascading delete</a> was used).
|
||||||
|
* With this interceptor in place, the Patient resource could be deleted, and the AuditEvent would remain intact.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@Interceptor
|
||||||
|
public class OverridePathBasedReferentialIntegrityForDeletesInterceptor {
|
||||||
|
|
||||||
|
private static final Logger ourLog = LoggerFactory.getLogger(OverridePathBasedReferentialIntegrityForDeletesInterceptor.class);
|
||||||
|
private final Set<String> 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. <code>AuditEvent.agent.who</code>
|
||||||
|
*/
|
||||||
|
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<String> 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<IBaseReference> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -99,7 +99,7 @@ public class FhirResourceDaoR4DeleteTest extends BaseJpaR4Test {
|
||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testDeleteCircularReferenceInTransaction() throws IOException {
|
public void testDeleteCircularReferenceInTransaction() {
|
||||||
|
|
||||||
// Create two resources with a circular reference
|
// Create two resources with a circular reference
|
||||||
Organization org1 = new Organization();
|
Organization org1 = new Organization();
|
||||||
|
@ -221,4 +221,12 @@ public class FhirResourceDaoR4DeleteTest extends BaseJpaR4Test {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDeleteIgnoreReferentialIntegrityForPaths() {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.interceptor.api.IInterceptorBroadcaster;
|
||||||
import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
|
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.api.Constants;
|
||||||
import ca.uhn.fhir.rest.server.exceptions.ResourceGoneException;
|
import ca.uhn.fhir.rest.server.exceptions.ResourceGoneException;
|
||||||
import ca.uhn.fhir.rest.server.exceptions.ResourceVersionConflictException;
|
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.Patient;
|
||||||
import org.hl7.fhir.r4.model.Reference;
|
import org.hl7.fhir.r4.model.Reference;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.fail;
|
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;
|
private IIdType myDiagnosticReportId;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
@ -42,18 +41,13 @@ public class CascadingDeleteInterceptorR4Test extends BaseResourceProviderR4Test
|
||||||
private IInterceptorBroadcaster myInterceptorBroadcaster;
|
private IInterceptorBroadcaster myInterceptorBroadcaster;
|
||||||
|
|
||||||
private IIdType myPatientId;
|
private IIdType myPatientId;
|
||||||
|
@Autowired
|
||||||
private CascadingDeleteInterceptor myDeleteInterceptor;
|
private CascadingDeleteInterceptor myDeleteInterceptor;
|
||||||
private IIdType myObservationId;
|
private IIdType myObservationId;
|
||||||
private IIdType myConditionId;
|
private IIdType myConditionId;
|
||||||
private IIdType myEncounterId;
|
private IIdType myEncounterId;
|
||||||
|
@Autowired
|
||||||
@Override
|
private OverridePathBasedReferentialIntegrityForDeletesInterceptor myOverridePathBasedReferentialIntegrityForDeletesInterceptor;
|
||||||
@BeforeEach
|
|
||||||
public void before() throws Exception {
|
|
||||||
super.before();
|
|
||||||
|
|
||||||
myDeleteInterceptor = new CascadingDeleteInterceptor(myFhirCtx, myDaoRegistry, myInterceptorBroadcaster);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@AfterEach
|
@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
|
@Test
|
||||||
public void testDeleteCascadingWithCircularReference() throws IOException {
|
public void testDeleteCascadingWithCircularReference() throws IOException {
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue