001/*-
002 * #%L
003 * HAPI FHIR JPA Server
004 * %%
005 * Copyright (C) 2014 - 2024 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.term;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.context.support.TranslateConceptResult;
024import ca.uhn.fhir.context.support.TranslateConceptResults;
025import ca.uhn.fhir.i18n.Msg;
026import ca.uhn.fhir.interceptor.model.RequestPartitionId;
027import ca.uhn.fhir.jpa.api.model.TranslationQuery;
028import ca.uhn.fhir.jpa.api.model.TranslationRequest;
029import ca.uhn.fhir.jpa.api.svc.IIdHelperService;
030import ca.uhn.fhir.jpa.dao.data.ITermConceptMapDao;
031import ca.uhn.fhir.jpa.entity.TermConceptMap;
032import ca.uhn.fhir.jpa.entity.TermConceptMapGroup;
033import ca.uhn.fhir.jpa.entity.TermConceptMapGroupElement;
034import ca.uhn.fhir.jpa.entity.TermConceptMapGroupElementTarget;
035import ca.uhn.fhir.jpa.model.dao.JpaPid;
036import ca.uhn.fhir.jpa.term.api.ITermConceptClientMappingSvc;
037import ca.uhn.fhir.jpa.util.ScrollableResultsIterator;
038import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
039import jakarta.annotation.Nonnull;
040import jakarta.persistence.EntityManager;
041import jakarta.persistence.PersistenceContext;
042import jakarta.persistence.PersistenceContextType;
043import jakarta.persistence.TypedQuery;
044import jakarta.persistence.criteria.CriteriaBuilder;
045import jakarta.persistence.criteria.CriteriaQuery;
046import jakarta.persistence.criteria.Join;
047import jakarta.persistence.criteria.Predicate;
048import jakarta.persistence.criteria.Root;
049import org.apache.commons.lang3.StringUtils;
050import org.hibernate.ScrollMode;
051import org.hibernate.ScrollableResults;
052import org.hl7.fhir.instance.model.api.IIdType;
053import org.hl7.fhir.r4.model.Coding;
054import org.hl7.fhir.r4.model.Enumerations;
055import org.slf4j.Logger;
056import org.slf4j.LoggerFactory;
057import org.springframework.beans.factory.annotation.Autowired;
058import org.springframework.data.domain.PageRequest;
059import org.springframework.data.domain.Pageable;
060import org.springframework.transaction.annotation.Propagation;
061import org.springframework.transaction.annotation.Transactional;
062
063import java.util.ArrayList;
064import java.util.HashSet;
065import java.util.List;
066import java.util.Set;
067
068import static org.apache.commons.lang3.StringUtils.isBlank;
069import static org.apache.commons.lang3.StringUtils.isNotBlank;
070
071public class TermConceptClientMappingSvcImpl implements ITermConceptClientMappingSvc {
072        private static final Logger ourLog = LoggerFactory.getLogger(TermConceptClientMappingSvcImpl.class);
073
074        private final int myFetchSize = TermReadSvcImpl.DEFAULT_FETCH_SIZE;
075
076        @PersistenceContext(type = PersistenceContextType.TRANSACTION)
077        protected EntityManager myEntityManager;
078
079        @Autowired
080        protected FhirContext myContext;
081
082        @Autowired
083        protected IIdHelperService<JpaPid> myIdHelperService;
084
085        @Autowired
086        protected ITermConceptMapDao myConceptMapDao;
087
088        @Override
089        @Transactional(propagation = Propagation.REQUIRED)
090        public TranslateConceptResults translate(TranslationRequest theTranslationRequest) {
091                TranslateConceptResults retVal = new TranslateConceptResults();
092
093                CriteriaBuilder criteriaBuilder = myEntityManager.getCriteriaBuilder();
094                CriteriaQuery<TermConceptMapGroupElementTarget> query =
095                                criteriaBuilder.createQuery(TermConceptMapGroupElementTarget.class);
096                Root<TermConceptMapGroupElementTarget> root = query.from(TermConceptMapGroupElementTarget.class);
097
098                Join<TermConceptMapGroupElementTarget, TermConceptMapGroupElement> elementJoin =
099                                root.join("myConceptMapGroupElement");
100                Join<TermConceptMapGroupElement, TermConceptMapGroup> groupJoin = elementJoin.join("myConceptMapGroup");
101                Join<TermConceptMapGroup, TermConceptMap> conceptMapJoin = groupJoin.join("myConceptMap");
102
103                List<TranslationQuery> translationQueries = theTranslationRequest.getTranslationQueries();
104                ArrayList<Predicate> predicates;
105                Coding coding;
106
107                // -- get the latest ConceptMapVersion if theTranslationRequest has ConceptMap url but no ConceptMap version
108                String latestConceptMapVersion = null;
109                if (theTranslationRequest.hasUrl() && !theTranslationRequest.hasConceptMapVersion())
110                        latestConceptMapVersion = getLatestConceptMapVersion(theTranslationRequest);
111
112                for (TranslationQuery translationQuery : translationQueries) {
113                        final List<TranslateConceptResult> targets = new ArrayList<>();
114
115                        predicates = new ArrayList<>();
116
117                        coding = translationQuery.getCoding();
118                        if (coding.hasCode()) {
119                                predicates.add(criteriaBuilder.equal(elementJoin.get("myCode"), coding.getCode()));
120                        } else {
121                                throw new InvalidRequestException(Msg.code(842) + "A code must be provided for translation to occur.");
122                        }
123
124                        if (coding.hasSystem()) {
125                                predicates.add(criteriaBuilder.equal(groupJoin.get("mySource"), coding.getSystem()));
126                        }
127
128                        if (coding.hasVersion()) {
129                                predicates.add(criteriaBuilder.equal(groupJoin.get("mySourceVersion"), coding.getVersion()));
130                        }
131
132                        if (translationQuery.hasTargetSystem()) {
133                                predicates.add(criteriaBuilder.equal(groupJoin.get("myTarget"), translationQuery.getTargetSystem()));
134                        }
135
136                        if (translationQuery.hasUrl()) {
137                                predicates.add(criteriaBuilder.equal(conceptMapJoin.get("myUrl"), translationQuery.getUrl()));
138                                if (translationQuery.hasConceptMapVersion()) {
139                                        // both url and conceptMapVersion
140                                        predicates.add(criteriaBuilder.equal(
141                                                        conceptMapJoin.get("myVersion"), translationQuery.getConceptMapVersion()));
142                                } else {
143                                        if (StringUtils.isNotBlank(latestConceptMapVersion)) {
144                                                // only url and use latestConceptMapVersion
145                                                predicates.add(criteriaBuilder.equal(conceptMapJoin.get("myVersion"), latestConceptMapVersion));
146                                        } else {
147                                                predicates.add(criteriaBuilder.isNull(conceptMapJoin.get("myVersion")));
148                                        }
149                                }
150                        }
151
152                        if (translationQuery.hasSource()) {
153                                predicates.add(criteriaBuilder.equal(conceptMapJoin.get("mySource"), translationQuery.getSource()));
154                        }
155
156                        if (translationQuery.hasTarget()) {
157                                predicates.add(criteriaBuilder.equal(conceptMapJoin.get("myTarget"), translationQuery.getTarget()));
158                        }
159
160                        if (translationQuery.hasResourceId()) {
161                                IIdType resourceId = translationQuery.getResourceId();
162                                JpaPid resourcePid =
163                                                myIdHelperService.getPidOrThrowException(RequestPartitionId.defaultPartition(), resourceId);
164                                predicates.add(criteriaBuilder.equal(conceptMapJoin.get("myResourcePid"), resourcePid.getId()));
165                        }
166
167                        Predicate outerPredicate = criteriaBuilder.and(predicates.toArray(new Predicate[0]));
168                        query.where(outerPredicate);
169
170                        // Use scrollable results.
171                        final TypedQuery<TermConceptMapGroupElementTarget> typedQuery =
172                                        myEntityManager.createQuery(query.select(root));
173                        org.hibernate.query.Query<TermConceptMapGroupElementTarget> hibernateQuery =
174                                        (org.hibernate.query.Query<TermConceptMapGroupElementTarget>) typedQuery;
175                        hibernateQuery.setFetchSize(myFetchSize);
176                        ScrollableResults<TermConceptMapGroupElementTarget> scrollableResults =
177                                        hibernateQuery.scroll(ScrollMode.FORWARD_ONLY);
178                        try (ScrollableResultsIterator<TermConceptMapGroupElementTarget> scrollableResultsIterator =
179                                        new ScrollableResultsIterator<>(scrollableResults)) {
180
181                                Set<TermConceptMapGroupElementTarget> matches = new HashSet<>();
182                                while (scrollableResultsIterator.hasNext()) {
183                                        TermConceptMapGroupElementTarget next = scrollableResultsIterator.next();
184                                        if (matches.add(next)) {
185
186                                                TranslateConceptResult translationMatch = new TranslateConceptResult();
187                                                if (next.getEquivalence() != null) {
188                                                        translationMatch.setEquivalence(
189                                                                        next.getEquivalence().toCode());
190                                                }
191
192                                                translationMatch.setCode(next.getCode());
193                                                translationMatch.setSystem(next.getSystem());
194                                                translationMatch.setSystemVersion(next.getSystemVersion());
195                                                translationMatch.setDisplay(next.getDisplay());
196                                                translationMatch.setValueSet(next.getValueSet());
197                                                translationMatch.setSystemVersion(next.getSystemVersion());
198                                                translationMatch.setConceptMapUrl(next.getConceptMapUrl());
199
200                                                targets.add(translationMatch);
201                                        }
202                                }
203                        }
204
205                        retVal.getResults().addAll(targets);
206                }
207
208                buildTranslationResult(retVal);
209                return retVal;
210        }
211
212        @Override
213        @Transactional(propagation = Propagation.REQUIRED)
214        public TranslateConceptResults translateWithReverse(TranslationRequest theTranslationRequest) {
215                TranslateConceptResults retVal = new TranslateConceptResults();
216
217                CriteriaBuilder criteriaBuilder = myEntityManager.getCriteriaBuilder();
218                CriteriaQuery<TermConceptMapGroupElement> query = criteriaBuilder.createQuery(TermConceptMapGroupElement.class);
219                Root<TermConceptMapGroupElement> root = query.from(TermConceptMapGroupElement.class);
220
221                Join<TermConceptMapGroupElement, TermConceptMapGroupElementTarget> targetJoin =
222                                root.join("myConceptMapGroupElementTargets");
223                Join<TermConceptMapGroupElement, TermConceptMapGroup> groupJoin = root.join("myConceptMapGroup");
224                Join<TermConceptMapGroup, TermConceptMap> conceptMapJoin = groupJoin.join("myConceptMap");
225
226                List<TranslationQuery> translationQueries = theTranslationRequest.getTranslationQueries();
227                ArrayList<Predicate> predicates;
228                Coding coding;
229
230                // -- get the latest ConceptMapVersion if theTranslationRequest has ConceptMap url but no ConceptMap version
231                String latestConceptMapVersion = null;
232                if (theTranslationRequest.hasUrl() && !theTranslationRequest.hasConceptMapVersion())
233                        latestConceptMapVersion = getLatestConceptMapVersion(theTranslationRequest);
234
235                for (TranslationQuery translationQuery : translationQueries) {
236                        final List<TranslateConceptResult> elements = new ArrayList<>();
237
238                        predicates = new ArrayList<>();
239
240                        coding = translationQuery.getCoding();
241                        String targetCode;
242                        String targetCodeSystem = null;
243                        if (coding.hasCode()) {
244                                predicates.add(criteriaBuilder.equal(targetJoin.get("myCode"), coding.getCode()));
245                                targetCode = coding.getCode();
246                        } else {
247                                throw new InvalidRequestException(Msg.code(843) + "A code must be provided for translation to occur.");
248                        }
249
250                        if (coding.hasSystem()) {
251                                predicates.add(criteriaBuilder.equal(groupJoin.get("myTarget"), coding.getSystem()));
252                                targetCodeSystem = coding.getSystem();
253                        }
254
255                        if (coding.hasVersion()) {
256                                predicates.add(criteriaBuilder.equal(groupJoin.get("myTargetVersion"), coding.getVersion()));
257                        }
258
259                        if (translationQuery.hasUrl()) {
260                                predicates.add(criteriaBuilder.equal(conceptMapJoin.get("myUrl"), translationQuery.getUrl()));
261                                if (translationQuery.hasConceptMapVersion()) {
262                                        // both url and conceptMapVersion
263                                        predicates.add(criteriaBuilder.equal(
264                                                        conceptMapJoin.get("myVersion"), translationQuery.getConceptMapVersion()));
265                                } else {
266                                        if (StringUtils.isNotBlank(latestConceptMapVersion)) {
267                                                // only url and use latestConceptMapVersion
268                                                predicates.add(criteriaBuilder.equal(conceptMapJoin.get("myVersion"), latestConceptMapVersion));
269                                        } else {
270                                                predicates.add(criteriaBuilder.isNull(conceptMapJoin.get("myVersion")));
271                                        }
272                                }
273                        }
274
275                        if (translationQuery.hasTargetSystem()) {
276                                predicates.add(criteriaBuilder.equal(groupJoin.get("mySource"), translationQuery.getTargetSystem()));
277                        }
278
279                        if (translationQuery.hasSource()) {
280                                predicates.add(criteriaBuilder.equal(conceptMapJoin.get("myTarget"), translationQuery.getSource()));
281                        }
282
283                        if (translationQuery.hasTarget()) {
284                                predicates.add(criteriaBuilder.equal(conceptMapJoin.get("mySource"), translationQuery.getTarget()));
285                        }
286
287                        if (translationQuery.hasResourceId()) {
288                                IIdType resourceId = translationQuery.getResourceId();
289                                JpaPid resourcePid =
290                                                myIdHelperService.getPidOrThrowException(RequestPartitionId.defaultPartition(), resourceId);
291                                predicates.add(criteriaBuilder.equal(conceptMapJoin.get("myResourcePid"), resourcePid.getId()));
292                        }
293
294                        Predicate outerPredicate = criteriaBuilder.and(predicates.toArray(new Predicate[0]));
295                        query.where(outerPredicate);
296
297                        // Use scrollable results.
298                        final TypedQuery<TermConceptMapGroupElement> typedQuery = myEntityManager.createQuery(query.select(root));
299                        org.hibernate.query.Query<TermConceptMapGroupElement> hibernateQuery =
300                                        (org.hibernate.query.Query<TermConceptMapGroupElement>) typedQuery;
301                        hibernateQuery.setFetchSize(myFetchSize);
302                        ScrollableResults<TermConceptMapGroupElement> scrollableResults =
303                                        hibernateQuery.scroll(ScrollMode.FORWARD_ONLY);
304                        try (ScrollableResultsIterator<TermConceptMapGroupElement> scrollableResultsIterator =
305                                        new ScrollableResultsIterator<>(scrollableResults)) {
306
307                                Set<TermConceptMapGroupElementTarget> matches = new HashSet<>();
308                                while (scrollableResultsIterator.hasNext()) {
309                                        TermConceptMapGroupElement nextElement = scrollableResultsIterator.next();
310
311                                        /* TODO: The invocation of the size() below does not seem to be necessary but for some reason,
312                                         * but removing it causes tests in TerminologySvcImplR4Test to fail. We use the outcome
313                                         * in a trace log to avoid ErrorProne flagging an unused return value.
314                                         */
315                                        int size = nextElement.getConceptMapGroupElementTargets().size();
316                                        ourLog.trace("Have {} targets", size);
317
318                                        myEntityManager.detach(nextElement);
319
320                                        if (isNotBlank(targetCode)) {
321                                                for (TermConceptMapGroupElementTarget next : nextElement.getConceptMapGroupElementTargets()) {
322                                                        if (matches.add(next)) {
323                                                                if (isBlank(targetCodeSystem)
324                                                                                || StringUtils.equals(targetCodeSystem, next.getSystem())) {
325                                                                        if (StringUtils.equals(targetCode, next.getCode())) {
326                                                                                TranslateConceptResult translationMatch =
327                                                                                                newTranslateConceptResult(nextElement, next);
328
329                                                                                if (alreadyContainsMapping(elements, translationMatch)
330                                                                                                || alreadyContainsMapping(retVal.getResults(), translationMatch)) {
331                                                                                        continue;
332                                                                                }
333
334                                                                                elements.add(translationMatch);
335                                                                        }
336                                                                }
337                                                        }
338                                                }
339                                        }
340                                }
341                        }
342
343                        retVal.getResults().addAll(elements);
344                }
345
346                buildTranslationResult(retVal);
347                return retVal;
348        }
349
350        @Nonnull
351        private static TranslateConceptResult newTranslateConceptResult(
352                        TermConceptMapGroupElement theGroup, TermConceptMapGroupElementTarget theTarget) {
353                TranslateConceptResult translationMatch = new TranslateConceptResult();
354                translationMatch.setCode(theGroup.getCode());
355                translationMatch.setSystem(theGroup.getSystem());
356                translationMatch.setSystemVersion(theGroup.getSystemVersion());
357                translationMatch.setDisplay(theGroup.getDisplay());
358                translationMatch.setValueSet(theGroup.getValueSet());
359                translationMatch.setSystemVersion(theGroup.getSystemVersion());
360                translationMatch.setConceptMapUrl(theGroup.getConceptMapUrl());
361                if (theTarget.getEquivalence() != null) {
362                        translationMatch.setEquivalence(theTarget.getEquivalence().toCode());
363                }
364                return translationMatch;
365        }
366
367        @Override
368        public FhirContext getFhirContext() {
369                return myContext;
370        }
371
372        // Special case for the translate operation with url and without
373        // conceptMapVersion, find the latest conecptMapVersion
374        private String getLatestConceptMapVersion(TranslationRequest theTranslationRequest) {
375
376                Pageable page = PageRequest.of(0, 1);
377                List<TermConceptMap> theConceptMapList = myConceptMapDao.getTermConceptMapEntitiesByUrlOrderByMostRecentUpdate(
378                                page, theTranslationRequest.getUrl());
379                if (!theConceptMapList.isEmpty()) {
380                        return theConceptMapList.get(0).getVersion();
381                }
382
383                return null;
384        }
385
386        private void buildTranslationResult(TranslateConceptResults theTranslationResult) {
387
388                String msg;
389                if (theTranslationResult.getResults().isEmpty()) {
390                        theTranslationResult.setResult(false);
391                        msg = myContext.getLocalizer().getMessage(TermConceptMappingSvcImpl.class, "noMatchesFound");
392                        theTranslationResult.setMessage(msg);
393                } else if (isOnlyNegativeMatches(theTranslationResult)) {
394                        theTranslationResult.setResult(false);
395                        msg = myContext.getLocalizer().getMessage(TermConceptMappingSvcImpl.class, "onlyNegativeMatchesFound");
396                        theTranslationResult.setMessage(msg);
397                } else {
398                        theTranslationResult.setResult(true);
399                        msg = myContext.getLocalizer().getMessage(TermConceptMappingSvcImpl.class, "matchesFound");
400                        theTranslationResult.setMessage(msg);
401                }
402        }
403
404        /**
405         * Evaluates whether a translation result contains any positive matches or only negative ones. This is required
406         * because the <a href="https://hl7.org/fhir/R4/conceptmap-operation-translate.html">FHIR specification</a> states
407         * that the result field "can only be true if at least one returned match has an equivalence which is not unmatched
408         * or disjoint".
409         * @param theTranslationResult the translation result to be evaluated
410         * @return true if all the potential matches in the result have a negative valence (i.e., "unmatched" and "disjoint")
411         */
412        private boolean isOnlyNegativeMatches(TranslateConceptResults theTranslationResult) {
413                return theTranslationResult.getResults().stream()
414                                .map(TranslateConceptResult::getEquivalence)
415                                .allMatch(t -> StringUtils.equals(Enumerations.ConceptMapEquivalence.UNMATCHED.toCode(), t)
416                                                || StringUtils.equals(Enumerations.ConceptMapEquivalence.DISJOINT.toCode(), t));
417        }
418
419        private boolean alreadyContainsMapping(
420                        List<TranslateConceptResult> elements, TranslateConceptResult translationMatch) {
421                for (TranslateConceptResult nextExistingElement : elements) {
422                        if (StringUtils.equals(nextExistingElement.getSystem(), translationMatch.getSystem())) {
423                                if (StringUtils.equals(nextExistingElement.getSystemVersion(), translationMatch.getSystemVersion())) {
424                                        if (StringUtils.equals(nextExistingElement.getCode(), translationMatch.getCode())) {
425                                                return true;
426                                        }
427                                }
428                        }
429                }
430                return false;
431        }
432}