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