
001/*- 002 * #%L 003 * HAPI FHIR JPA Server 004 * %% 005 * Copyright (C) 2014 - 2026 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.bulk.export.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.mdm.svc.IBulkExportMdmFullResourceExpander; 035import ca.uhn.fhir.mdm.svc.MdmExpansionCacheSvc; 036import ca.uhn.fhir.model.primitive.IdDt; 037import ca.uhn.fhir.rest.api.server.SystemRequestDetails; 038import ca.uhn.fhir.util.ExtensionUtil; 039import ca.uhn.fhir.util.HapiExtensions; 040import ca.uhn.fhir.util.SearchParameterUtil; 041import org.apache.commons.lang3.StringUtils; 042import org.hl7.fhir.instance.model.api.IBaseExtension; 043import org.hl7.fhir.instance.model.api.IBaseReference; 044import org.hl7.fhir.instance.model.api.IBaseResource; 045import org.slf4j.Logger; 046import org.slf4j.LoggerFactory; 047 048import java.util.ArrayList; 049import java.util.HashMap; 050import java.util.HashSet; 051import java.util.List; 052import java.util.Map; 053import java.util.Optional; 054import java.util.Set; 055 056/** 057 * Implementation of MDM resource expansion for bulk export operations. 058 * Expands group memberships via MDM links and annotates exported resources with golden resource references. 059 */ 060public class BulkExportMdmFullResourceExpander implements IBulkExportMdmFullResourceExpander<JpaPid> { 061 private static final Logger ourLog = LoggerFactory.getLogger(BulkExportMdmFullResourceExpander.class); 062 063 private final MdmExpansionCacheSvc myMdmExpansionCacheSvc; 064 private final IMdmLinkDao myMdmLinkDao; 065 private final IIdHelperService<JpaPid> myIdHelperService; 066 private final DaoRegistry myDaoRegistry; 067 private final FhirContext myContext; 068 private IFhirPath myFhirPath; 069 070 public BulkExportMdmFullResourceExpander( 071 MdmExpansionCacheSvc theMdmExpansionCacheSvc, 072 IMdmLinkDao theMdmLinkDao, 073 IIdHelperService<JpaPid> theIdHelperService, 074 DaoRegistry theDaoRegistry, 075 FhirContext theFhirContext) { 076 myMdmExpansionCacheSvc = theMdmExpansionCacheSvc; 077 myMdmLinkDao = theMdmLinkDao; 078 myIdHelperService = theIdHelperService; 079 myDaoRegistry = theDaoRegistry; 080 myContext = theFhirContext; 081 } 082 083 @Override 084 public Set<JpaPid> expandGroup(String theGroupResourceId, RequestPartitionId theRequestPartitionId) { 085 IdDt groupId = new IdDt(theGroupResourceId); 086 SystemRequestDetails requestDetails = new SystemRequestDetails(); 087 requestDetails.setRequestPartitionId(theRequestPartitionId); 088 IBaseResource group = myDaoRegistry.getResourceDao("Group").read(groupId, requestDetails); 089 JpaPid pidOrNull = myIdHelperService.getPidOrNull(theRequestPartitionId, group); 090 // Attempt to perform MDM Expansion of membership 091 return performMembershipExpansionViaMdmTable(pidOrNull); 092 } 093 094 @Override 095 public Set<String> expandPatient(String thePatientId, RequestPartitionId theRequestPartitionId) { 096 Set<String> expandedPatientIdsAsString = new HashSet<>(); 097 Set<JpaPid> expandedPatientJpaPids = new HashSet<>(); 098 099 SystemRequestDetails requestDetails = new SystemRequestDetails(); 100 requestDetails.setRequestPartitionId(theRequestPartitionId); 101 102 IdDt patientIdDt = new IdDt(thePatientId); 103 104 IBaseResource patient; 105 try { 106 patient = myDaoRegistry.getResourceDao("Patient").read(patientIdDt, requestDetails); 107 } catch (Exception e) { 108 ourLog.warn("Failed to read patient {} for MDM expansion: {}", thePatientId, e.getMessage()); 109 return expandedPatientIdsAsString; 110 } 111 112 JpaPid patientPid = myIdHelperService.getPidOrNull(theRequestPartitionId, patient); 113 if (patientPid == null) { 114 ourLog.warn("Failed to resolve PID for patient {}", thePatientId); 115 return expandedPatientIdsAsString; 116 } 117 118 List<MdmPidTuple<JpaPid>> allLinkedPatients = 119 myMdmLinkDao.expandPidsBySourcePidAndMatchResult(patientPid, MdmMatchResultEnum.MATCH); 120 121 if (allLinkedPatients.isEmpty()) { 122 expandedPatientJpaPids.add(patientPid); 123 ourLog.debug("Patient {} has no MDM links, including only this patient", thePatientId); 124 } else { 125 126 ourLog.debug("Patient {} expanded to {} linked patients", thePatientId, allLinkedPatients.size()); 127 List<MdmPidTuple<JpaPid>> goldenPidSourcePidTuples = new ArrayList<>(allLinkedPatients); 128 129 populateMdmResourceCache(goldenPidSourcePidTuples); 130 131 for (MdmPidTuple<JpaPid> tuple : goldenPidSourcePidTuples) { 132 expandedPatientJpaPids.add(tuple.getGoldenPid()); 133 expandedPatientJpaPids.add(tuple.getSourcePid()); 134 } 135 } 136 137 for (JpaPid pid : expandedPatientJpaPids) { 138 String patientIdString; 139 140 Optional<String> forcedIdOpt = myIdHelperService.translatePidIdToForcedIdWithCache(pid); 141 patientIdString = forcedIdOpt.orElse("Patient/" + pid.getId().toString()); 142 143 expandedPatientIdsAsString.add(patientIdString); 144 } 145 146 ourLog.debug( 147 "Expanded patient {} to {} total patient IDs via MDM", thePatientId, expandedPatientIdsAsString.size()); 148 return expandedPatientIdsAsString; 149 } 150 151 @SuppressWarnings({"rawtypes", "unchecked"}) 152 private Set<JpaPid> performMembershipExpansionViaMdmTable(JpaPid pidOrNull) { 153 List<MdmPidTuple<JpaPid>> goldenPidTargetPidTuples = 154 myMdmLinkDao.expandPidsFromGroupPidGivenMatchResult(pidOrNull, MdmMatchResultEnum.MATCH); 155 156 Set<JpaPid> uniquePids = new HashSet<>(); 157 goldenPidTargetPidTuples.forEach(tuple -> { 158 uniquePids.add(tuple.getGoldenPid()); 159 uniquePids.add(tuple.getSourcePid()); 160 }); 161 populateMdmResourceCache(goldenPidTargetPidTuples); 162 return uniquePids; 163 } 164 165 /** 166 * @param thePidTuples 167 */ 168 @SuppressWarnings({"unchecked", "rawtypes"}) 169 private void populateMdmResourceCache(List<MdmPidTuple<JpaPid>> thePidTuples) { 170 if (myMdmExpansionCacheSvc.hasBeenPopulated()) { 171 return; 172 } 173 // First, convert this zipped set of tuples to a map of 174 // { 175 // patient/gold-1 -> [patient/1, patient/2] 176 // patient/gold-2 -> [patient/3, patient/4] 177 // } 178 Map<JpaPid, Set<JpaPid>> goldenResourceToSourcePidMap = new HashMap<>(); 179 extract(thePidTuples, goldenResourceToSourcePidMap); 180 181 // Next, lets convert it to an inverted index for fast lookup 182 // { 183 // patient/1 -> patient/gold-1 184 // patient/2 -> patient/gold-1 185 // patient/3 -> patient/gold-2 186 // patient/4 -> patient/gold-2 187 // } 188 Map<String, String> sourceResourceIdToGoldenResourceIdMap = new HashMap<>(); 189 goldenResourceToSourcePidMap.forEach((key, value) -> { 190 String goldenResourceId = 191 myIdHelperService.translatePidIdToForcedIdWithCache(key).orElse(key.toString()); 192 PersistentIdToForcedIdMap pidsToForcedIds = myIdHelperService.translatePidsToForcedIds(value); 193 194 Set<String> sourceResourceIds = pidsToForcedIds.getResolvedResourceIds(); 195 196 sourceResourceIds.forEach( 197 sourceResourceId -> sourceResourceIdToGoldenResourceIdMap.put(sourceResourceId, goldenResourceId)); 198 }); 199 200 // Now that we have built our cached expansion, store it. 201 myMdmExpansionCacheSvc.setCacheContents(sourceResourceIdToGoldenResourceIdMap); 202 } 203 204 private void extract( 205 List<MdmPidTuple<JpaPid>> theGoldenPidTargetPidTuples, 206 Map<JpaPid, Set<JpaPid>> theGoldenResourceToSourcePidMap) { 207 for (MdmPidTuple<JpaPid> goldenPidTargetPidTuple : theGoldenPidTargetPidTuples) { 208 JpaPid goldenPid = goldenPidTargetPidTuple.getGoldenPid(); 209 JpaPid sourcePid = goldenPidTargetPidTuple.getSourcePid(); 210 theGoldenResourceToSourcePidMap 211 .computeIfAbsent(goldenPid, key -> new HashSet<>()) 212 .add(sourcePid); 213 } 214 } 215 216 private RuntimeSearchParam getRuntimeSearchParam(IBaseResource theResource) { 217 Optional<RuntimeSearchParam> oPatientSearchParam = 218 SearchParameterUtil.getOnlyPatientSearchParamForResourceType(myContext, theResource.fhirType()); 219 if (oPatientSearchParam.isEmpty()) { 220 String errorMessage = String.format( 221 "[%s] has no search parameters that are for patients, so it is invalid for Group Bulk Export!", 222 theResource.fhirType()); 223 throw new IllegalArgumentException(Msg.code(2242) + errorMessage); 224 } else { 225 return oPatientSearchParam.get(); 226 } 227 } 228 229 @Override 230 public void annotateResource(IBaseResource iBaseResource) { 231 Optional<String> patientReference = getPatientReference(iBaseResource); 232 if (patientReference.isPresent()) { 233 addGoldenResourceExtension(iBaseResource, patientReference.get()); 234 } else { 235 ourLog.error( 236 "Failed to find the patient reference information for resource {}. This is a bug, " 237 + "as all resources which can be exported via Group Bulk Export must reference a patient.", 238 iBaseResource); 239 } 240 } 241 242 private Optional<String> getPatientReference(IBaseResource iBaseResource) { 243 String fhirPath; 244 245 RuntimeSearchParam runtimeSearchParam = getRuntimeSearchParam(iBaseResource); 246 fhirPath = getPatientFhirPath(runtimeSearchParam); 247 248 if (iBaseResource.fhirType().equalsIgnoreCase("Patient")) { 249 return Optional.of(iBaseResource.getIdElement().getIdPart()); 250 } else { 251 Optional<IBaseReference> optionalReference = 252 getFhirParser().evaluateFirst(iBaseResource, fhirPath, IBaseReference.class); 253 if (optionalReference.isPresent()) { 254 return optionalReference.map(theIBaseReference -> theIBaseReference 255 .getReferenceElement() 256 .toUnqualifiedVersionless() 257 .toString()); 258 } else { 259 return Optional.empty(); 260 } 261 } 262 } 263 264 private void addGoldenResourceExtension(IBaseResource iBaseResource, String sourceResourceId) { 265 // EHP: reimplement this, it is currently completely broken given the distributed nature of the job. 266 String goldenResourceId = ""; // TODO we must be able to fetch this, for now, will be no-op 267 if (!StringUtils.isBlank(goldenResourceId)) { 268 IBaseExtension<?, ?> extension = ExtensionUtil.getOrCreateExtension( 269 iBaseResource, HapiExtensions.ASSOCIATED_GOLDEN_RESOURCE_EXTENSION_URL); 270 ExtensionUtil.setExtension(myContext, extension, "reference", goldenResourceId); 271 } 272 } 273 274 private IFhirPath getFhirParser() { 275 if (myFhirPath == null) { 276 myFhirPath = myContext.newFhirPath(); 277 } 278 return myFhirPath; 279 } 280 281 private String getPatientFhirPath(RuntimeSearchParam theRuntimeParam) { 282 String path = theRuntimeParam.getPath(); 283 // GGG: Yes this is a stupid hack, but by default this runtime search param will return stuff like 284 // Observation.subject.where(resolve() is Patient) which unfortunately our FHIRpath evaluator doesn't play 285 // nicely with 286 // our FHIRPath evaluator. 287 if (path.contains(".where")) { 288 path = path.substring(0, path.indexOf(".where")); 289 } 290 return path; 291 } 292}