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.dao.search; 021 022import ca.uhn.fhir.context.FhirContext; 023import ca.uhn.fhir.context.RuntimeSearchParam; 024import ca.uhn.fhir.i18n.Msg; 025import ca.uhn.fhir.jpa.model.entity.NormalizedQuantitySearchLevel; 026import ca.uhn.fhir.jpa.model.entity.StorageSettings; 027import ca.uhn.fhir.jpa.model.util.UcumServiceUtil; 028import ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers; 029import ca.uhn.fhir.model.api.IQueryParameterType; 030import ca.uhn.fhir.model.api.TemporalPrecisionEnum; 031import ca.uhn.fhir.rest.api.Constants; 032import ca.uhn.fhir.rest.param.CompositeParam; 033import ca.uhn.fhir.rest.param.DateParam; 034import ca.uhn.fhir.rest.param.DateRangeParam; 035import ca.uhn.fhir.rest.param.NumberParam; 036import ca.uhn.fhir.rest.param.ParamPrefixEnum; 037import ca.uhn.fhir.rest.param.QuantityParam; 038import ca.uhn.fhir.rest.param.ReferenceParam; 039import ca.uhn.fhir.rest.param.StringParam; 040import ca.uhn.fhir.rest.param.TokenParam; 041import ca.uhn.fhir.rest.param.UriParam; 042import ca.uhn.fhir.util.DateUtils; 043import ca.uhn.fhir.util.NumericParamRangeUtil; 044import ca.uhn.fhir.util.StringUtil; 045import jakarta.annotation.Nonnull; 046import org.apache.commons.collections4.CollectionUtils; 047import org.apache.commons.lang3.ObjectUtils; 048import org.apache.commons.lang3.StringUtils; 049import org.apache.commons.lang3.Validate; 050import org.apache.commons.lang3.tuple.Pair; 051import org.hibernate.search.engine.search.common.BooleanOperator; 052import org.hibernate.search.engine.search.predicate.dsl.BooleanPredicateClausesStep; 053import org.hibernate.search.engine.search.predicate.dsl.PredicateFinalStep; 054import org.hibernate.search.engine.search.predicate.dsl.RangePredicateOptionsStep; 055import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory; 056import org.hibernate.search.engine.search.predicate.dsl.WildcardPredicateOptionsStep; 057import org.slf4j.Logger; 058import org.slf4j.LoggerFactory; 059 060import java.math.BigDecimal; 061import java.time.Instant; 062import java.util.Arrays; 063import java.util.HashSet; 064import java.util.List; 065import java.util.Locale; 066import java.util.Objects; 067import java.util.Optional; 068import java.util.Set; 069import java.util.stream.Collectors; 070 071import static ca.uhn.fhir.jpa.dao.search.PathContext.joinPath; 072import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.IDX_STRING_EXACT; 073import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.IDX_STRING_NORMALIZED; 074import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.IDX_STRING_TEXT; 075import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.INDEX_TYPE_QUANTITY; 076import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.INDEX_TYPE_STRING; 077import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.INDEX_TYPE_TOKEN; 078import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.NUMBER_VALUE; 079import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_CODE; 080import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_CODE_NORM; 081import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_SYSTEM; 082import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_VALUE; 083import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_VALUE_NORM; 084import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.SEARCH_PARAM_ROOT; 085import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.TOKEN_CODE; 086import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.TOKEN_SYSTEM; 087import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.TOKEN_SYSTEM_CODE; 088import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.URI_VALUE; 089import static org.apache.commons.lang3.StringUtils.isNotBlank; 090 091public class ExtendedHSearchClauseBuilder { 092 private static final Logger ourLog = LoggerFactory.getLogger(ExtendedHSearchClauseBuilder.class); 093 094 private static final double QTY_APPROX_TOLERANCE_PERCENT = .10; 095 public static final String PATH_JOINER = "."; 096 097 final FhirContext myFhirContext; 098 public final BooleanPredicateClausesStep<?> myRootClause; 099 public final StorageSettings myStorageSettings; 100 final PathContext myRootContext; 101 102 final List<TemporalPrecisionEnum> ordinalSearchPrecisions = 103 Arrays.asList(TemporalPrecisionEnum.YEAR, TemporalPrecisionEnum.MONTH, TemporalPrecisionEnum.DAY); 104 105 public ExtendedHSearchClauseBuilder( 106 FhirContext myFhirContext, 107 StorageSettings theStorageSettings, 108 BooleanPredicateClausesStep<?> theRootClause, 109 SearchPredicateFactory thePredicateFactory) { 110 this.myFhirContext = myFhirContext; 111 this.myStorageSettings = theStorageSettings; 112 this.myRootClause = theRootClause; 113 myRootContext = PathContext.buildRootContext(theRootClause, thePredicateFactory); 114 } 115 116 /** 117 * Restrict search to resources of a type 118 * @param theResourceType the type to match. e.g. "Observation" 119 */ 120 public void addResourceTypeClause(String theResourceType) { 121 myRootClause.must(myRootContext.match().field("myResourceType").matching(theResourceType)); 122 } 123 124 @Nonnull 125 private Set<String> extractOrStringParams(List<? extends IQueryParameterType> nextAnd) { 126 Set<String> terms = new HashSet<>(); 127 for (IQueryParameterType nextOr : nextAnd) { 128 String nextValueTrimmed; 129 if (nextOr instanceof StringParam) { 130 StringParam nextOrString = (StringParam) nextOr; 131 nextValueTrimmed = 132 StringUtils.defaultString(nextOrString.getValue()).trim(); 133 } else if (nextOr instanceof TokenParam) { 134 TokenParam nextOrToken = (TokenParam) nextOr; 135 nextValueTrimmed = nextOrToken.getValue(); 136 } else if (nextOr instanceof ReferenceParam) { 137 ReferenceParam referenceParam = (ReferenceParam) nextOr; 138 nextValueTrimmed = referenceParam.getValue(); 139 if (nextValueTrimmed.contains("/_history")) { 140 nextValueTrimmed = nextValueTrimmed.substring(0, nextValueTrimmed.indexOf("/_history")); 141 } 142 } else { 143 throw new IllegalArgumentException( 144 Msg.code(1088) + "Unsupported full-text param type: " + nextOr.getClass()); 145 } 146 if (isNotBlank(nextValueTrimmed)) { 147 terms.add(nextValueTrimmed); 148 } 149 } 150 return terms; 151 } 152 153 public void addTokenUnmodifiedSearch(String theSearchParamName, List<List<IQueryParameterType>> theAndOrTerms) { 154 if (CollectionUtils.isEmpty(theAndOrTerms)) { 155 return; 156 } 157 PathContext spContext = contextForFlatSP(theSearchParamName); 158 for (List<? extends IQueryParameterType> nextAnd : theAndOrTerms) { 159 160 ourLog.debug("addTokenUnmodifiedSearch {} {}", theSearchParamName, nextAnd); 161 List<? extends PredicateFinalStep> clauses = nextAnd.stream() 162 .map(orTerm -> buildTokenUnmodifiedMatchOn(orTerm, spContext)) 163 .collect(Collectors.toList()); 164 PredicateFinalStep finalClause = spContext.orPredicateOrSingle(clauses); 165 166 myRootClause.must(finalClause); 167 } 168 } 169 170 private PathContext contextForFlatSP(String theSearchParamName) { 171 String path = joinPath(SEARCH_PARAM_ROOT, theSearchParamName); 172 return myRootContext.forAbsolutePath(path); 173 } 174 175 private PredicateFinalStep buildTokenUnmodifiedMatchOn(IQueryParameterType orTerm, PathContext thePathContext) { 176 String pathPrefix = thePathContext.getContextPath(); 177 if (orTerm instanceof TokenParam) { 178 TokenParam token = (TokenParam) orTerm; 179 if (StringUtils.isBlank(token.getSystem())) { 180 // bare value 181 return thePathContext 182 .match() 183 .field(joinPath(pathPrefix, INDEX_TYPE_TOKEN, TOKEN_CODE)) 184 .matching(token.getValue()); 185 } else if (StringUtils.isBlank(token.getValue())) { 186 // system without value 187 return thePathContext 188 .match() 189 .field(joinPath(pathPrefix, INDEX_TYPE_TOKEN, TOKEN_SYSTEM)) 190 .matching(token.getSystem()); 191 } else { 192 // system + value 193 return thePathContext 194 .match() 195 .field(joinPath(pathPrefix, INDEX_TYPE_TOKEN, TOKEN_SYSTEM_CODE)) 196 .matching(token.getValueAsQueryToken(this.myFhirContext)); 197 } 198 } else if (orTerm instanceof StringParam) { 199 // MB I don't quite understand why FhirResourceDaoR4SearchNoFtTest.testSearchByIdParamWrongType() uses 200 // String but here we are 201 StringParam string = (StringParam) orTerm; 202 // treat a string as a code with no system (like _id) 203 return thePathContext 204 .match() 205 .field(joinPath(pathPrefix, INDEX_TYPE_TOKEN, TOKEN_CODE)) 206 .matching(string.getValue()); 207 } else { 208 throw new IllegalArgumentException(Msg.code(1089) + "Unexpected param type for token search-param: " 209 + orTerm.getClass().getName()); 210 } 211 } 212 213 public void addStringTextSearch(String theSearchParamName, List<List<IQueryParameterType>> stringAndOrTerms) { 214 if (CollectionUtils.isEmpty(stringAndOrTerms)) { 215 return; 216 } 217 String fieldName; 218 switch (theSearchParamName) { 219 // _content and _text were here first, and don't obey our mapping. 220 // Leave them as-is for backwards compatibility. 221 case Constants.PARAM_CONTENT: 222 fieldName = "myContentText"; 223 break; 224 case Constants.PARAM_TEXT: 225 fieldName = "myNarrativeText"; 226 break; 227 default: 228 fieldName = joinPath(SEARCH_PARAM_ROOT, theSearchParamName, INDEX_TYPE_STRING, IDX_STRING_TEXT); 229 break; 230 } 231 232 for (List<? extends IQueryParameterType> nextOrList : stringAndOrTerms) { 233 Set<String> orTerms = TermHelper.makePrefixSearchTerm(extractOrStringParams(nextOrList)); 234 ourLog.debug("addStringTextSearch {}, {}", theSearchParamName, orTerms); 235 if (!orTerms.isEmpty()) { 236 String query = orTerms.stream().map(s -> "( " + s + " )").collect(Collectors.joining(" | ")); 237 myRootClause.must(myRootContext 238 .simpleQueryString() 239 .field(fieldName) 240 .matching(query) 241 .defaultOperator( 242 BooleanOperator 243 .AND)); // term value may contain multiple tokens. Require all of them to be 244 // present. 245 } else { 246 ourLog.warn("No Terms found in query parameter {}", nextOrList); 247 } 248 } 249 } 250 251 public void addStringExactSearch(String theSearchParamName, List<List<IQueryParameterType>> theStringAndOrTerms) { 252 String fieldPath = joinPath(SEARCH_PARAM_ROOT, theSearchParamName, INDEX_TYPE_STRING, IDX_STRING_EXACT); 253 254 for (List<? extends IQueryParameterType> nextAnd : theStringAndOrTerms) { 255 Set<String> terms = extractOrStringParams(nextAnd); 256 ourLog.debug("addStringExactSearch {} {}", theSearchParamName, terms); 257 List<? extends PredicateFinalStep> orTerms = terms.stream() 258 .map(s -> myRootContext.match().field(fieldPath).matching(s)) 259 .collect(Collectors.toList()); 260 261 myRootClause.must(myRootContext.orPredicateOrSingle(orTerms)); 262 } 263 } 264 265 public void addStringContainsSearch( 266 String theSearchParamName, List<List<IQueryParameterType>> theStringAndOrTerms) { 267 String fieldPath = joinPath(SEARCH_PARAM_ROOT, theSearchParamName, INDEX_TYPE_STRING, IDX_STRING_NORMALIZED); 268 for (List<? extends IQueryParameterType> nextAnd : theStringAndOrTerms) { 269 Set<String> terms = extractOrStringParams(nextAnd); 270 ourLog.debug("addStringContainsSearch {} {}", theSearchParamName, terms); 271 List<? extends PredicateFinalStep> orTerms = terms.stream() 272 // wildcard is a term-level query, so queries aren't analyzed. Do our own normalization first. 273 .map(this::normalize) 274 .map(s -> myRootContext.wildcard().field(fieldPath).matching("*" + s + "*")) 275 .collect(Collectors.toList()); 276 277 myRootClause.must(myRootContext.orPredicateOrSingle(orTerms)); 278 } 279 } 280 281 /** 282 * Normalize the string to match our standardAnalyzer. 283 * @see HapiHSearchAnalysisConfigurers.HapiLuceneAnalysisConfigurer#STANDARD_ANALYZER 284 * 285 * @param theString the raw string 286 * @return a case and accent normalized version of the input 287 */ 288 @Nonnull 289 private String normalize(String theString) { 290 return StringUtil.normalizeStringForSearchIndexing(theString).toLowerCase(Locale.ROOT); 291 } 292 293 public void addStringUnmodifiedSearch( 294 String theSearchParamName, List<List<IQueryParameterType>> theStringAndOrTerms) { 295 PathContext context = contextForFlatSP(theSearchParamName); 296 for (List<? extends IQueryParameterType> nextOrList : theStringAndOrTerms) { 297 Set<String> terms = extractOrStringParams(nextOrList); 298 ourLog.debug("addStringUnmodifiedSearch {} {}", theSearchParamName, terms); 299 List<PredicateFinalStep> orTerms = terms.stream() 300 .map(s -> buildStringUnmodifiedClause(s, context)) 301 .collect(Collectors.toList()); 302 303 myRootClause.must(context.orPredicateOrSingle(orTerms)); 304 } 305 } 306 307 private WildcardPredicateOptionsStep<?> buildStringUnmodifiedClause(String theString, PathContext theContext) { 308 return theContext 309 .wildcard() 310 .field(joinPath(theContext.getContextPath(), INDEX_TYPE_STRING, IDX_STRING_NORMALIZED)) 311 // wildcard is a term-level query, so it isn't analyzed. Do our own case-folding to match the 312 // normStringAnalyzer 313 .matching(normalize(theString) + "*"); 314 } 315 316 public void addReferenceUnchainedSearch( 317 String theSearchParamName, List<List<IQueryParameterType>> theReferenceAndOrTerms) { 318 String fieldPath = joinPath(SEARCH_PARAM_ROOT, theSearchParamName, "reference", "value"); 319 for (List<? extends IQueryParameterType> nextAnd : theReferenceAndOrTerms) { 320 Set<String> terms = extractOrStringParams(nextAnd); 321 ourLog.trace("reference unchained search {}", terms); 322 323 List<? extends PredicateFinalStep> orTerms = terms.stream() 324 .map(s -> myRootContext.match().field(fieldPath).matching(s)) 325 .collect(Collectors.toList()); 326 327 myRootClause.must(myRootContext.orPredicateOrSingle(orTerms)); 328 } 329 } 330 331 /** 332 * Create date clause from date params. The date lower and upper bounds are taken 333 * into considertion when generating date query ranges 334 * 335 * <p>Example 1 ('eq' prefix/empty): <code>http://fhirserver/Observation?date=eq2020</code> 336 * would generate the following search clause 337 * <pre> 338 * {@code 339 * { 340 * "bool": { 341 * "must": [{ 342 * "range": { 343 * "sp.date.dt.lower-ord": { "gte": "20200101" } 344 * } 345 * }, { 346 * "range": { 347 * "sp.date.dt.upper-ord": { "lte": "20201231" } 348 * } 349 * }] 350 * } 351 * } 352 * } 353 * </pre> 354 * 355 * <p>Example 2 ('gt' prefix): <code>http://fhirserver/Observation?date=gt2020-01-01T08:00:00.000</code> 356 * <p>No timezone in the query will be taken as localdatetime(for e.g MST/UTC-07:00 in this case) converted to UTC before comparison</p> 357 * <pre> 358 * {@code 359 * { 360 * "range":{ 361 * "sp.date.dt.upper":{ "gt": "2020-01-01T15:00:00.000000000Z" } 362 * } 363 * } 364 * } 365 * </pre> 366 * 367 * <p>Example 3 between dates: <code>http://fhirserver/Observation?date=ge2010-01-01&date=le2020-01</code></p> 368 * <pre> 369 * {@code 370 * { 371 * "range":{ 372 * "sp.date.dt.upper-ord":{ "gte":"20100101" } 373 * }, 374 * "range":{ 375 * "sp.date.dt.lower-ord":{ "lte":"20200101" } 376 * } 377 * } 378 * } 379 * </pre> 380 * 381 * <p>Example 4 not equal: <code>http://fhirserver/Observation?date=ne2021</code></p> 382 * <pre> 383 * {@code 384 * { 385 * "bool": { 386 * "should": [{ 387 * "range": { 388 * "sp.date.dt.upper-ord": { "lt": "20210101" } 389 * } 390 * }, { 391 * "range": { 392 * "sp.date.dt.lower-ord": { "gt": "20211231" } 393 * } 394 * }], 395 * "minimum_should_match": "1" 396 * } 397 * } 398 * } 399 * </pre> 400 * 401 * @param theSearchParamName e.g code 402 * @param theDateAndOrTerms The and/or list of DateParam values 403 * 404 * buildDateTermClause(subComponentPath, value); 405 */ 406 public void addDateUnmodifiedSearch(String theSearchParamName, List<List<IQueryParameterType>> theDateAndOrTerms) { 407 for (List<? extends IQueryParameterType> nextOrList : theDateAndOrTerms) { 408 409 PathContext spContext = contextForFlatSP(theSearchParamName); 410 411 List<PredicateFinalStep> clauses = nextOrList.stream() 412 .map(d -> buildDateTermClause(d, spContext)) 413 .collect(Collectors.toList()); 414 415 myRootClause.must(myRootContext.orPredicateOrSingle(clauses)); 416 } 417 } 418 419 private PredicateFinalStep buildDateTermClause(IQueryParameterType theQueryParameter, PathContext theSpContext) { 420 DateParam dateParam = (DateParam) theQueryParameter; 421 boolean isOrdinalSearch = ordinalSearchPrecisions.contains(dateParam.getPrecision()); 422 return isOrdinalSearch 423 ? generateDateOrdinalSearchTerms(dateParam, theSpContext) 424 : generateDateInstantSearchTerms(dateParam, theSpContext); 425 } 426 427 private PredicateFinalStep generateDateOrdinalSearchTerms(DateParam theDateParam, PathContext theSpContext) { 428 429 String lowerOrdinalField = joinPath(theSpContext.getContextPath(), "dt", "lower-ord"); 430 String upperOrdinalField = joinPath(theSpContext.getContextPath(), "dt", "upper-ord"); 431 int lowerBoundAsOrdinal; 432 int upperBoundAsOrdinal; 433 ParamPrefixEnum prefix = theDateParam.getPrefix(); 434 435 // default when handling 'Day' temporal types 436 lowerBoundAsOrdinal = upperBoundAsOrdinal = DateUtils.convertDateToDayInteger(theDateParam.getValue()); 437 TemporalPrecisionEnum precision = theDateParam.getPrecision(); 438 // complete the date from 'YYYY' and 'YYYY-MM' temporal types 439 if (precision == TemporalPrecisionEnum.YEAR || precision == TemporalPrecisionEnum.MONTH) { 440 Pair<String, String> completedDate = DateUtils.getCompletedDate(theDateParam.getValueAsString()); 441 lowerBoundAsOrdinal = Integer.parseInt(completedDate.getLeft().replace("-", "")); 442 upperBoundAsOrdinal = Integer.parseInt(completedDate.getRight().replace("-", "")); 443 } 444 445 if (Objects.isNull(prefix) || prefix == ParamPrefixEnum.EQUAL) { 446 // For equality prefix we would like the date to fall between the lower and upper bound 447 List<? extends PredicateFinalStep> predicateSteps = Arrays.asList( 448 theSpContext.range().field(lowerOrdinalField).atLeast(lowerBoundAsOrdinal), 449 theSpContext.range().field(upperOrdinalField).atMost(upperBoundAsOrdinal)); 450 BooleanPredicateClausesStep<?> booleanStep = theSpContext.bool(); 451 predicateSteps.forEach(booleanStep::must); 452 return booleanStep; 453 } else if (ParamPrefixEnum.GREATERTHAN == prefix || ParamPrefixEnum.STARTS_AFTER == prefix) { 454 // TODO JB: more fine tuning needed for STARTS_AFTER 455 return theSpContext.range().field(upperOrdinalField).greaterThan(upperBoundAsOrdinal); 456 } else if (ParamPrefixEnum.GREATERTHAN_OR_EQUALS == prefix) { 457 return theSpContext.range().field(upperOrdinalField).atLeast(upperBoundAsOrdinal); 458 } else if (ParamPrefixEnum.LESSTHAN == prefix || ParamPrefixEnum.ENDS_BEFORE == prefix) { 459 // TODO JB: more fine tuning needed for END_BEFORE 460 return theSpContext.range().field(lowerOrdinalField).lessThan(lowerBoundAsOrdinal); 461 } else if (ParamPrefixEnum.LESSTHAN_OR_EQUALS == prefix) { 462 return theSpContext.range().field(lowerOrdinalField).atMost(lowerBoundAsOrdinal); 463 } else if (ParamPrefixEnum.NOT_EQUAL == prefix) { 464 List<? extends PredicateFinalStep> predicateSteps = Arrays.asList( 465 theSpContext.range().field(upperOrdinalField).lessThan(lowerBoundAsOrdinal), 466 theSpContext.range().field(lowerOrdinalField).greaterThan(upperBoundAsOrdinal)); 467 BooleanPredicateClausesStep<?> booleanStep = theSpContext.bool(); 468 predicateSteps.forEach(booleanStep::should); 469 booleanStep.minimumShouldMatchNumber(1); 470 return booleanStep; 471 } 472 throw new IllegalArgumentException( 473 Msg.code(2255) + "Date search param does not support prefix of type: " + prefix); 474 } 475 476 private PredicateFinalStep generateDateInstantSearchTerms(DateParam theDateParam, PathContext theSpContext) { 477 String lowerInstantField = joinPath(theSpContext.getContextPath(), "dt", "lower"); 478 String upperInstantField = joinPath(theSpContext.getContextPath(), "dt", "upper"); 479 final ParamPrefixEnum prefix = ObjectUtils.defaultIfNull(theDateParam.getPrefix(), ParamPrefixEnum.EQUAL); 480 481 if (ParamPrefixEnum.NOT_EQUAL == prefix) { 482 Instant dateInstant = theDateParam.getValue().toInstant(); 483 List<? extends PredicateFinalStep> predicateSteps = Arrays.asList( 484 theSpContext.range().field(upperInstantField).lessThan(dateInstant), 485 theSpContext.range().field(lowerInstantField).greaterThan(dateInstant)); 486 BooleanPredicateClausesStep<?> booleanStep = theSpContext.bool(); 487 predicateSteps.forEach(booleanStep::should); 488 booleanStep.minimumShouldMatchNumber(1); 489 return booleanStep; 490 } 491 492 // Consider lower and upper bounds for building range predicates 493 DateRangeParam dateRange = new DateRangeParam(theDateParam); 494 Instant lowerBoundAsInstant = Optional.ofNullable(dateRange.getLowerBound()) 495 .map(param -> param.getValue().toInstant()) 496 .orElse(null); 497 Instant upperBoundAsInstant = Optional.ofNullable(dateRange.getUpperBound()) 498 .map(param -> param.getValue().toInstant()) 499 .orElse(null); 500 501 if (prefix == ParamPrefixEnum.EQUAL) { 502 // For equality prefix we would like the date to fall between the lower and upper bound 503 List<? extends PredicateFinalStep> predicateSteps = Arrays.asList( 504 ((SearchPredicateFactory) theSpContext) 505 .range() 506 .field(lowerInstantField) 507 .atLeast(lowerBoundAsInstant), 508 ((SearchPredicateFactory) theSpContext) 509 .range() 510 .field(upperInstantField) 511 .atMost(upperBoundAsInstant)); 512 BooleanPredicateClausesStep<?> booleanStep = ((SearchPredicateFactory) theSpContext).bool(); 513 predicateSteps.forEach(booleanStep::must); 514 return booleanStep; 515 } else if (ParamPrefixEnum.GREATERTHAN == prefix || ParamPrefixEnum.STARTS_AFTER == prefix) { 516 return ((SearchPredicateFactory) theSpContext) 517 .range() 518 .field(upperInstantField) 519 .greaterThan(lowerBoundAsInstant); 520 } else if (ParamPrefixEnum.GREATERTHAN_OR_EQUALS == prefix) { 521 return ((SearchPredicateFactory) theSpContext) 522 .range() 523 .field(upperInstantField) 524 .atLeast(lowerBoundAsInstant); 525 } else if (ParamPrefixEnum.LESSTHAN == prefix || ParamPrefixEnum.ENDS_BEFORE == prefix) { 526 return ((SearchPredicateFactory) theSpContext) 527 .range() 528 .field(lowerInstantField) 529 .lessThan(upperBoundAsInstant); 530 } else if (ParamPrefixEnum.LESSTHAN_OR_EQUALS == prefix) { 531 return ((SearchPredicateFactory) theSpContext) 532 .range() 533 .field(lowerInstantField) 534 .atMost(upperBoundAsInstant); 535 } 536 537 throw new IllegalArgumentException( 538 Msg.code(2256) + "Date search param does not support prefix of type: " + prefix); 539 } 540 541 /** 542 * Differences with DB search: 543 * _ is not all-normalized-or-all-not. Each parameter is applied on quantity or normalized quantity depending on UCUM fitness 544 * _ respects ranges for equal and approximate qualifiers 545 * 546 * Strategy: For each parameter, if it can be canonicalized, it is, and used against 'normalized-value-quantity' index 547 * otherwise it is applied as-is to 'value-quantity' 548 */ 549 public void addQuantityUnmodifiedSearch( 550 String theSearchParamName, List<List<IQueryParameterType>> theQuantityAndOrTerms) { 551 552 for (List<IQueryParameterType> nextOrList : theQuantityAndOrTerms) { 553 // we build quantity predicates in a nested context so we can match units and systems with values. 554 PredicateFinalStep nestedClause = 555 myRootContext.buildPredicateInNestedContext(theSearchParamName, nextedContext -> { 556 List<PredicateFinalStep> orClauses = nextOrList.stream() 557 .map(quantityTerm -> buildQuantityTermClause(quantityTerm, nextedContext)) 558 .collect(Collectors.toList()); 559 560 return nextedContext.orPredicateOrSingle(orClauses); 561 }); 562 563 myRootClause.must(nestedClause); 564 } 565 } 566 567 private BooleanPredicateClausesStep<?> buildQuantityTermClause( 568 IQueryParameterType theQueryParameter, PathContext thePathContext) { 569 570 BooleanPredicateClausesStep<?> quantityClause = ((SearchPredicateFactory) thePathContext).bool(); 571 572 QuantityParam qtyParam = QuantityParam.toQuantityParam(theQueryParameter); 573 ParamPrefixEnum activePrefix = qtyParam.getPrefix() == null ? ParamPrefixEnum.EQUAL : qtyParam.getPrefix(); 574 String quantityElement = joinPath(thePathContext.getContextPath(), INDEX_TYPE_QUANTITY); 575 576 if (myStorageSettings.getNormalizedQuantitySearchLevel() 577 == NormalizedQuantitySearchLevel.NORMALIZED_QUANTITY_SEARCH_SUPPORTED) { 578 QuantityParam canonicalQty = UcumServiceUtil.toCanonicalQuantityOrNull(qtyParam); 579 if (canonicalQty != null) { 580 String valueFieldPath = joinPath(quantityElement, QTY_VALUE_NORM); 581 582 quantityClause.must( 583 buildNumericClause(valueFieldPath, activePrefix, canonicalQty.getValue(), thePathContext)); 584 quantityClause.must(((SearchPredicateFactory) thePathContext) 585 .match() 586 .field(joinPath(quantityElement, QTY_CODE_NORM)) 587 .matching(canonicalQty.getUnits())); 588 return quantityClause; 589 } 590 } 591 592 String valueFieldPath = joinPath(quantityElement, QTY_VALUE); 593 594 quantityClause.must(buildNumericClause(valueFieldPath, activePrefix, qtyParam.getValue(), thePathContext)); 595 596 if (isNotBlank(qtyParam.getSystem())) { 597 quantityClause.must(((SearchPredicateFactory) thePathContext) 598 .match() 599 .field(joinPath(quantityElement, QTY_SYSTEM)) 600 .matching(qtyParam.getSystem())); 601 } 602 603 if (isNotBlank(qtyParam.getUnits())) { 604 quantityClause.must(((SearchPredicateFactory) thePathContext) 605 .match() 606 .field(joinPath(quantityElement, QTY_CODE)) 607 .matching(qtyParam.getUnits())); 608 } 609 610 return quantityClause; 611 } 612 613 /** 614 * Shared helper between quantity and number 615 * @param valueFieldPath The path leading to index node 616 * @param thePrefix the query prefix (e.g. lt). Null means eq 617 * @param theNumberValue the query value 618 * @param thePathContext HSearch builder 619 * @return a query predicate applying the prefix to the value 620 */ 621 @Nonnull 622 private PredicateFinalStep buildNumericClause( 623 String valueFieldPath, ParamPrefixEnum thePrefix, BigDecimal theNumberValue, PathContext thePathContext) { 624 PredicateFinalStep predicate = null; 625 626 double value = theNumberValue.doubleValue(); 627 Pair<BigDecimal, BigDecimal> range = NumericParamRangeUtil.getRange(theNumberValue); 628 double approxTolerance = value * QTY_APPROX_TOLERANCE_PERCENT; 629 630 ParamPrefixEnum activePrefix = thePrefix == null ? ParamPrefixEnum.EQUAL : thePrefix; 631 switch (activePrefix) { 632 // searches for resource quantity between passed param value +/- 10% 633 case APPROXIMATE: 634 predicate = ((SearchPredicateFactory) thePathContext) 635 .range() 636 .field(valueFieldPath) 637 .between(value - approxTolerance, value + approxTolerance); 638 break; 639 640 // searches for resource quantity between passed param value +/- 5% 641 case EQUAL: 642 predicate = ((SearchPredicateFactory) thePathContext) 643 .range() 644 .field(valueFieldPath) 645 .between(range.getLeft().doubleValue(), range.getRight().doubleValue()); 646 break; 647 648 // searches for resource quantity > param value 649 case GREATERTHAN: 650 case STARTS_AFTER: // treated as GREATERTHAN because search doesn't handle ranges 651 predicate = ((SearchPredicateFactory) thePathContext) 652 .range() 653 .field(valueFieldPath) 654 .greaterThan(value); 655 break; 656 657 // searches for resource quantity not < param value 658 case GREATERTHAN_OR_EQUALS: 659 predicate = ((SearchPredicateFactory) thePathContext) 660 .range() 661 .field(valueFieldPath) 662 .atLeast(value); 663 break; 664 665 // searches for resource quantity < param value 666 case LESSTHAN: 667 case ENDS_BEFORE: // treated as LESSTHAN because search doesn't handle ranges 668 predicate = ((SearchPredicateFactory) thePathContext) 669 .range() 670 .field(valueFieldPath) 671 .lessThan(value); 672 break; 673 674 // searches for resource quantity not > param value 675 case LESSTHAN_OR_EQUALS: 676 predicate = ((SearchPredicateFactory) thePathContext) 677 .range() 678 .field(valueFieldPath) 679 .atMost(value); 680 break; 681 682 // NOT_EQUAL: searches for resource quantity not between passed param value +/- 5% 683 case NOT_EQUAL: 684 RangePredicateOptionsStep<?> negRange = ((SearchPredicateFactory) thePathContext) 685 .range() 686 .field(valueFieldPath) 687 .between(range.getLeft().doubleValue(), range.getRight().doubleValue()); 688 predicate = ((SearchPredicateFactory) thePathContext).bool().mustNot(negRange); 689 break; 690 } 691 Validate.notNull(predicate, "Unsupported prefix: %s", thePrefix); 692 return predicate; 693 } 694 695 public void addUriUnmodifiedSearch( 696 String theParamName, List<List<IQueryParameterType>> theUriUnmodifiedAndOrTerms) { 697 PathContext spContext = this.contextForFlatSP(theParamName); 698 for (List<IQueryParameterType> nextOrList : theUriUnmodifiedAndOrTerms) { 699 700 PredicateFinalStep orListPredicate = buildURIClause(nextOrList, spContext); 701 702 myRootClause.must(orListPredicate); 703 } 704 } 705 706 private PredicateFinalStep buildURIClause(List<IQueryParameterType> theOrList, PathContext thePathContext) { 707 List<String> orTerms = 708 theOrList.stream().map(p -> ((UriParam) p).getValue()).collect(Collectors.toList()); 709 710 return ((SearchPredicateFactory) thePathContext) 711 .terms() 712 .field(joinPath(thePathContext.getContextPath(), URI_VALUE)) 713 .matchingAny(orTerms); 714 } 715 716 public void addNumberUnmodifiedSearch( 717 String theParamName, List<List<IQueryParameterType>> theNumberUnmodifiedAndOrTerms) { 718 PathContext pathContext = contextForFlatSP(theParamName); 719 String fieldPath = joinPath(SEARCH_PARAM_ROOT, theParamName, NUMBER_VALUE); 720 721 for (List<IQueryParameterType> nextOrList : theNumberUnmodifiedAndOrTerms) { 722 List<PredicateFinalStep> orTerms = nextOrList.stream() 723 .map(NumberParam.class::cast) 724 .map(orTerm -> buildNumericClause(fieldPath, orTerm.getPrefix(), orTerm.getValue(), pathContext)) 725 .collect(Collectors.toList()); 726 727 myRootClause.must(pathContext.orPredicateOrSingle(orTerms)); 728 } 729 } 730 731 private PredicateFinalStep buildNumericClause(IQueryParameterType theValue, PathContext thePathContext) { 732 NumberParam p = (NumberParam) theValue; 733 734 return buildNumericClause( 735 joinPath(thePathContext.getContextPath(), NUMBER_VALUE), p.getPrefix(), p.getValue(), thePathContext); 736 } 737 738 public void addCompositeUnmodifiedSearch( 739 RuntimeSearchParam theSearchParam, 740 List<RuntimeSearchParam> theSubSearchParams, 741 List<List<IQueryParameterType>> theCompositeAndOrTerms) { 742 for (List<IQueryParameterType> nextOrList : theCompositeAndOrTerms) { 743 744 // The index data for each extracted element is stored in a separate nested HSearch document. 745 // Create a nested parent node for all component predicates. 746 // Each can share this nested beacuse all nested docs share a parent id. 747 748 PredicateFinalStep nestedClause = 749 myRootContext.buildPredicateInNestedContext(theSearchParam.getName(), nestedContext -> { 750 List<PredicateFinalStep> orClauses = nextOrList.stream() 751 .map(term -> computeCompositeTermClause( 752 theSearchParam, theSubSearchParams, (CompositeParam<?, ?>) term, nestedContext)) 753 .collect(Collectors.toList()); 754 755 return nestedContext.orPredicateOrSingle(orClauses); 756 }); 757 myRootClause.must(nestedClause); 758 } 759 } 760 761 /** 762 * Compute the match clause for all the components of theCompositeQueryParam. 763 * 764 * @param theSearchParam The composite SP 765 * @param theSubSearchParams the composite component SPs 766 * @param theCompositeQueryParam the query param values 767 * @param theCompositeContext the root of the nested SP query. 768 */ 769 private PredicateFinalStep computeCompositeTermClause( 770 RuntimeSearchParam theSearchParam, 771 List<RuntimeSearchParam> theSubSearchParams, 772 CompositeParam<?, ?> theCompositeQueryParam, 773 PathContext theCompositeContext) { 774 Validate.notNull(theSearchParam); 775 Validate.notNull(theSubSearchParams); 776 Validate.notNull(theCompositeQueryParam); 777 Validate.isTrue( 778 theSubSearchParams.size() == 2, 779 "Hapi only supports composite search parameters with 2 components. %s %d", 780 theSearchParam.getName(), 781 theSubSearchParams.size()); 782 List<IQueryParameterType> values = theCompositeQueryParam.getValues(); 783 Validate.isTrue( 784 theSubSearchParams.size() == values.size(), 785 "Different number of query components than defined. %s %d %d", 786 theSearchParam.getName(), 787 theSubSearchParams.size(), 788 values.size()); 789 790 // The index data for each extracted element is stored in a separate nested HSearch document. 791 792 // Create a nested parent node for all component predicates. 793 BooleanPredicateClausesStep<?> compositeClause = ((SearchPredicateFactory) theCompositeContext).bool(); 794 for (int i = 0; i < theSubSearchParams.size(); i += 1) { 795 RuntimeSearchParam component = theSubSearchParams.get(i); 796 IQueryParameterType value = values.get(i); 797 PredicateFinalStep subMatch = null; 798 PathContext componentContext = theCompositeContext.getSubComponentContext(component.getName()); 799 switch (component.getParamType()) { 800 case DATE: 801 subMatch = buildDateTermClause(value, componentContext); 802 break; 803 case STRING: 804 subMatch = buildStringUnmodifiedClause(value.getValueAsQueryToken(myFhirContext), componentContext); 805 break; 806 case TOKEN: 807 subMatch = buildTokenUnmodifiedMatchOn(value, componentContext); 808 break; 809 case QUANTITY: 810 subMatch = buildQuantityTermClause(value, componentContext); 811 break; 812 case URI: 813 subMatch = buildURIClause(List.of(value), componentContext); 814 break; 815 case NUMBER: 816 subMatch = buildNumericClause(value, componentContext); 817 break; 818 case REFERENCE: 819 820 default: 821 break; 822 } 823 824 Validate.notNull( 825 subMatch, 826 "Unsupported composite type in %s: %s %s", 827 theSearchParam.getName(), 828 component.getName(), 829 component.getParamType()); 830 compositeClause.must(subMatch); 831 } 832 833 return compositeClause; 834 } 835}