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}