diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/JpaConfig.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/JpaConfig.java index b889b27e10f..bb0e4964e9d 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/JpaConfig.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/JpaConfig.java @@ -97,8 +97,10 @@ import ca.uhn.fhir.jpa.partition.PartitionLookupSvcImpl; import ca.uhn.fhir.jpa.partition.PartitionManagementProvider; import ca.uhn.fhir.jpa.partition.RequestPartitionHelperSvc; import ca.uhn.fhir.jpa.provider.DiffProvider; +import ca.uhn.fhir.jpa.provider.IReplaceReferencesSvc; import ca.uhn.fhir.jpa.provider.InstanceReindexProvider; import ca.uhn.fhir.jpa.provider.ProcessMessageProvider; +import ca.uhn.fhir.jpa.provider.ReplaceReferencesSvcImpl; import ca.uhn.fhir.jpa.provider.SubscriptionTriggeringProvider; import ca.uhn.fhir.jpa.provider.TerminologyUploaderProvider; import ca.uhn.fhir.jpa.provider.ValueSetOperationProvider; @@ -926,4 +928,9 @@ public class JpaConfig { ITagDefinitionDao tagDefinitionDao, MemoryCacheService memoryCacheService) { return new CacheTagDefinitionDao(tagDefinitionDao, memoryCacheService); } + + @Bean + public IReplaceReferencesSvc replaceReferencesSvc(FhirContext theFhirContext, DaoRegistry theDaoRegistry) { + return new ReplaceReferencesSvcImpl(theFhirContext, theDaoRegistry); + } } diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/BaseJpaSystemProvider.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/BaseJpaSystemProvider.java index 41f8161e8a5..1bd5cea10e6 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/BaseJpaSystemProvider.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/BaseJpaSystemProvider.java @@ -69,6 +69,9 @@ public abstract class BaseJpaSystemProvider extends BaseStorageSystemProv @Autowired private ITermReadSvc myTermReadSvc; + @Autowired + private IReplaceReferencesSvc myReplaceReferencesSvc; + public BaseJpaSystemProvider() { // nothing } @@ -77,6 +80,10 @@ public abstract class BaseJpaSystemProvider extends BaseStorageSystemProv return myResourceReindexingSvc; } + public IReplaceReferencesSvc getReplaceReferencesSvc() { + return myReplaceReferencesSvc; + } + @History public IBundleProvider historyServer( HttpServletRequest theRequest, diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/IReplaceReferencesSvc.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/IReplaceReferencesSvc.java new file mode 100644 index 00000000000..db1a19450d4 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/IReplaceReferencesSvc.java @@ -0,0 +1,12 @@ +package ca.uhn.fhir.jpa.provider; + +import ca.uhn.fhir.rest.api.server.RequestDetails; +import org.hl7.fhir.instance.model.api.IBaseParameters; + +/** + * Contract for service which replaces references + */ +public interface IReplaceReferencesSvc { + + IBaseParameters replaceReferences(String theSourceRefId, String theTargetRefId, RequestDetails theRequest); +} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/JpaSystemProvider.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/JpaSystemProvider.java index 7a3287183e5..9954a7e7c7d 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/JpaSystemProvider.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/JpaSystemProvider.java @@ -141,4 +141,17 @@ public final class JpaSystemProvider extends BaseJpaSystemProvider endRequest(((ServletRequestDetails) theRequestDetails).getServletRequest()); } } + + @Operation(name = ProviderConstants.OPERATION_REPLACE_REFERENCES, global = true) + @Description( + value = + "This operation searches for all references matching the provided id and updates them to references to the provided newReferenceTargetId.", + shortDefinition = "Repoints referencing resources to another resources instance") + public IBaseParameters replaceReferences( + @OperationParam(name = ProviderConstants.PARAM_SOURCE_REFERENCE_ID) String theSourceId, + @OperationParam(name = ProviderConstants.PARAM_TARGET_REFERENCE_ID) String theTargetId, + RequestDetails theRequest) { + + return getReplaceReferencesSvc().replaceReferences(theSourceId, theTargetId, theRequest); + } } diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/ReplaceReferencesSvcImpl.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/ReplaceReferencesSvcImpl.java new file mode 100644 index 00000000000..3a183069193 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/provider/ReplaceReferencesSvcImpl.java @@ -0,0 +1,221 @@ +package ca.uhn.fhir.jpa.provider; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.i18n.Msg; +import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; +import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; +import ca.uhn.fhir.model.api.Include; +import ca.uhn.fhir.model.primitive.IdDt; +import ca.uhn.fhir.rest.api.MethodOutcome; +import ca.uhn.fhir.rest.api.PatchTypeEnum; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.param.StringParam; +import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; +import ca.uhn.fhir.util.ResourceReferenceInfo; +import jakarta.annotation.Nonnull; +import org.hl7.fhir.instance.model.api.IBaseParameters; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.r4.model.CodeType; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Reference; +import org.hl7.fhir.r4.model.Resource; +import org.hl7.fhir.r4.model.StringType; +import org.hl7.fhir.r4.model.Type; + +import java.security.InvalidParameterException; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static ca.uhn.fhir.jpa.patch.FhirPatch.OPERATION_REPLACE; +import static ca.uhn.fhir.jpa.patch.FhirPatch.PARAMETER_OPERATION; +import static ca.uhn.fhir.jpa.patch.FhirPatch.PARAMETER_PATH; +import static ca.uhn.fhir.jpa.patch.FhirPatch.PARAMETER_TYPE; +import static ca.uhn.fhir.jpa.patch.FhirPatch.PARAMETER_VALUE; +import static ca.uhn.fhir.rest.api.Constants.PARAM_ID; +import static ca.uhn.fhir.rest.server.provider.ProviderConstants.PARAM_SOURCE_REFERENCE_ID; +import static ca.uhn.fhir.rest.server.provider.ProviderConstants.PARAM_TARGET_REFERENCE_ID; +import static software.amazon.awssdk.utils.StringUtils.isBlank; + +public class ReplaceReferencesSvcImpl implements IReplaceReferencesSvc { + + private final FhirContext myFhirContext; + private final DaoRegistry myDaoRegistry; + + public ReplaceReferencesSvcImpl(FhirContext theFhirContext, DaoRegistry theDaoRegistry) { + myFhirContext = theFhirContext; + myDaoRegistry = theDaoRegistry; + } + + @Override + public IBaseParameters replaceReferences(String theSourceRefId, String theTargetRefId, RequestDetails theRequest) { + + validateParameters(theSourceRefId, theTargetRefId); + IIdType sourceRefId = new IdDt(theSourceRefId); + IIdType targetRefId = new IdDt(theTargetRefId); + + // todo jm: this could be problematic depending on referenceing object set size, however we are adding + // batch job option to handle that case as part of this feature + List referencingResources = findReferencingResourceIds(sourceRefId, theRequest); + + return replaceReferencesInTransaction(referencingResources, sourceRefId, targetRefId, theRequest); + } + + private IBaseParameters replaceReferencesInTransaction( + List theReferencingResources, + IIdType theCurrentTargetId, + IIdType theNewTargetId, + RequestDetails theRequest) { + + Parameters resultParams = new Parameters(); + // map resourceType -> map resourceId -> patch Parameters + Map> parametersMap = + buildPatchParameterMap(theReferencingResources, theCurrentTargetId, theNewTargetId); + + for (Map.Entry> mapEntry : parametersMap.entrySet()) { + String resourceType = mapEntry.getKey(); + IFhirResourceDao resDao = myDaoRegistry.getResourceDao(resourceType); + if (resDao == null) { + throw new InternalErrorException( + Msg.code(2588) + "No DAO registered for resource type: " + resourceType); + } + + // patch each resource of resourceType + patchResourceTypeResources(mapEntry, resDao, resultParams, theRequest); + } + + return resultParams; + } + + private void patchResourceTypeResources( + Map.Entry> mapEntry, + IFhirResourceDao resDao, + Parameters resultParams, + RequestDetails theRequest) { + + for (Map.Entry idParamMapEntry : + mapEntry.getValue().entrySet()) { + IIdType resourceId = idParamMapEntry.getKey(); + Parameters parameters = idParamMapEntry.getValue(); + + MethodOutcome result = + resDao.patch(resourceId, null, PatchTypeEnum.FHIR_PATCH_JSON, null, parameters, theRequest); + + resultParams.addParameter().setResource((Resource) result.getOperationOutcome()); + } + } + + private Map> buildPatchParameterMap( + List theReferencingResources, + IIdType theCurrentReferencedResourceId, + IIdType theNewReferencedResourceId) { + Map> paramsMap = new HashMap<>(); + + for (IBaseResource referencingResource : theReferencingResources) { + // resource can have more than one reference to the same target resource + for (ResourceReferenceInfo refInfo : + myFhirContext.newTerser().getAllResourceReferences(referencingResource)) { + + addReferenceToMapIfForSource( + theCurrentReferencedResourceId, + theNewReferencedResourceId, + referencingResource, + refInfo, + paramsMap); + } + } + return paramsMap; + } + + private void addReferenceToMapIfForSource( + IIdType theCurrentReferencedResourceId, + IIdType theNewReferencedResourceId, + IBaseResource referencingResource, + ResourceReferenceInfo refInfo, + Map> paramsMap) { + if (!refInfo.getResourceReference() + .getReferenceElement() + .toUnqualifiedVersionless() + .getValueAsString() + .equals(theCurrentReferencedResourceId + .toUnqualifiedVersionless() + .getValueAsString())) { + + // not a reference to the resource being replaced + return; + } + + Parameters.ParametersParameterComponent paramComponent = createReplaceReferencePatchOperation( + referencingResource.fhirType() + "." + refInfo.getName(), + new Reference( + theNewReferencedResourceId.toUnqualifiedVersionless().getValueAsString())); + + paramsMap + // preserve order, in case it could matter + .computeIfAbsent(referencingResource.fhirType(), k -> new LinkedHashMap<>()) + .computeIfAbsent(referencingResource.getIdElement(), k -> new Parameters()) + .addParameter(paramComponent); + } + + @Nonnull + private Parameters.ParametersParameterComponent createReplaceReferencePatchOperation( + String thePath, Type theValue) { + + Parameters.ParametersParameterComponent operation = new Parameters.ParametersParameterComponent(); + operation.setName(PARAMETER_OPERATION); + operation.addPart().setName(PARAMETER_TYPE).setValue(new CodeType(OPERATION_REPLACE)); + operation.addPart().setName(PARAMETER_PATH).setValue(new StringType(thePath)); + operation.addPart().setName(PARAMETER_VALUE).setValue(theValue); + return operation; + } + + private List findReferencingResourceIds( + IIdType theSourceRefIdParam, RequestDetails theRequest) { + IFhirResourceDao dao = getDao(theSourceRefIdParam.getResourceType()); + if (dao == null) { + throw new InternalErrorException( + Msg.code(2582) + "Couldn't obtain DAO for resource type" + theSourceRefIdParam.getResourceType()); + } + + SearchParameterMap parameterMap = new SearchParameterMap(); + parameterMap.add(PARAM_ID, new StringParam(theSourceRefIdParam.getValue())); + parameterMap.addRevInclude(new Include("*")); + return dao.search(parameterMap, theRequest).getAllResources(); + } + + private IFhirResourceDao getDao(String theResourceName) { + return myDaoRegistry.getResourceDao(theResourceName); + } + + private void validateParameters(String theSourceRefIdParam, String theTargetRefIdParam) { + if (isBlank(theSourceRefIdParam)) { + throw new InvalidParameterException( + Msg.code(2583) + "Parameter '" + PARAM_SOURCE_REFERENCE_ID + "' is blank"); + } + + if (isBlank(theTargetRefIdParam)) { + throw new InvalidParameterException( + Msg.code(2584) + "Parameter '" + PARAM_TARGET_REFERENCE_ID + "' is blank"); + } + + IIdType sourceId = new IdDt(theSourceRefIdParam); + if (isBlank(sourceId.getResourceType())) { + throw new InvalidParameterException( + Msg.code(2585) + "'" + PARAM_SOURCE_REFERENCE_ID + "' must be a resource type qualified id"); + } + + IIdType targetId = new IdDt(theTargetRefIdParam); + if (isBlank(targetId.getResourceType())) { + throw new InvalidParameterException( + Msg.code(2586) + "'" + PARAM_TARGET_REFERENCE_ID + "' must be a resource type qualified id"); + } + + if (!targetId.getResourceType().equals(sourceId.getResourceType())) { + throw new InvalidParameterException( + Msg.code(2587) + "Source and target id parameters must be for the same resource type"); + } + } +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/provider/ProviderConstants.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/provider/ProviderConstants.java index 9982588e513..a0df066bade 100644 --- a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/provider/ProviderConstants.java +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/provider/ProviderConstants.java @@ -239,8 +239,24 @@ public class ProviderConstants { * Operation name for the "$export-poll-status" operation */ public static final String OPERATION_EXPORT_POLL_STATUS = "$export-poll-status"; + /** * Operation name for the "$export" operation */ public static final String OPERATION_EXPORT = "$export"; + + /** + * Operation name for the "$replace-references" operation + */ + public static final String OPERATION_REPLACE_REFERENCES = "$replace-references"; + + /** + * Parameter for source reference of the "$replace-references" operation + */ + public static final String PARAM_SOURCE_REFERENCE_ID = "sourceReferenceId"; + + /** + * Parameter for target reference of the "$replace-references" operation + */ + public static final String PARAM_TARGET_REFERENCE_ID = "targetReferenceId"; }