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.interceptor.model.RequestPartitionId;
024import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc;
025import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
026import ca.uhn.fhir.mdm.api.IMdmLinkExpandSvc;
027import ca.uhn.fhir.mdm.log.Logs;
028import ca.uhn.fhir.model.api.IQueryParameterType;
029import ca.uhn.fhir.rest.api.server.RequestDetails;
030import ca.uhn.fhir.rest.param.BaseParam;
031import ca.uhn.fhir.rest.param.ReferenceParam;
032import ca.uhn.fhir.rest.param.TokenParam;
033import jakarta.annotation.Nonnull;
034import jakarta.annotation.Nullable;
035import org.hl7.fhir.instance.model.api.IAnyResource;
036import org.hl7.fhir.instance.model.api.IIdType;
037import org.slf4j.Logger;
038import org.springframework.beans.factory.annotation.Autowired;
039
040import java.util.ArrayList;
041import java.util.List;
042import java.util.Map;
043import java.util.Objects;
044import java.util.Set;
045
046import static org.apache.commons.lang3.StringUtils.isBlank;
047
048public class MdmSearchExpansionSvc {
049        private static final String EXPANSION_RESULTS = MdmSearchExpansionSvc.class.getName() + "_EXPANSION_RESULTS";
050        private static final String RESOURCE_NAME = MdmSearchExpansionSvc.class.getName() + "_RESOURCE_NAME";
051        private static final String QUERY_STRING = MdmSearchExpansionSvc.class.getName() + "_QUERY_STRING";
052        private static final Logger ourLog = Logs.getMdmTroubleshootingLog();
053
054        @Autowired
055        private FhirContext myFhirContext;
056
057        @Autowired
058        private IRequestPartitionHelperSvc myRequestPartitionHelperSvc;
059
060        @Autowired
061        private MdmExpandersHolder myMdmExpandersHolder;
062
063        /**
064         * This method looks through all the reference parameters within a {@link SearchParameterMap}
065         * and performs MDM expansion. This means looking for any subject/patient parameters, and
066         * expanding them to include any linked and golden resource patients. So, for example, a
067         * search for <code>Encounter?subject=Patient/1</code> might be modified to be a search
068         * for <code>Encounter?subject=Patient/1,Patient/999</code> if 999 is linked to 1 by MDM.
069         * <p>
070         * This is an internal MDM service and its API is subject to change. Use with caution!
071         * </p>
072         *
073         * @param theRequestDetails     The incoming request details
074         * @param theSearchParameterMap The parameter map to modify
075         * @param theParamTester        Determines which parameters should be expanded
076         * @return Returns the results of the expansion, which are also stored in the {@link RequestDetails} userdata map, so this service will only be invoked a maximum of once per request.
077         * @since 8.0.0
078         */
079        public MdmSearchExpansionResults expandSearchAndStoreInRequestDetails(
080                        String theResourceName,
081                        @Nullable RequestDetails theRequestDetails,
082                        @Nonnull SearchParameterMap theSearchParameterMap,
083                        IParamTester theParamTester) {
084
085                if (theRequestDetails == null) {
086                        return null;
087                }
088
089                // Try to detect if the RequestDetails is being reused across multiple different queries, which
090                // can happen during CQL measure evaluation
091                {
092                        String resourceName = theRequestDetails.getResourceName();
093                        String queryString = theSearchParameterMap.toNormalizedQueryString(myFhirContext);
094                        if (!Objects.equals(resourceName, theRequestDetails.getUserData().get(RESOURCE_NAME))
095                                        || !Objects.equals(
096                                                        queryString, theRequestDetails.getUserData().get(QUERY_STRING))) {
097                                theRequestDetails.getUserData().remove(EXPANSION_RESULTS);
098                        }
099                }
100
101                MdmSearchExpansionResults expansionResults = getCachedExpansionResults(theRequestDetails);
102                if (expansionResults != null) {
103                        return expansionResults;
104                }
105
106                expansionResults = new MdmSearchExpansionResults();
107
108                final RequestPartitionId requestPartitionId =
109                                myRequestPartitionHelperSvc.determineReadPartitionForRequestForSearchType(
110                                                theRequestDetails, theRequestDetails.getResourceName(), theSearchParameterMap);
111
112                for (Map.Entry<String, List<List<IQueryParameterType>>> set : theSearchParameterMap.entrySet()) {
113                        String paramName = set.getKey();
114                        List<List<IQueryParameterType>> andList = set.getValue();
115                        for (List<IQueryParameterType> orList : andList) {
116                                // here we will know if it's an _id param or not
117                                // from theSearchParameterMap.keySet()
118                                expandAnyReferenceParameters(
119                                                requestPartitionId, theResourceName, paramName, orList, theParamTester, expansionResults);
120                        }
121                }
122
123                theRequestDetails.getUserData().put(EXPANSION_RESULTS, expansionResults);
124
125                /*
126                 * Note: Do this at the end so that the query string reflects the post-translated
127                 * query string
128                 */
129                String queryString = theSearchParameterMap.toNormalizedQueryString(myFhirContext);
130                theRequestDetails.getUserData().put(RESOURCE_NAME, theResourceName);
131                theRequestDetails.getUserData().put(QUERY_STRING, queryString);
132
133                return expansionResults;
134        }
135
136        private void expandAnyReferenceParameters(
137                        RequestPartitionId theRequestPartitionId,
138                        String theResourceName,
139                        String theParamName,
140                        List<IQueryParameterType> orList,
141                        IParamTester theParamTester,
142                        MdmSearchExpansionResults theResultsToPopulate) {
143
144                IMdmLinkExpandSvc mdmLinkExpandSvc = myMdmExpandersHolder.getLinkExpandSvcInstance();
145
146                List<IQueryParameterType> toRemove = new ArrayList<>();
147                List<IQueryParameterType> toAdd = new ArrayList<>();
148                for (IQueryParameterType iQueryParameterType : orList) {
149                        if (iQueryParameterType instanceof ReferenceParam) {
150                                ReferenceParam refParam = (ReferenceParam) iQueryParameterType;
151                                if (theParamTester.shouldExpand(theParamName, refParam)) {
152                                        ourLog.debug("Found a reference parameter to expand: {}", refParam);
153                                        // First, attempt to expand as a source resource.
154                                        IIdType sourceId = newId(refParam.getValue());
155                                        Set<String> expandedResourceIds =
156                                                        mdmLinkExpandSvc.expandMdmBySourceResourceId(theRequestPartitionId, sourceId);
157
158                                        // If we failed, attempt to expand as a golden resource
159                                        if (expandedResourceIds.isEmpty()) {
160                                                expandedResourceIds =
161                                                                mdmLinkExpandSvc.expandMdmByGoldenResourceId(theRequestPartitionId, sourceId);
162                                        }
163
164                                        // Rebuild the search param list.
165                                        if (!expandedResourceIds.isEmpty()) {
166                                                ourLog.debug("Parameter has been expanded to: {}", String.join(", ", expandedResourceIds));
167                                                toRemove.add(refParam);
168                                                for (String resourceId : expandedResourceIds) {
169                                                        IIdType nextReference =
170                                                                        newId(addResourceTypeIfNecessary(refParam.getResourceType(), resourceId));
171                                                        toAdd.add(new ReferenceParam(nextReference));
172                                                        theResultsToPopulate.addExpandedId(sourceId, nextReference);
173                                                }
174                                        }
175                                }
176                        } else if (theParamName.equalsIgnoreCase(IAnyResource.SP_RES_ID)) {
177                                expandIdParameter(
178                                                theRequestPartitionId,
179                                                iQueryParameterType,
180                                                toAdd,
181                                                toRemove,
182                                                theParamTester,
183                                                theResourceName,
184                                                theResultsToPopulate);
185                        }
186                }
187
188                orList.removeAll(toRemove);
189                orList.addAll(toAdd);
190        }
191
192        private IIdType newId(String value) {
193                return myFhirContext.getVersion().newIdType(value);
194        }
195
196        private String addResourceTypeIfNecessary(String theResourceType, String theResourceId) {
197                if (theResourceId.contains("/") || isBlank(theResourceType)) {
198                        return theResourceId;
199                } else {
200                        return theResourceType + "/" + theResourceId;
201                }
202        }
203
204        /**
205         * Expands out the provided _id parameter into all the various
206         * ids of linked resources.
207         */
208        private void expandIdParameter(
209                        RequestPartitionId theRequestPartitionId,
210                        IQueryParameterType theIdParameter,
211                        List<IQueryParameterType> theAddList,
212                        List<IQueryParameterType> theRemoveList,
213                        IParamTester theParamTester,
214                        String theResourceName,
215                        MdmSearchExpansionResults theResultsToPopulate) {
216                // id parameters can either be StringParam (for $everything operation)
217                // or TokenParam (for searches)
218                // either case, we want to expand it out and grab all related resources
219                IIdType id;
220                Creator<? extends IQueryParameterType> creator;
221                boolean mdmExpand = false;
222                if (theIdParameter instanceof TokenParam) {
223                        TokenParam param = (TokenParam) theIdParameter;
224                        mdmExpand = theParamTester.shouldExpand(IAnyResource.SP_RES_ID, param);
225                        String value = param.getValue();
226                        value = addResourceTypeIfNecessary(theResourceName, value);
227                        id = newId(value);
228                        creator = TokenParam::new;
229                } else {
230                        creator = null;
231                        id = null;
232                }
233
234                if (id == null) {
235                        // in case the _id parameter type is different from the above
236                        ourLog.warn(
237                                        "_id parameter of incorrect type. Expected StringParam or TokenParam, but got {}. No expansion will be done!",
238                                        theIdParameter.getClass().getSimpleName());
239                } else if (mdmExpand) {
240                        ourLog.debug("_id parameter must be expanded out from: {}", id.getValue());
241
242                        IMdmLinkExpandSvc mdmLinkExpandSvc = myMdmExpandersHolder.getLinkExpandSvcInstance();
243                        Set<String> expandedResourceIds = mdmLinkExpandSvc.expandMdmBySourceResourceId(theRequestPartitionId, id);
244
245                        if (expandedResourceIds.isEmpty()) {
246                                expandedResourceIds = mdmLinkExpandSvc.expandMdmByGoldenResourceId(theRequestPartitionId, id);
247                        }
248
249                        // Rebuild
250                        if (!expandedResourceIds.isEmpty()) {
251                                ourLog.debug("_id parameter has been expanded to: {}", expandedResourceIds);
252
253                                // remove the original
254                                theRemoveList.add(theIdParameter);
255
256                                // add in all the linked values
257                                expandedResourceIds.stream().map(creator::create).forEach(theAddList::add);
258
259                                for (String expandedId : expandedResourceIds) {
260                                        theResultsToPopulate.addExpandedId(
261                                                        id, newId(addResourceTypeIfNecessary(theResourceName, expandedId)));
262                                }
263                        }
264                }
265                // else - no expansion required
266        }
267
268        // A simple interface to turn ids into some form of IQueryParameterTypes
269        private interface Creator<T extends IQueryParameterType> {
270                T create(String id);
271        }
272
273        @FunctionalInterface
274        public interface IParamTester {
275
276                boolean shouldExpand(String theParamName, BaseParam theParam);
277        }
278
279        @Nullable
280        public static MdmSearchExpansionResults getCachedExpansionResults(@Nonnull RequestDetails theRequestDetails) {
281                MdmSearchExpansionResults expansionResults =
282                                (MdmSearchExpansionResults) theRequestDetails.getUserData().get(EXPANSION_RESULTS);
283                return expansionResults;
284        }
285}