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