
001/*- 002 * #%L 003 * HAPI FHIR - Master Data Management 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.mdm.svc; 021 022import ca.uhn.fhir.context.FhirContext; 023import ca.uhn.fhir.context.RuntimeSearchParam; 024import ca.uhn.fhir.fhirpath.IFhirPath; 025import ca.uhn.fhir.i18n.Msg; 026import ca.uhn.fhir.interceptor.model.RequestPartitionId; 027import ca.uhn.fhir.jpa.api.dao.DaoRegistry; 028import ca.uhn.fhir.jpa.api.model.PersistentIdToForcedIdMap; 029import ca.uhn.fhir.jpa.api.svc.IIdHelperService; 030import ca.uhn.fhir.jpa.model.dao.JpaPid; 031import ca.uhn.fhir.mdm.api.MdmMatchResultEnum; 032import ca.uhn.fhir.mdm.dao.IMdmLinkDao; 033import ca.uhn.fhir.mdm.model.MdmPidTuple; 034import ca.uhn.fhir.model.primitive.IdDt; 035import ca.uhn.fhir.rest.api.server.SystemRequestDetails; 036import ca.uhn.fhir.util.ExtensionUtil; 037import ca.uhn.fhir.util.HapiExtensions; 038import ca.uhn.fhir.util.SearchParameterUtil; 039import org.apache.commons.lang3.StringUtils; 040import org.hl7.fhir.instance.model.api.IBaseExtension; 041import org.hl7.fhir.instance.model.api.IBaseReference; 042import org.hl7.fhir.instance.model.api.IBaseResource; 043import org.slf4j.Logger; 044import org.slf4j.LoggerFactory; 045 046import java.util.*; 047 048/** 049 * Implementation of MDM resource expansion for bulk export operations. 050 * Expands group memberships via MDM links and annotates exported resources with golden resource references. 051 */ 052public class BulkExportMdmResourceExpander implements IBulkExportMdmResourceExpander { 053 private static final Logger ourLog = LoggerFactory.getLogger(BulkExportMdmResourceExpander.class); 054 055 private final MdmExpansionCacheSvc myMdmExpansionCacheSvc; 056 private final IMdmLinkDao myMdmLinkDao; 057 private final IIdHelperService<JpaPid> myIdHelperService; 058 private final DaoRegistry myDaoRegistry; 059 private final FhirContext myContext; 060 private IFhirPath myFhirPath; 061 062 public BulkExportMdmResourceExpander( 063 MdmExpansionCacheSvc theMdmExpansionCacheSvc, 064 IMdmLinkDao theMdmLinkDao, 065 IIdHelperService<JpaPid> theIdHelperService, 066 DaoRegistry theDaoRegistry, 067 FhirContext theFhirContext) { 068 myMdmExpansionCacheSvc = theMdmExpansionCacheSvc; 069 myMdmLinkDao = theMdmLinkDao; 070 myIdHelperService = theIdHelperService; 071 myDaoRegistry = theDaoRegistry; 072 myContext = theFhirContext; 073 } 074 075 @Override 076 public Set<JpaPid> expandGroup(String theGroupResourceId, RequestPartitionId theRequestPartitionId) { 077 IdDt groupId = new IdDt(theGroupResourceId); 078 SystemRequestDetails requestDetails = new SystemRequestDetails(); 079 requestDetails.setRequestPartitionId(theRequestPartitionId); 080 IBaseResource group = myDaoRegistry.getResourceDao("Group").read(groupId, requestDetails); 081 JpaPid pidOrNull = myIdHelperService.getPidOrNull(theRequestPartitionId, group); 082 // Attempt to perform MDM Expansion of membership 083 return performMembershipExpansionViaMdmTable(pidOrNull); 084 } 085 086 @SuppressWarnings({"rawtypes", "unchecked"}) 087 private Set<JpaPid> performMembershipExpansionViaMdmTable(JpaPid pidOrNull) { 088 List<MdmPidTuple<JpaPid>> goldenPidTargetPidTuples = 089 myMdmLinkDao.expandPidsFromGroupPidGivenMatchResult(pidOrNull, MdmMatchResultEnum.MATCH); 090 091 Set<JpaPid> uniquePids = new HashSet<>(); 092 goldenPidTargetPidTuples.forEach(tuple -> { 093 uniquePids.add(tuple.getGoldenPid()); 094 uniquePids.add(tuple.getSourcePid()); 095 }); 096 populateMdmResourceCache(goldenPidTargetPidTuples); 097 return uniquePids; 098 } 099 100 /** 101 * @param thePidTuples 102 */ 103 @SuppressWarnings({"unchecked", "rawtypes"}) 104 private void populateMdmResourceCache(List<MdmPidTuple<JpaPid>> thePidTuples) { 105 if (myMdmExpansionCacheSvc.hasBeenPopulated()) { 106 return; 107 } 108 // First, convert this zipped set of tuples to a map of 109 // { 110 // patient/gold-1 -> [patient/1, patient/2] 111 // patient/gold-2 -> [patient/3, patient/4] 112 // } 113 Map<JpaPid, Set<JpaPid>> goldenResourceToSourcePidMap = new HashMap<>(); 114 extract(thePidTuples, goldenResourceToSourcePidMap); 115 116 // Next, lets convert it to an inverted index for fast lookup 117 // { 118 // patient/1 -> patient/gold-1 119 // patient/2 -> patient/gold-1 120 // patient/3 -> patient/gold-2 121 // patient/4 -> patient/gold-2 122 // } 123 Map<String, String> sourceResourceIdToGoldenResourceIdMap = new HashMap<>(); 124 goldenResourceToSourcePidMap.forEach((key, value) -> { 125 String goldenResourceId = 126 myIdHelperService.translatePidIdToForcedIdWithCache(key).orElse(key.toString()); 127 PersistentIdToForcedIdMap pidsToForcedIds = myIdHelperService.translatePidsToForcedIds(value); 128 129 Set<String> sourceResourceIds = pidsToForcedIds.getResolvedResourceIds(); 130 131 sourceResourceIds.forEach( 132 sourceResourceId -> sourceResourceIdToGoldenResourceIdMap.put(sourceResourceId, goldenResourceId)); 133 }); 134 135 // Now that we have built our cached expansion, store it. 136 myMdmExpansionCacheSvc.setCacheContents(sourceResourceIdToGoldenResourceIdMap); 137 } 138 139 private void extract( 140 List<MdmPidTuple<JpaPid>> theGoldenPidTargetPidTuples, 141 Map<JpaPid, Set<JpaPid>> theGoldenResourceToSourcePidMap) { 142 for (MdmPidTuple<JpaPid> goldenPidTargetPidTuple : theGoldenPidTargetPidTuples) { 143 JpaPid goldenPid = goldenPidTargetPidTuple.getGoldenPid(); 144 JpaPid sourcePid = goldenPidTargetPidTuple.getSourcePid(); 145 theGoldenResourceToSourcePidMap 146 .computeIfAbsent(goldenPid, key -> new HashSet<>()) 147 .add(sourcePid); 148 } 149 } 150 151 private RuntimeSearchParam getRuntimeSearchParam(IBaseResource theResource) { 152 Optional<RuntimeSearchParam> oPatientSearchParam = 153 SearchParameterUtil.getOnlyPatientSearchParamForResourceType(myContext, theResource.fhirType()); 154 if (!oPatientSearchParam.isPresent()) { 155 String errorMessage = String.format( 156 "[%s] has no search parameters that are for patients, so it is invalid for Group Bulk Export!", 157 theResource.fhirType()); 158 throw new IllegalArgumentException(Msg.code(2242) + errorMessage); 159 } else { 160 return oPatientSearchParam.get(); 161 } 162 } 163 164 @Override 165 public void annotateResource(IBaseResource iBaseResource) { 166 Optional<String> patientReference = getPatientReference(iBaseResource); 167 if (patientReference.isPresent()) { 168 addGoldenResourceExtension(iBaseResource, patientReference.get()); 169 } else { 170 ourLog.error( 171 "Failed to find the patient reference information for resource {}. This is a bug, " 172 + "as all resources which can be exported via Group Bulk Export must reference a patient.", 173 iBaseResource); 174 } 175 } 176 177 private Optional<String> getPatientReference(IBaseResource iBaseResource) { 178 String fhirPath; 179 180 RuntimeSearchParam runtimeSearchParam = getRuntimeSearchParam(iBaseResource); 181 fhirPath = getPatientFhirPath(runtimeSearchParam); 182 183 if (iBaseResource.fhirType().equalsIgnoreCase("Patient")) { 184 return Optional.of(iBaseResource.getIdElement().getIdPart()); 185 } else { 186 Optional<IBaseReference> optionalReference = 187 getFhirParser().evaluateFirst(iBaseResource, fhirPath, IBaseReference.class); 188 if (optionalReference.isPresent()) { 189 return optionalReference.map(theIBaseReference -> 190 theIBaseReference.getReferenceElement().getIdPart()); 191 } else { 192 return Optional.empty(); 193 } 194 } 195 } 196 197 private void addGoldenResourceExtension(IBaseResource iBaseResource, String sourceResourceId) { 198 String goldenResourceId = myMdmExpansionCacheSvc.getGoldenResourceId(sourceResourceId); 199 IBaseExtension<?, ?> extension = ExtensionUtil.getOrCreateExtension( 200 iBaseResource, HapiExtensions.ASSOCIATED_GOLDEN_RESOURCE_EXTENSION_URL); 201 if (!StringUtils.isBlank(goldenResourceId)) { 202 ExtensionUtil.setExtension(myContext, extension, "reference", prefixPatient(goldenResourceId)); 203 } 204 } 205 206 private String prefixPatient(String theResourceId) { 207 return "Patient/" + theResourceId; 208 } 209 210 private IFhirPath getFhirParser() { 211 if (myFhirPath == null) { 212 myFhirPath = myContext.newFhirPath(); 213 } 214 return myFhirPath; 215 } 216 217 private String getPatientFhirPath(RuntimeSearchParam theRuntimeParam) { 218 String path = theRuntimeParam.getPath(); 219 // GGG: Yes this is a stupid hack, but by default this runtime search param will return stuff like 220 // Observation.subject.where(resolve() is Patient) which unfortunately our FHIRpath evaluator doesn't play 221 // nicely with 222 // our FHIRPath evaluator. 223 if (path.contains(".where")) { 224 path = path.substring(0, path.indexOf(".where")); 225 } 226 return path; 227 } 228}