001/*-
002 * #%L
003 * HAPI FHIR JPA Server - Master Data Management
004 * %%
005 * Copyright (C) 2014 - 2023 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.mdm.svc.candidate;
021
022import ca.uhn.fhir.i18n.Msg;
023import ca.uhn.fhir.interceptor.model.RequestPartitionId;
024import ca.uhn.fhir.jpa.api.svc.IIdHelperService;
025import ca.uhn.fhir.mdm.api.IMdmSettings;
026import ca.uhn.fhir.mdm.log.Logs;
027import ca.uhn.fhir.mdm.rules.json.MdmFilterSearchParamJson;
028import ca.uhn.fhir.mdm.rules.json.MdmResourceSearchParamJson;
029import ca.uhn.fhir.rest.api.server.IBundleProvider;
030import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId;
031import org.hl7.fhir.instance.model.api.IAnyResource;
032import org.hl7.fhir.instance.model.api.IBaseResource;
033import org.slf4j.Logger;
034import org.springframework.beans.factory.annotation.Autowired;
035import org.springframework.stereotype.Service;
036import org.springframework.transaction.annotation.Transactional;
037
038import java.util.Collection;
039import java.util.Collections;
040import java.util.HashMap;
041import java.util.List;
042import java.util.Map;
043import java.util.Optional;
044import java.util.stream.Collectors;
045
046import static ca.uhn.fhir.jpa.mdm.svc.candidate.CandidateSearcher.idOrType;
047import static ca.uhn.fhir.mdm.api.MdmConstants.ALL_RESOURCE_SEARCH_PARAM_TYPE;
048
049@Service
050public class MdmCandidateSearchSvc {
051
052        private static final Logger ourLog = Logs.getMdmTroubleshootingLog();
053
054        @Autowired
055        private IMdmSettings myMdmSettings;
056
057        @Autowired
058        private IIdHelperService myIdHelperService;
059
060        @Autowired
061        private MdmCandidateSearchCriteriaBuilderSvc myMdmCandidateSearchCriteriaBuilderSvc;
062
063        @Autowired
064        private CandidateSearcher myCandidateSearcher;
065
066        public MdmCandidateSearchSvc() {}
067
068        /**
069         * Given a source resource, search for all resources that are considered an MDM match based on defined MDM rules.
070         *
071         * @param theResourceType the resource type of the resource being matched
072         * @param theResource the {@link IBaseResource} we are attempting to match.
073         * @param theRequestPartitionId  the {@link RequestPartitionId} representation of the partitions we are limited to when attempting to match
074         *
075         * @return the list of candidate {@link IBaseResource} which could be matches to theResource
076         */
077        @Transactional
078        public Collection<IAnyResource> findCandidates(
079                        String theResourceType, IAnyResource theResource, RequestPartitionId theRequestPartitionId) {
080                Map<IResourcePersistentId, IAnyResource> matchedPidsToResources = new HashMap<>();
081                List<MdmFilterSearchParamJson> filterSearchParams =
082                                myMdmSettings.getMdmRules().getCandidateFilterSearchParams();
083                List<String> filterCriteria = buildFilterQuery(filterSearchParams, theResourceType);
084                List<MdmResourceSearchParamJson> candidateSearchParams =
085                                myMdmSettings.getMdmRules().getCandidateSearchParams();
086
087                // If there are zero MdmResourceSearchParamJson, we end up only making a single search, otherwise we
088                // must perform one search per MdmResourceSearchParamJson.
089                if (candidateSearchParams.isEmpty()) {
090                        searchForIdsAndAddToMap(
091                                        theResourceType, theResource, matchedPidsToResources, filterCriteria, null, theRequestPartitionId);
092                } else {
093                        for (MdmResourceSearchParamJson resourceSearchParam : candidateSearchParams) {
094
095                                if (!isSearchParamForResource(theResourceType, resourceSearchParam)) {
096                                        continue;
097                                }
098
099                                searchForIdsAndAddToMap(
100                                                theResourceType,
101                                                theResource,
102                                                matchedPidsToResources,
103                                                filterCriteria,
104                                                resourceSearchParam,
105                                                theRequestPartitionId);
106                        }
107                }
108                // Obviously we don't want to consider the incoming resource as a potential candidate.
109                // Sometimes, we are running this function on a resource that has not yet been persisted,
110                // so it may not have an ID yet, precluding the need to remove it.
111                if (theResource.getIdElement().getIdPart() != null) {
112                        if (matchedPidsToResources.remove(
113                                                        myIdHelperService.getPidOrNull(RequestPartitionId.allPartitions(), theResource))
114                                        != null) {
115                                ourLog.debug(
116                                                "Removing incoming resource {} from list of candidates.",
117                                                theResource.getIdElement().toUnqualifiedVersionless());
118                        }
119                }
120
121                ourLog.info(
122                                "Candidate search found {} matching resources for {}",
123                                matchedPidsToResources.size(),
124                                idOrType(theResource, theResourceType));
125                return matchedPidsToResources.values();
126        }
127
128        private boolean isSearchParamForResource(String theResourceType, MdmResourceSearchParamJson resourceSearchParam) {
129                String resourceType = resourceSearchParam.getResourceType();
130                return resourceType.equals(theResourceType) || resourceType.equalsIgnoreCase(ALL_RESOURCE_SEARCH_PARAM_TYPE);
131        }
132
133        /*
134         * Helper method which performs too much work currently.
135         * 1. Build a full query string for the given filter and resource criteria.
136         * 2. Convert that URL to a SearchParameterMap.
137         * 3. Execute a Synchronous search on the DAO using that parameter map.
138         * 4. Store all results in `theMatchedPidsToResources`
139         */
140        @SuppressWarnings("rawtypes")
141        private void searchForIdsAndAddToMap(
142                        String theResourceType,
143                        IAnyResource theResource,
144                        Map<IResourcePersistentId, IAnyResource> theMatchedPidsToResources,
145                        List<String> theFilterCriteria,
146                        MdmResourceSearchParamJson resourceSearchParam,
147                        RequestPartitionId theRequestPartitionId) {
148                // 1.
149                Optional<String> oResourceCriteria = myMdmCandidateSearchCriteriaBuilderSvc.buildResourceQueryString(
150                                theResourceType, theResource, theFilterCriteria, resourceSearchParam);
151                if (!oResourceCriteria.isPresent()) {
152                        return;
153                }
154                String resourceCriteria = oResourceCriteria.get();
155                ourLog.debug("Searching for {} candidates with {}", theResourceType, resourceCriteria);
156
157                // 2.
158                Optional<IBundleProvider> bundleProvider =
159                                myCandidateSearcher.search(theResourceType, resourceCriteria, theRequestPartitionId);
160                if (!bundleProvider.isPresent()) {
161                        throw new TooManyCandidatesException(Msg.code(762) + "More than " + myMdmSettings.getCandidateSearchLimit()
162                                        + " candidate matches found for " + resourceCriteria + ".  Aborting mdm matching.");
163                }
164                List<IBaseResource> resources = bundleProvider.get().getAllResources();
165
166                int initialSize = theMatchedPidsToResources.size();
167
168                // 4.
169                resources.forEach(resource -> theMatchedPidsToResources.put(
170                                myIdHelperService.getPidOrNull(RequestPartitionId.allPartitions(), resource), (IAnyResource) resource));
171
172                int newSize = theMatchedPidsToResources.size();
173
174                if (ourLog.isDebugEnabled()) {
175                        ourLog.debug("Candidate search added {} {}s", newSize - initialSize, theResourceType);
176                }
177        }
178
179        private List<String> buildFilterQuery(
180                        List<MdmFilterSearchParamJson> theFilterSearchParams, String theResourceType) {
181                return Collections.unmodifiableList(theFilterSearchParams.stream()
182                                .filter(spFilterJson -> paramIsOnCorrectType(theResourceType, spFilterJson))
183                                .map(this::convertToQueryString)
184                                .collect(Collectors.toList()));
185        }
186
187        private boolean paramIsOnCorrectType(String theResourceType, MdmFilterSearchParamJson spFilterJson) {
188                return spFilterJson.getResourceType().equals(theResourceType)
189                                || spFilterJson.getResourceType().equalsIgnoreCase(ALL_RESOURCE_SEARCH_PARAM_TYPE);
190        }
191
192        private String convertToQueryString(MdmFilterSearchParamJson theSpFilterJson) {
193                String qualifier = theSpFilterJson.getTokenParamModifierAsString();
194                return theSpFilterJson.getSearchParam() + qualifier + "=" + theSpFilterJson.getFixedValue();
195        }
196}