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}