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}