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 refParam) {
150                                if (theParamTester.shouldExpand(theParamName, refParam)) {
151                                        ourLog.debug("Found a reference parameter to expand: {}", refParam);
152                                        // First, attempt to expand as a source resource.
153                                        IIdType sourceId = newId(refParam.getValue());
154                                        Set<String> expandedResourceIds =
155                                                        mdmLinkExpandSvc.expandMdmBySourceResourceId(theRequestPartitionId, sourceId);
156
157                                        // If we failed, attempt to expand as a golden resource
158                                        if (expandedResourceIds.isEmpty()) {
159                                                expandedResourceIds =
160                                                                mdmLinkExpandSvc.expandMdmByGoldenResourceId(theRequestPartitionId, sourceId);
161                                        }
162
163                                        // Rebuild the search param list.
164                                        if (!expandedResourceIds.isEmpty()) {
165                                                ourLog.debug("Parameter has been expanded to: {}", String.join(", ", expandedResourceIds));
166                                                toRemove.add(refParam);
167                                                for (String resourceId : expandedResourceIds) {
168                                                        IIdType nextReference =
169                                                                        newId(addResourceTypeIfNecessary(refParam.getResourceType(), resourceId));
170                                                        toAdd.add(new ReferenceParam(nextReference));
171                                                        theResultsToPopulate.addExpandedId(sourceId, nextReference);
172                                                }
173                                        }
174                                }
175                        } else if (theParamName.equalsIgnoreCase(IAnyResource.SP_RES_ID)) {
176                                expandIdParameter(
177                                                theRequestPartitionId,
178                                                iQueryParameterType,
179                                                toAdd,
180                                                toRemove,
181                                                theParamTester,
182                                                theResourceName,
183                                                theResultsToPopulate);
184                        }
185                }
186
187                orList.removeAll(toRemove);
188                orList.addAll(toAdd);
189        }
190
191        private IIdType newId(String value) {
192                return myFhirContext.getVersion().newIdType(value);
193        }
194
195        private String addResourceTypeIfNecessary(String theResourceType, String theResourceId) {
196                if (theResourceId.contains("/") || isBlank(theResourceType)) {
197                        return theResourceId;
198                } else {
199                        return theResourceType + "/" + theResourceId;
200                }
201        }
202
203        /**
204         * Expands out the provided _id parameter into all the various
205         * ids of linked resources.
206         */
207        private void expandIdParameter(
208                        RequestPartitionId theRequestPartitionId,
209                        IQueryParameterType theIdParameter,
210                        List<IQueryParameterType> theAddList,
211                        List<IQueryParameterType> theRemoveList,
212                        IParamTester theParamTester,
213                        String theResourceName,
214                        MdmSearchExpansionResults theResultsToPopulate) {
215                // id parameters can either be StringParam (for $everything operation)
216                // or TokenParam (for searches)
217                // either case, we want to expand it out and grab all related resources
218                IIdType id;
219                Creator<? extends IQueryParameterType> creator;
220                boolean mdmExpand = false;
221                if (theIdParameter instanceof TokenParam) {
222                        TokenParam param = (TokenParam) theIdParameter;
223                        mdmExpand = theParamTester.shouldExpand(IAnyResource.SP_RES_ID, param);
224                        String value = param.getValue();
225                        value = addResourceTypeIfNecessary(theResourceName, value);
226                        id = newId(value);
227                        creator = TokenParam::new;
228                } else {
229                        creator = null;
230                        id = null;
231                }
232
233                if (id == null) {
234                        // in case the _id parameter type is different from the above
235                        ourLog.warn(
236                                        "_id parameter of incorrect type. Expected StringParam or TokenParam, but got {}. No expansion will be done!",
237                                        theIdParameter.getClass().getSimpleName());
238                } else if (mdmExpand) {
239                        ourLog.debug("_id parameter must be expanded out from: {}", id.getValue());
240
241                        IMdmLinkExpandSvc mdmLinkExpandSvc = myMdmExpandersHolder.getLinkExpandSvcInstance();
242                        Set<String> expandedResourceIds = mdmLinkExpandSvc.expandMdmBySourceResourceId(theRequestPartitionId, id);
243
244                        if (expandedResourceIds.isEmpty()) {
245                                expandedResourceIds = mdmLinkExpandSvc.expandMdmByGoldenResourceId(theRequestPartitionId, id);
246                        }
247
248                        // Rebuild
249                        if (!expandedResourceIds.isEmpty()) {
250                                ourLog.debug("_id parameter has been expanded to: {}", expandedResourceIds);
251
252                                // remove the original
253                                theRemoveList.add(theIdParameter);
254
255                                // add in all the linked values
256                                expandedResourceIds.stream().map(creator::create).forEach(theAddList::add);
257
258                                for (String expandedId : expandedResourceIds) {
259                                        theResultsToPopulate.addExpandedId(
260                                                        id, newId(addResourceTypeIfNecessary(theResourceName, expandedId)));
261                                }
262                        }
263                }
264                // else - no expansion required
265        }
266
267        // A simple interface to turn ids into some form of IQueryParameterTypes
268        private interface Creator<T extends IQueryParameterType> {
269                T create(String id);
270        }
271
272        @FunctionalInterface
273        public interface IParamTester {
274
275                boolean shouldExpand(String theParamName, BaseParam theParam);
276        }
277
278        @Nullable
279        public static MdmSearchExpansionResults getCachedExpansionResults(@Nonnull RequestDetails theRequestDetails) {
280                MdmSearchExpansionResults expansionResults =
281                                (MdmSearchExpansionResults) theRequestDetails.getUserData().get(EXPANSION_RESULTS);
282                return expansionResults;
283        }
284}