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}