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.search.builder.predicate; 021 022import ca.uhn.fhir.context.BaseRuntimeChildDefinition; 023import ca.uhn.fhir.context.BaseRuntimeDeclaredChildDefinition; 024import ca.uhn.fhir.context.BaseRuntimeElementCompositeDefinition; 025import ca.uhn.fhir.context.FhirContext; 026import ca.uhn.fhir.context.FhirVersionEnum; 027import ca.uhn.fhir.context.RuntimeResourceDefinition; 028import ca.uhn.fhir.context.RuntimeSearchParam; 029import ca.uhn.fhir.context.support.IValidationSupport; 030import ca.uhn.fhir.context.support.ValidationSupportContext; 031import ca.uhn.fhir.context.support.ValueSetExpansionOptions; 032import ca.uhn.fhir.i18n.Msg; 033import ca.uhn.fhir.interceptor.model.RequestPartitionId; 034import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; 035import ca.uhn.fhir.jpa.dao.predicate.SearchFilterParser; 036import ca.uhn.fhir.jpa.model.entity.BaseResourceIndexedSearchParam; 037import ca.uhn.fhir.jpa.model.entity.ResourceIndexedSearchParamToken; 038import ca.uhn.fhir.jpa.search.builder.sql.SearchQueryBuilder; 039import ca.uhn.fhir.jpa.term.api.ITermReadSvc; 040import ca.uhn.fhir.jpa.util.QueryParameterUtils; 041import ca.uhn.fhir.model.api.IQueryParameterType; 042import ca.uhn.fhir.model.base.composite.BaseCodingDt; 043import ca.uhn.fhir.model.base.composite.BaseIdentifierDt; 044import ca.uhn.fhir.rest.api.Constants; 045import ca.uhn.fhir.rest.param.NumberParam; 046import ca.uhn.fhir.rest.param.TokenParam; 047import ca.uhn.fhir.rest.param.TokenParamModifier; 048import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; 049import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException; 050import ca.uhn.fhir.util.FhirVersionIndependentConcept; 051import com.google.common.annotations.VisibleForTesting; 052import com.google.common.collect.Sets; 053import com.healthmarketscience.sqlbuilder.BinaryCondition; 054import com.healthmarketscience.sqlbuilder.Condition; 055import com.healthmarketscience.sqlbuilder.dbspec.basic.DbColumn; 056import org.hl7.fhir.instance.model.api.IBase; 057import org.hl7.fhir.instance.model.api.IBaseResource; 058import org.hl7.fhir.instance.model.api.IPrimitiveType; 059import org.springframework.beans.factory.annotation.Autowired; 060 061import java.util.ArrayList; 062import java.util.Arrays; 063import java.util.Collection; 064import java.util.List; 065import java.util.Optional; 066import java.util.Set; 067import java.util.stream.Collectors; 068 069import static org.apache.commons.lang3.StringUtils.defaultIfBlank; 070import static org.apache.commons.lang3.StringUtils.isBlank; 071import static org.apache.commons.lang3.StringUtils.isNotBlank; 072 073public class TokenPredicateBuilder extends BaseSearchParamPredicateBuilder { 074 private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(TokenPredicateBuilder.class); 075 076 private final DbColumn myColumnResId; 077 private final DbColumn myColumnHashSystemAndValue; 078 private final DbColumn myColumnHashSystem; 079 private final DbColumn myColumnHashValue; 080 private final DbColumn myColumnSystem; 081 private final DbColumn myColumnValue; 082 private final DbColumn myColumnHashIdentity; 083 084 @Autowired 085 private IValidationSupport myValidationSupport; 086 087 @Autowired 088 private ITermReadSvc myTerminologySvc; 089 090 @Autowired 091 private FhirContext myContext; 092 093 @Autowired 094 private JpaStorageSettings myStorageSettings; 095 096 /** 097 * Constructor 098 */ 099 public TokenPredicateBuilder(SearchQueryBuilder theSearchSqlBuilder) { 100 super(theSearchSqlBuilder, theSearchSqlBuilder.addTable("HFJ_SPIDX_TOKEN")); 101 myColumnResId = getTable().addColumn("RES_ID"); 102 myColumnHashIdentity = getTable().addColumn("HASH_IDENTITY"); 103 myColumnHashSystem = getTable().addColumn("HASH_SYS"); 104 myColumnHashSystemAndValue = getTable().addColumn("HASH_SYS_AND_VALUE"); 105 myColumnHashValue = getTable().addColumn("HASH_VALUE"); 106 myColumnSystem = getTable().addColumn("SP_SYSTEM"); 107 myColumnValue = getTable().addColumn("SP_VALUE"); 108 } 109 110 @Override 111 public DbColumn getColumnHashIdentity() { 112 return myColumnHashIdentity; 113 } 114 115 @VisibleForTesting 116 public void setStorageSettingsForUnitTest(JpaStorageSettings theStorageSettings) { 117 myStorageSettings = theStorageSettings; 118 } 119 120 @Override 121 public DbColumn getResourceIdColumn() { 122 return myColumnResId; 123 } 124 125 public Condition createPredicateToken( 126 Collection<IQueryParameterType> theParameters, 127 String theResourceName, 128 String theSpnamePrefix, 129 RuntimeSearchParam theSearchParam, 130 RequestPartitionId theRequestPartitionId) { 131 return createPredicateToken( 132 theParameters, theResourceName, theSpnamePrefix, theSearchParam, null, theRequestPartitionId); 133 } 134 135 public Condition createPredicateToken( 136 Collection<IQueryParameterType> theParameters, 137 String theResourceName, 138 String theSpnamePrefix, 139 RuntimeSearchParam theSearchParam, 140 SearchFilterParser.CompareOperation theOperation, 141 RequestPartitionId theRequestPartitionId) { 142 143 final List<FhirVersionIndependentConcept> codes = new ArrayList<>(); 144 145 String paramName = QueryParameterUtils.getParamNameWithPrefix(theSpnamePrefix, theSearchParam.getName()); 146 147 SearchFilterParser.CompareOperation operation = theOperation; 148 149 TokenParamModifier modifier = null; 150 for (IQueryParameterType nextParameter : theParameters) { 151 152 String code; 153 String system; 154 if (nextParameter instanceof TokenParam) { 155 TokenParam id = (TokenParam) nextParameter; 156 system = id.getSystem(); 157 code = id.getValue(); 158 modifier = id.getModifier(); 159 } else if (nextParameter instanceof BaseIdentifierDt) { 160 BaseIdentifierDt id = (BaseIdentifierDt) nextParameter; 161 system = id.getSystemElement().getValueAsString(); 162 code = (id.getValueElement().getValue()); 163 } else if (nextParameter instanceof BaseCodingDt) { 164 BaseCodingDt id = (BaseCodingDt) nextParameter; 165 system = id.getSystemElement().getValueAsString(); 166 code = (id.getCodeElement().getValue()); 167 } else if (nextParameter instanceof NumberParam) { 168 NumberParam number = (NumberParam) nextParameter; 169 system = null; 170 code = number.getValueAsQueryToken(getFhirContext()); 171 } else { 172 throw new IllegalArgumentException(Msg.code(1236) + "Invalid token type: " + nextParameter.getClass()); 173 } 174 175 if (system != null && system.length() > ResourceIndexedSearchParamToken.MAX_LENGTH) { 176 ourLog.info( 177 "Parameter[{}] has system ({}) that is longer than maximum ({}) so will truncate: {} ", 178 paramName, 179 system.length(), 180 ResourceIndexedSearchParamToken.MAX_LENGTH, 181 system); 182 } 183 184 if (code != null && code.length() > ResourceIndexedSearchParamToken.MAX_LENGTH) { 185 ourLog.info( 186 "Parameter[{}] has code ({}) that is longer than maximum ({}) so will truncate: {} ", 187 paramName, 188 code.length(), 189 ResourceIndexedSearchParamToken.MAX_LENGTH, 190 code); 191 } 192 193 /* 194 * Process token modifiers (:in, :below, :above) 195 */ 196 197 if (modifier == TokenParamModifier.IN || modifier == TokenParamModifier.NOT_IN) { 198 if (myContext.getVersion().getVersion().isNewerThan(FhirVersionEnum.DSTU2)) { 199 ValueSetExpansionOptions valueSetExpansionOptions = new ValueSetExpansionOptions(); 200 valueSetExpansionOptions.setCount(myStorageSettings.getMaximumExpansionSize()); 201 IValidationSupport.ValueSetExpansionOutcome expanded = myValidationSupport.expandValueSet( 202 new ValidationSupportContext(myValidationSupport), valueSetExpansionOptions, code); 203 204 codes.addAll(extractValueSetCodes(expanded.getValueSet())); 205 } else { 206 codes.addAll(myTerminologySvc.expandValueSetIntoConceptList(null, code)); 207 } 208 if (modifier == TokenParamModifier.NOT_IN) { 209 operation = SearchFilterParser.CompareOperation.ne; 210 } 211 } else if (modifier == TokenParamModifier.ABOVE) { 212 system = determineSystemIfMissing(theSearchParam, code, system); 213 validateHaveSystemAndCodeForToken(paramName, code, system); 214 codes.addAll(myTerminologySvc.findCodesAbove(system, code)); 215 } else if (modifier == TokenParamModifier.BELOW) { 216 system = determineSystemIfMissing(theSearchParam, code, system); 217 validateHaveSystemAndCodeForToken(paramName, code, system); 218 codes.addAll(myTerminologySvc.findCodesBelow(system, code)); 219 } else if (modifier == TokenParamModifier.OF_TYPE) { 220 if (!myStorageSettings.isIndexIdentifierOfType()) { 221 throw new MethodNotAllowedException( 222 Msg.code(2012) + "The :of-type modifier is not enabled on this server"); 223 } 224 if (isBlank(system) || isBlank(code)) { 225 throw new InvalidRequestException(Msg.code(2013) + "Invalid parameter value for :of-type query"); 226 } 227 int pipeIdx = code.indexOf('|'); 228 if (pipeIdx < 1 || pipeIdx == code.length() - 1) { 229 throw new InvalidRequestException(Msg.code(2014) + "Invalid parameter value for :of-type query"); 230 } 231 232 paramName = paramName + Constants.PARAMQUALIFIER_TOKEN_OF_TYPE; 233 codes.add(new FhirVersionIndependentConcept(system, code)); 234 } else { 235 if (modifier == TokenParamModifier.NOT && operation == null) { 236 operation = SearchFilterParser.CompareOperation.ne; 237 } 238 codes.add(new FhirVersionIndependentConcept(system, code)); 239 } 240 } 241 242 List<FhirVersionIndependentConcept> sortedCodesList = codes.stream() 243 .filter(t -> t.getCode() != null || t.getSystem() != null) 244 .sorted() 245 .distinct() 246 .collect(Collectors.toList()); 247 248 if (codes.isEmpty()) { 249 // This will never match anything 250 setMatchNothing(); 251 return null; 252 } 253 254 Condition predicate; 255 if (operation == SearchFilterParser.CompareOperation.ne) { 256 257 /* 258 * For a token :not search, we look for index rows that have the right identity (i.e. it's the right resource and 259 * param name) but not the actual provided token value. 260 */ 261 262 long hashIdentity = BaseResourceIndexedSearchParam.calculateHashIdentity( 263 getPartitionSettings(), theRequestPartitionId, theResourceName, paramName); 264 Condition hashIdentityPredicate = 265 BinaryCondition.equalTo(getColumnHashIdentity(), generatePlaceholder(hashIdentity)); 266 267 Condition hashValuePredicate = createPredicateOrList(theResourceName, paramName, sortedCodesList, false); 268 predicate = QueryParameterUtils.toAndPredicate(hashIdentityPredicate, hashValuePredicate); 269 270 } else { 271 272 predicate = createPredicateOrList(theResourceName, paramName, sortedCodesList, true); 273 274 if (myStorageSettings.isIncludeHashIdentityForTokenSearches()) { 275 long hashIdentity = BaseResourceIndexedSearchParam.calculateHashIdentity( 276 getPartitionSettings(), theRequestPartitionId, theResourceName, paramName); 277 Condition hashIdentityPredicate = 278 BinaryCondition.equalTo(getColumnHashIdentity(), generatePlaceholder(hashIdentity)); 279 predicate = QueryParameterUtils.toAndPredicate(hashIdentityPredicate, predicate); 280 } 281 } 282 283 return predicate; 284 } 285 286 private List<FhirVersionIndependentConcept> extractValueSetCodes(IBaseResource theValueSet) { 287 List<FhirVersionIndependentConcept> retVal = new ArrayList<>(); 288 289 RuntimeResourceDefinition vsDef = myContext.getResourceDefinition("ValueSet"); 290 BaseRuntimeChildDefinition expansionChild = vsDef.getChildByName("expansion"); 291 Optional<IBase> expansionOpt = expansionChild.getAccessor().getFirstValueOrNull(theValueSet); 292 if (expansionOpt.isPresent()) { 293 IBase expansion = expansionOpt.get(); 294 BaseRuntimeElementCompositeDefinition<?> expansionDef = 295 (BaseRuntimeElementCompositeDefinition<?>) myContext.getElementDefinition(expansion.getClass()); 296 BaseRuntimeChildDefinition containsChild = expansionDef.getChildByName("contains"); 297 List<IBase> contains = containsChild.getAccessor().getValues(expansion); 298 299 BaseRuntimeChildDefinition.IAccessor systemAccessor = null; 300 BaseRuntimeChildDefinition.IAccessor codeAccessor = null; 301 for (IBase nextContains : contains) { 302 if (systemAccessor == null) { 303 systemAccessor = myContext 304 .getElementDefinition(nextContains.getClass()) 305 .getChildByName("system") 306 .getAccessor(); 307 } 308 if (codeAccessor == null) { 309 codeAccessor = myContext 310 .getElementDefinition(nextContains.getClass()) 311 .getChildByName("code") 312 .getAccessor(); 313 } 314 String system = systemAccessor 315 .getFirstValueOrNull(nextContains) 316 .map(t -> (IPrimitiveType<?>) t) 317 .map(t -> t.getValueAsString()) 318 .orElse(null); 319 String code = codeAccessor 320 .getFirstValueOrNull(nextContains) 321 .map(t -> (IPrimitiveType<?>) t) 322 .map(t -> t.getValueAsString()) 323 .orElse(null); 324 if (isNotBlank(system) && isNotBlank(code)) { 325 retVal.add(new FhirVersionIndependentConcept(system, code)); 326 } 327 } 328 } 329 330 return retVal; 331 } 332 333 private String determineSystemIfMissing(RuntimeSearchParam theSearchParam, String code, String theSystem) { 334 String retVal = theSystem; 335 if (retVal == null) { 336 if (theSearchParam != null) { 337 Set<String> valueSetUris = Sets.newHashSet(); 338 for (String nextPath : theSearchParam.getPathsSplitForResourceType(getResourceType())) { 339 Class<? extends IBaseResource> type = getFhirContext() 340 .getResourceDefinition(getResourceType()) 341 .getImplementingClass(); 342 BaseRuntimeChildDefinition def = 343 getFhirContext().newTerser().getDefinition(type, nextPath); 344 if (def instanceof BaseRuntimeDeclaredChildDefinition) { 345 String valueSet = ((BaseRuntimeDeclaredChildDefinition) def).getBindingValueSet(); 346 if (isNotBlank(valueSet)) { 347 valueSetUris.add(valueSet); 348 } 349 } 350 } 351 if (valueSetUris.size() == 1) { 352 String valueSet = valueSetUris.iterator().next(); 353 ValueSetExpansionOptions options = new ValueSetExpansionOptions().setFailOnMissingCodeSystem(false); 354 List<FhirVersionIndependentConcept> candidateCodes = 355 myTerminologySvc.expandValueSetIntoConceptList(options, valueSet); 356 for (FhirVersionIndependentConcept nextCandidate : candidateCodes) { 357 if (nextCandidate.getCode().equals(code)) { 358 retVal = nextCandidate.getSystem(); 359 break; 360 } 361 } 362 } 363 } 364 } 365 return retVal; 366 } 367 368 public DbColumn getColumnSystem() { 369 return myColumnSystem; 370 } 371 372 public DbColumn getColumnValue() { 373 return myColumnValue; 374 } 375 376 private void validateHaveSystemAndCodeForToken(String theParamName, String theCode, String theSystem) { 377 String systemDesc = defaultIfBlank(theSystem, "(missing)"); 378 String codeDesc = defaultIfBlank(theCode, "(missing)"); 379 if (isBlank(theCode)) { 380 String msg = getFhirContext() 381 .getLocalizer() 382 .getMessage( 383 TokenPredicateBuilder.class, 384 "invalidCodeMissingSystem", 385 theParamName, 386 systemDesc, 387 codeDesc); 388 throw new InvalidRequestException(Msg.code(1239) + msg); 389 } 390 if (isBlank(theSystem)) { 391 String msg = getFhirContext() 392 .getLocalizer() 393 .getMessage( 394 TokenPredicateBuilder.class, "invalidCodeMissingCode", theParamName, systemDesc, codeDesc); 395 throw new InvalidRequestException(Msg.code(1240) + msg); 396 } 397 } 398 399 private Condition createPredicateOrList( 400 String theResourceType, 401 String theSearchParamName, 402 List<FhirVersionIndependentConcept> theCodes, 403 boolean theWantEquals) { 404 Condition[] conditions = new Condition[theCodes.size()]; 405 406 Long[] hashes = new Long[theCodes.size()]; 407 DbColumn[] columns = new DbColumn[theCodes.size()]; 408 boolean haveMultipleColumns = false; 409 for (int i = 0; i < conditions.length; i++) { 410 411 FhirVersionIndependentConcept nextToken = theCodes.get(i); 412 long hash; 413 DbColumn column; 414 if (nextToken.getSystem() == null) { 415 hash = ResourceIndexedSearchParamToken.calculateHashValue( 416 getPartitionSettings(), 417 getRequestPartitionId(), 418 theResourceType, 419 theSearchParamName, 420 nextToken.getCode()); 421 column = myColumnHashValue; 422 } else if (isBlank(nextToken.getCode())) { 423 hash = ResourceIndexedSearchParamToken.calculateHashSystem( 424 getPartitionSettings(), 425 getRequestPartitionId(), 426 theResourceType, 427 theSearchParamName, 428 nextToken.getSystem()); 429 column = myColumnHashSystem; 430 } else { 431 hash = ResourceIndexedSearchParamToken.calculateHashSystemAndValue( 432 getPartitionSettings(), 433 getRequestPartitionId(), 434 theResourceType, 435 theSearchParamName, 436 nextToken.getSystem(), 437 nextToken.getCode()); 438 column = myColumnHashSystemAndValue; 439 } 440 hashes[i] = hash; 441 columns[i] = column; 442 if (i > 0 && columns[0] != columns[i]) { 443 haveMultipleColumns = true; 444 } 445 } 446 447 if (!haveMultipleColumns && conditions.length > 1) { 448 List<Long> values = Arrays.asList(hashes); 449 return QueryParameterUtils.toEqualToOrInPredicate(columns[0], generatePlaceholders(values), !theWantEquals); 450 } 451 452 for (int i = 0; i < conditions.length; i++) { 453 String valuePlaceholder = generatePlaceholder(hashes[i]); 454 if (theWantEquals) { 455 conditions[i] = BinaryCondition.equalTo(columns[i], valuePlaceholder); 456 } else { 457 conditions[i] = BinaryCondition.notEqualTo(columns[i], valuePlaceholder); 458 } 459 } 460 if (conditions.length > 1) { 461 if (theWantEquals) { 462 return QueryParameterUtils.toOrPredicate(conditions); 463 } else { 464 return QueryParameterUtils.toAndPredicate(conditions); 465 } 466 } else { 467 return conditions[0]; 468 } 469 } 470}