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