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