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