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