diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/HapiJpaConfig.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/HapiJpaConfig.java index 48782614594..426a590f11d 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/HapiJpaConfig.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/config/HapiJpaConfig.java @@ -27,6 +27,20 @@ import ca.uhn.fhir.jpa.config.util.ValidationSupportConfigUtil; import ca.uhn.fhir.jpa.dao.FulltextSearchSvcImpl; import ca.uhn.fhir.jpa.dao.IFulltextSearchSvc; +import ca.uhn.fhir.jpa.dao.search.HSearchParamHelper; +import ca.uhn.fhir.jpa.dao.search.HSearchParamHelperDate; +import ca.uhn.fhir.jpa.dao.search.HSearchParamHelperNumber; +import ca.uhn.fhir.jpa.dao.search.HSearchParamHelperProviderImpl; +import ca.uhn.fhir.jpa.dao.search.HSearchParamHelperQuantity; +import ca.uhn.fhir.jpa.dao.search.HSearchParamHelperReference; +import ca.uhn.fhir.jpa.dao.search.HSearchParamHelperString; +import ca.uhn.fhir.jpa.dao.search.HSearchParamHelperToken; +import ca.uhn.fhir.jpa.dao.search.HSearchParamHelperUri; +import ca.uhn.fhir.jpa.dao.search.HSearchSortHelperImpl; +import ca.uhn.fhir.jpa.dao.search.IHSearchParamHelperProvider; +import ca.uhn.fhir.jpa.dao.search.IHSearchSortHelper; +import ca.uhn.fhir.jpa.dao.search.IPrefixedNumberPredicateHelperImpl; +import ca.uhn.fhir.jpa.model.entity.ModelConfig; import ca.uhn.fhir.jpa.dao.search.HSearchSortHelperImpl; import ca.uhn.fhir.jpa.dao.search.IHSearchSortHelper; import ca.uhn.fhir.jpa.provider.DaoRegistryResourceSupportedSvc; @@ -57,6 +71,31 @@ public IHSearchSortHelper extendedFulltextSortHelper() { return new HSearchSortHelperImpl(mySearchParamRegistry); } + @Autowired + private ModelConfig myModelConfig; + + @Bean + public IPrefixedNumberPredicateHelperImpl prefixedNumberPredicateHelper() { + return new IPrefixedNumberPredicateHelperImpl(); + } + + @Bean + public IHSearchParamHelperProvider hSearchParamHelperProvider() { + + IPrefixedNumberPredicateHelperImpl prefixedNumberPredicateHelper = prefixedNumberPredicateHelper(); + + // register specific parameter helpers in parent map + HSearchParamHelper.registerChildHelper( new HSearchParamHelperToken() ) ; + HSearchParamHelper.registerChildHelper( new HSearchParamHelperNumber( prefixedNumberPredicateHelper ) ) ; + HSearchParamHelper.registerChildHelper( new HSearchParamHelperUri() ) ; + HSearchParamHelper.registerChildHelper( new HSearchParamHelperReference() ) ; + HSearchParamHelper.registerChildHelper( new HSearchParamHelperQuantity( prefixedNumberPredicateHelper, myModelConfig ) ) ; + HSearchParamHelper.registerChildHelper( new HSearchParamHelperDate() ) ; + HSearchParamHelper.registerChildHelper( new HSearchParamHelperString() ) ; + + return new HSearchParamHelperProviderImpl(); + } + @Bean public IFulltextSearchSvc fullTextSearchSvc() { return new FulltextSearchSvcImpl(); diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/FulltextSearchSvcImpl.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/FulltextSearchSvcImpl.java index 95b86cb97c5..0795375cf9f 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/FulltextSearchSvcImpl.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/FulltextSearchSvcImpl.java @@ -24,10 +24,11 @@ import ca.uhn.fhir.i18n.Msg; import ca.uhn.fhir.jpa.api.config.DaoConfig; import ca.uhn.fhir.jpa.api.svc.IIdHelperService; -import ca.uhn.fhir.jpa.dao.search.ExtendedHSearchClauseBuilder; import ca.uhn.fhir.jpa.dao.search.ExtendedHSearchIndexExtractor; import ca.uhn.fhir.jpa.dao.search.ExtendedHSearchResourceProjection; import ca.uhn.fhir.jpa.dao.search.ExtendedHSearchSearchBuilder; +import ca.uhn.fhir.jpa.dao.search.HSearchClauseProvider; +import ca.uhn.fhir.jpa.dao.search.IHSearchParamHelperProvider; import ca.uhn.fhir.jpa.dao.search.IHSearchSortHelper; import ca.uhn.fhir.jpa.dao.search.LastNOperation; import ca.uhn.fhir.jpa.dao.search.SearchScrollQueryExecutorAdaptor; @@ -41,7 +42,6 @@ import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; import ca.uhn.fhir.jpa.searchparam.extractor.ISearchParamExtractor; import ca.uhn.fhir.jpa.searchparam.extractor.ResourceIndexedSearchParams; -import ca.uhn.fhir.model.api.IQueryParameterType; import ca.uhn.fhir.parser.IParser; import ca.uhn.fhir.rest.api.Constants; import ca.uhn.fhir.rest.api.SortOrderEnum; @@ -96,6 +96,9 @@ public class FulltextSearchSvcImpl implements IFulltextSearchSvc { @Autowired IIdHelperService myIdHelperService; + @Autowired + private IHSearchParamHelperProvider myHSearchParamHelperProvider; + @Autowired ModelConfig myModelConfig; @@ -182,41 +185,21 @@ private SearchScroll getSearchScroll(String theResourceType, SearchParamet ) .where( f -> f.bool(b -> { - ExtendedHSearchClauseBuilder builder = new ExtendedHSearchClauseBuilder(myFhirContext, myModelConfig, b, f); - - /* - * Handle _content parameter (resource body content) - * - * Posterity: - * We do not want the HAPI-FHIR dao's to process the - * _content parameter, so we remove it from the map here - */ - List> contentAndTerms = theParams.remove(Constants.PARAM_CONTENT); - builder.addStringTextSearch(Constants.PARAM_CONTENT, contentAndTerms); - - /* - * Handle _text parameter (resource narrative content) - * - * Posterity: - * We do not want the HAPI-FHIR dao's to process the - * _text parameter, so we remove it from the map here - */ - List> textAndTerms = theParams.remove(Constants.PARAM_TEXT); - builder.addStringTextSearch(Constants.PARAM_TEXT, textAndTerms); + HSearchClauseProvider clauseProvider = new HSearchClauseProvider(myHSearchParamHelperProvider, f, b); if (theReferencingPid != null) { b.must(f.match().field("myResourceLinksField").matching(theReferencingPid.toString())); } if (isNotBlank(theResourceType)) { - builder.addResourceTypeClause(theResourceType); + clauseProvider.addResourceTypeClause(theResourceType); } /* * Handle other supported parameters */ if (myDaoConfig.isAdvancedHSearchIndexing() && theParams.getEverythingMode() == null) { - myAdvancedIndexQueryBuilder.addAndConsumeAdvancedQueryClauses(builder, theResourceType, theParams, mySearchParamRegistry); + myAdvancedIndexQueryBuilder.addAndConsumeAdvancedQueryClauses(clauseProvider, theResourceType, theParams, mySearchParamRegistry); } //DROP EARLY HERE IF BOOL IS EMPTY? @@ -329,7 +312,8 @@ private void ensureElastic() { @Override public List lastN(SearchParameterMap theParams, Integer theMaximumResults) { ensureElastic(); - List pidList = new LastNOperation(getSearchSession(), myFhirContext, myModelConfig, mySearchParamRegistry) + List pidList = new LastNOperation( + getSearchSession(), myFhirContext, myModelConfig, mySearchParamRegistry, myHSearchParamHelperProvider) .executeLastN(theParams, theMaximumResults); return convertLongsToResourcePersistentIds(pidList); } diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/ExtendedHSearchClauseBuilder.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/ExtendedHSearchClauseBuilder.java deleted file mode 100644 index 29e6f35e5f7..00000000000 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/ExtendedHSearchClauseBuilder.java +++ /dev/null @@ -1,699 +0,0 @@ -package ca.uhn.fhir.jpa.dao.search; - -/*- - * #%L - * HAPI FHIR JPA Server - * %% - * Copyright (C) 2014 - 2022 Smile CDR, Inc. - * %% - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * #L% - */ - -import ca.uhn.fhir.context.FhirContext; -import ca.uhn.fhir.i18n.Msg; -import ca.uhn.fhir.jpa.model.entity.ModelConfig; -import ca.uhn.fhir.jpa.model.entity.NormalizedQuantitySearchLevel; -import ca.uhn.fhir.jpa.model.util.UcumServiceUtil; -import ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers; -import ca.uhn.fhir.model.api.IQueryParameterType; -import ca.uhn.fhir.model.api.TemporalPrecisionEnum; -import ca.uhn.fhir.rest.api.Constants; -import ca.uhn.fhir.rest.param.DateParam; -import ca.uhn.fhir.rest.param.DateRangeParam; -import ca.uhn.fhir.rest.param.NumberParam; -import ca.uhn.fhir.rest.param.ParamPrefixEnum; -import ca.uhn.fhir.rest.param.QuantityParam; -import ca.uhn.fhir.rest.param.ReferenceParam; -import ca.uhn.fhir.rest.param.StringParam; -import ca.uhn.fhir.rest.param.TokenParam; -import ca.uhn.fhir.rest.param.UriParam; -import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; -import ca.uhn.fhir.util.DateUtils; -import ca.uhn.fhir.util.StringUtil; -import org.apache.commons.collections4.CollectionUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.tuple.Pair; -import org.hibernate.search.engine.search.common.BooleanOperator; -import org.hibernate.search.engine.search.predicate.dsl.BooleanPredicateClausesStep; -import org.hibernate.search.engine.search.predicate.dsl.PredicateFinalStep; -import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import javax.annotation.Nonnull; -import java.time.Instant; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.IDX_STRING_EXACT; -import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.IDX_STRING_NORMALIZED; -import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.IDX_STRING_TEXT; -import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.NESTED_SEARCH_PARAM_ROOT; -import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.NUMBER_VALUE; -import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_CODE; -import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_CODE_NORM; -import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_PARAM_NAME; -import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_SYSTEM; -import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_VALUE; -import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_VALUE_NORM; -import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.SEARCH_PARAM_ROOT; -import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.URI_VALUE; -import static org.apache.commons.lang3.StringUtils.isNotBlank; - -public class ExtendedHSearchClauseBuilder { - private static final Logger ourLog = LoggerFactory.getLogger(ExtendedHSearchClauseBuilder.class); - - private static final double QTY_APPROX_TOLERANCE_PERCENT = .10; - private static final double QTY_TOLERANCE_PERCENT = .05; - - final FhirContext myFhirContext; - public final SearchPredicateFactory myPredicateFactory; - public final BooleanPredicateClausesStep myRootClause; - public final ModelConfig myModelConfig; - - final List ordinalSearchPrecisions = Arrays.asList(TemporalPrecisionEnum.YEAR, TemporalPrecisionEnum.MONTH, TemporalPrecisionEnum.DAY); - - public ExtendedHSearchClauseBuilder(FhirContext myFhirContext, ModelConfig theModelConfig, - BooleanPredicateClausesStep myRootClause, SearchPredicateFactory myPredicateFactory) { - this.myFhirContext = myFhirContext; - this.myModelConfig = theModelConfig; - this.myRootClause = myRootClause; - this.myPredicateFactory = myPredicateFactory; - } - - /** - * Restrict search to resources of a type - * @param theResourceType the type to match. e.g. "Observation" - */ - public void addResourceTypeClause(String theResourceType) { - myRootClause.must(myPredicateFactory.match().field("myResourceType").matching(theResourceType)); - } - - @Nonnull - private Set extractOrStringParams(List nextAnd) { - Set terms = new HashSet<>(); - for (IQueryParameterType nextOr : nextAnd) { - String nextValueTrimmed; - if (nextOr instanceof StringParam) { - StringParam nextOrString = (StringParam) nextOr; - nextValueTrimmed = StringUtils.defaultString(nextOrString.getValue()).trim(); - } else if (nextOr instanceof TokenParam) { - TokenParam nextOrToken = (TokenParam) nextOr; - nextValueTrimmed = nextOrToken.getValue(); - } else if (nextOr instanceof ReferenceParam) { - ReferenceParam referenceParam = (ReferenceParam) nextOr; - nextValueTrimmed = referenceParam.getValue(); - if (nextValueTrimmed.contains("/_history")) { - nextValueTrimmed = nextValueTrimmed.substring(0, nextValueTrimmed.indexOf("/_history")); - } - } else { - throw new IllegalArgumentException(Msg.code(1088) + "Unsupported full-text param type: " + nextOr.getClass()); - } - if (isNotBlank(nextValueTrimmed)) { - terms.add(nextValueTrimmed); - } - } - return terms; - } - - - /** - * Provide an OR wrapper around a list of predicates. - * Returns the sole predicate if it solo, or wrap as a bool/should for OR semantics. - * - * @param theOrList a list containing at least 1 predicate - * @return a predicate providing or-sematics over the list. - */ - private PredicateFinalStep orPredicateOrSingle(List theOrList) { - PredicateFinalStep finalClause; - if (theOrList.size() == 1) { - finalClause = theOrList.get(0); - } else { - BooleanPredicateClausesStep orClause = myPredicateFactory.bool(); - theOrList.forEach(orClause::should); - finalClause = orClause; - } - return finalClause; - } - - public void addTokenUnmodifiedSearch(String theSearchParamName, List> theAndOrTerms) { - if (CollectionUtils.isEmpty(theAndOrTerms)) { - return; - } - for (List nextAnd : theAndOrTerms) { - - ourLog.debug("addTokenUnmodifiedSearch {} {}", theSearchParamName, nextAnd); - List clauses = nextAnd.stream().map(orTerm -> { - if (orTerm instanceof TokenParam) { - TokenParam token = (TokenParam) orTerm; - if (StringUtils.isBlank(token.getSystem())) { - // bare value - return myPredicateFactory.match().field(getTokenCodeFieldPath(theSearchParamName)).matching(token.getValue()); - } else if (StringUtils.isBlank(token.getValue())) { - // system without value - return myPredicateFactory.match().field(SEARCH_PARAM_ROOT + "." + theSearchParamName + ".token" + ".system").matching(token.getSystem()); - } else { - // system + value - return myPredicateFactory.match().field(getTokenSystemCodeFieldPath(theSearchParamName)).matching(token.getValueAsQueryToken(this.myFhirContext)); - } - } else if (orTerm instanceof StringParam) { - // MB I don't quite understand why FhirResourceDaoR4SearchNoFtTest.testSearchByIdParamWrongType() uses String but here we are - StringParam string = (StringParam) orTerm; - // treat a string as a code with no system (like _id) - return myPredicateFactory.match().field(getTokenCodeFieldPath(theSearchParamName)).matching(string.getValue()); - } else { - throw new IllegalArgumentException(Msg.code(1089) + "Unexpected param type for token search-param: " + orTerm.getClass().getName()); - } - }).collect(Collectors.toList()); - - PredicateFinalStep finalClause = orPredicateOrSingle(clauses); - myRootClause.must(finalClause); - } - - } - - @Nonnull - public static String getTokenCodeFieldPath(String theSearchParamName) { - return SEARCH_PARAM_ROOT + "." + theSearchParamName + ".token" + ".code"; - } - - @Nonnull - public static String getTokenSystemCodeFieldPath(@Nonnull String theSearchParamName) { - return SEARCH_PARAM_ROOT + "." + theSearchParamName + ".token" + ".code-system"; - } - - public void addStringTextSearch(String theSearchParamName, List> stringAndOrTerms) { - if (CollectionUtils.isEmpty(stringAndOrTerms)) { - return; - } - String fieldName; - switch (theSearchParamName) { - // _content and _text were here first, and don't obey our mapping. - // Leave them as-is for backwards compatibility. - case Constants.PARAM_CONTENT: - fieldName = "myContentText"; - break; - case Constants.PARAM_TEXT: - fieldName = "myNarrativeText"; - break; - default: - fieldName = SEARCH_PARAM_ROOT + "." + theSearchParamName + ".string." + IDX_STRING_TEXT; - break; - } - - for (List nextAnd : stringAndOrTerms) { - Set terms = extractOrStringParams(nextAnd); - ourLog.debug("addStringTextSearch {}, {}", theSearchParamName, terms); - if (!terms.isEmpty()) { - String query = terms.stream() - .map(s -> "( " + s + " )") - .collect(Collectors.joining(" | ")); - myRootClause.must(myPredicateFactory - .simpleQueryString() - .field(fieldName) - .matching(query) - .defaultOperator(BooleanOperator.AND)); // term value may contain multiple tokens. Require all of them to be present. - } else { - ourLog.warn("No Terms found in query parameter {}", nextAnd); - } - } - } - - public void addStringExactSearch(String theSearchParamName, List> theStringAndOrTerms) { - String fieldPath = SEARCH_PARAM_ROOT + "." + theSearchParamName + ".string." + IDX_STRING_EXACT; - - for (List nextAnd : theStringAndOrTerms) { - Set terms = extractOrStringParams(nextAnd); - ourLog.debug("addStringExactSearch {} {}", theSearchParamName, terms); - List orTerms = terms.stream() - .map(s -> myPredicateFactory.match().field(fieldPath).matching(s)) - .collect(Collectors.toList()); - - myRootClause.must(orPredicateOrSingle(orTerms)); - } - } - - public void addStringContainsSearch(String theSearchParamName, List> theStringAndOrTerms) { - String fieldPath = SEARCH_PARAM_ROOT + "." + theSearchParamName + ".string." + IDX_STRING_NORMALIZED; - for (List nextAnd : theStringAndOrTerms) { - Set terms = extractOrStringParams(nextAnd); - ourLog.debug("addStringContainsSearch {} {}", theSearchParamName, terms); - List orTerms = terms.stream() - // wildcard is a term-level query, so queries aren't analyzed. Do our own normalization first. - .map(s-> normalize(s)) - .map(s -> myPredicateFactory - .wildcard().field(fieldPath) - .matching("*" + s + "*")) - .collect(Collectors.toList()); - - myRootClause.must(orPredicateOrSingle(orTerms)); - } - } - - /** - * Normalize the string to match our standardAnalyzer. - * @see HapiHSearchAnalysisConfigurers.HapiLuceneAnalysisConfigurer#STANDARD_ANALYZER - * - * @param theString the raw string - * @return a case and accent normalized version of the input - */ - @Nonnull - private String normalize(String theString) { - return StringUtil.normalizeStringForSearchIndexing(theString).toLowerCase(Locale.ROOT); - } - - public void addStringUnmodifiedSearch(String theSearchParamName, List> theStringAndOrTerms) { - String fieldPath = SEARCH_PARAM_ROOT + "." + theSearchParamName + ".string." + IDX_STRING_NORMALIZED; - for (List nextAnd : theStringAndOrTerms) { - Set terms = extractOrStringParams(nextAnd); - ourLog.debug("addStringUnmodifiedSearch {} {}", theSearchParamName, terms); - List orTerms = terms.stream() - .map(s -> - myPredicateFactory.wildcard() - .field(fieldPath) - // wildcard is a term-level query, so it isn't analyzed. Do our own case-folding to match the normStringAnalyzer - .matching(normalize(s) + "*")) - .collect(Collectors.toList()); - - myRootClause.must(orPredicateOrSingle(orTerms)); - } - } - - public void addReferenceUnchainedSearch(String theSearchParamName, List> theReferenceAndOrTerms) { - String fieldPath = SEARCH_PARAM_ROOT + "." + theSearchParamName + ".reference.value"; - for (List nextAnd : theReferenceAndOrTerms) { - Set terms = extractOrStringParams(nextAnd); - ourLog.trace("reference unchained search {}", terms); - - List orTerms = terms.stream() - .map(s -> myPredicateFactory.match().field(fieldPath).matching(s)) - .collect(Collectors.toList()); - - myRootClause.must(orPredicateOrSingle(orTerms)); - } - } - - /** - * Create date clause from date params. The date lower and upper bounds are taken - * into considertion when generating date query ranges - * - *

Example 1 ('eq' prefix/empty): http://fhirserver/Observation?date=eq2020 - * would generate the following search clause - *

-	 * {@code
-	 * {
-	 *  "bool": {
-	 *    "must": [{
-	 *      "range": {
-	 *        "sp.date.dt.lower-ord": { "gte": "20200101" }
-	 *      }
-	 *    }, {
-	 *      "range": {
-	 *        "sp.date.dt.upper-ord": { "lte": "20201231" }
-	 *      }
-	 *    }]
-	 *  }
-	 * }
-	 * }
-	 * 
- * - *

Example 2 ('gt' prefix): http://fhirserver/Observation?date=gt2020-01-01T08:00:00.000 - *

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

- *
-	 * {@code
-	 * {
-	 *   "range":{
-	 *     "sp.date.dt.upper":{ "gt": "2020-01-01T15:00:00.000000000Z" }
-	 *   }
-	 * }
-	 * }
-	 * 
- * - *

Example 3 between dates: http://fhirserver/Observation?date=ge2010-01-01&date=le2020-01

- *
-	 * {@code
-	 * {
-	 *   "range":{
-	 *     "sp.date.dt.upper-ord":{ "gte":"20100101" }
-	 *   },
-	 *   "range":{
-	 *     "sp.date.dt.lower-ord":{ "lte":"20200101" }
-	 *   }
-	 * }
-	 * }
-	 * 
- * - *

Example 4 not equal: http://fhirserver/Observation?date=ne2021

- *
-	 * {@code
-	 * {
-	 *    "bool": {
-	 *       "should": [{
-	 *          "range": {
-	 *             "sp.date.dt.upper-ord": { "lt": "20210101" }
-	 *          }
-	 *       }, {
-	 *          "range": {
-	 *             "sp.date.dt.lower-ord": { "gt": "20211231" }
-	 *          }
-	 *       }],
-	 *       "minimum_should_match": "1"
-	 *    }
-	 * }
-	 * }
-	 * 
- * - * @param theSearchParamName e.g code - * @param theDateAndOrTerms The and/or list of DateParam values - */ - public void addDateUnmodifiedSearch(String theSearchParamName, List> theDateAndOrTerms) { - for (List nextAnd : theDateAndOrTerms) { - // comma separated list of dates(OR list) on a date param is not applicable so grab - // first from default list - if (nextAnd.size() > 1) { - throw new IllegalArgumentException(Msg.code(2032) + "OR (,) searches on DATE search parameters are not supported for ElasticSearch/Lucene"); - } - DateParam dateParam = (DateParam) nextAnd.stream().findFirst() - .orElseThrow(() -> new InvalidRequestException("Date param is missing value")); - - boolean isOrdinalSearch = ordinalSearchPrecisions.contains(dateParam.getPrecision()); - - PredicateFinalStep searchPredicate = isOrdinalSearch - ? generateDateOrdinalSearchTerms(theSearchParamName, dateParam) - : generateDateInstantSearchTerms(theSearchParamName, dateParam); - - myRootClause.must(searchPredicate); - } - } - - private PredicateFinalStep generateDateOrdinalSearchTerms(String theSearchParamName, DateParam theDateParam) { - String lowerOrdinalField = SEARCH_PARAM_ROOT + "." + theSearchParamName + ".dt.lower-ord"; - String upperOrdinalField = SEARCH_PARAM_ROOT + "." + theSearchParamName + ".dt.upper-ord"; - int lowerBoundAsOrdinal; - int upperBoundAsOrdinal; - ParamPrefixEnum prefix = theDateParam.getPrefix(); - - // default when handling 'Day' temporal types - lowerBoundAsOrdinal = upperBoundAsOrdinal = DateUtils.convertDateToDayInteger(theDateParam.getValue()); - TemporalPrecisionEnum precision = theDateParam.getPrecision(); - // complete the date from 'YYYY' and 'YYYY-MM' temporal types - if (precision == TemporalPrecisionEnum.YEAR || precision == TemporalPrecisionEnum.MONTH) { - Pair completedDate = DateUtils.getCompletedDate(theDateParam.getValueAsString()); - lowerBoundAsOrdinal = Integer.parseInt(completedDate.getLeft().replace("-", "")); - upperBoundAsOrdinal = Integer.parseInt(completedDate.getRight().replace("-", "")); - } - - if (Objects.isNull(prefix) || prefix == ParamPrefixEnum.EQUAL) { - // For equality prefix we would like the date to fall between the lower and upper bound - List predicateSteps = Arrays.asList( - myPredicateFactory.range().field(lowerOrdinalField).atLeast(lowerBoundAsOrdinal), - myPredicateFactory.range().field(upperOrdinalField).atMost(upperBoundAsOrdinal) - ); - BooleanPredicateClausesStep booleanStep = myPredicateFactory.bool(); - predicateSteps.forEach(booleanStep::must); - return booleanStep; - } else if (ParamPrefixEnum.GREATERTHAN == prefix || ParamPrefixEnum.STARTS_AFTER == prefix) { - // TODO JB: more fine tuning needed for STARTS_AFTER - return myPredicateFactory.range().field(upperOrdinalField).greaterThan(upperBoundAsOrdinal); - } else if (ParamPrefixEnum.GREATERTHAN_OR_EQUALS == prefix) { - return myPredicateFactory.range().field(upperOrdinalField).atLeast(upperBoundAsOrdinal); - } else if (ParamPrefixEnum.LESSTHAN == prefix || ParamPrefixEnum.ENDS_BEFORE == prefix) { - // TODO JB: more fine tuning needed for END_BEFORE - return myPredicateFactory.range().field(lowerOrdinalField).lessThan(lowerBoundAsOrdinal); - } else if (ParamPrefixEnum.LESSTHAN_OR_EQUALS == prefix) { - return myPredicateFactory.range().field(lowerOrdinalField).atMost(lowerBoundAsOrdinal); - } else if (ParamPrefixEnum.NOT_EQUAL == prefix) { - List predicateSteps = Arrays.asList( - myPredicateFactory.range().field(upperOrdinalField).lessThan(lowerBoundAsOrdinal), - myPredicateFactory.range().field(lowerOrdinalField).greaterThan(upperBoundAsOrdinal) - ); - BooleanPredicateClausesStep booleanStep = myPredicateFactory.bool(); - predicateSteps.forEach(booleanStep::should); - booleanStep.minimumShouldMatchNumber(1); - return booleanStep; - } - throw new IllegalArgumentException(Msg.code(2025) + "Date search param does not support prefix of type: " + prefix); - } - - private PredicateFinalStep generateDateInstantSearchTerms(String theSearchParamName, DateParam theDateParam) { - String lowerInstantField = SEARCH_PARAM_ROOT + "." + theSearchParamName + ".dt.lower"; - String upperInstantField = SEARCH_PARAM_ROOT + "." + theSearchParamName + ".dt.upper"; - ParamPrefixEnum prefix = theDateParam.getPrefix(); - - if (ParamPrefixEnum.NOT_EQUAL == prefix) { - Instant dateInstant = theDateParam.getValue().toInstant(); - List predicateSteps = Arrays.asList( - myPredicateFactory.range().field(upperInstantField).lessThan(dateInstant), - myPredicateFactory.range().field(lowerInstantField).greaterThan(dateInstant) - ); - BooleanPredicateClausesStep booleanStep = myPredicateFactory.bool(); - predicateSteps.forEach(booleanStep::should); - booleanStep.minimumShouldMatchNumber(1); - return booleanStep; - } - - // Consider lower and upper bounds for building range predicates - DateRangeParam dateRange = new DateRangeParam(theDateParam); - Instant lowerBoundAsInstant = Optional.ofNullable(dateRange.getLowerBound()).map(param -> param.getValue().toInstant()).orElse(null); - Instant upperBoundAsInstant = Optional.ofNullable(dateRange.getUpperBound()).map(param -> param.getValue().toInstant()).orElse(null); - - if (Objects.isNull(prefix) || prefix == ParamPrefixEnum.EQUAL) { - // For equality prefix we would like the date to fall between the lower and upper bound - List predicateSteps = Arrays.asList( - myPredicateFactory.range().field(lowerInstantField).atLeast(lowerBoundAsInstant), - myPredicateFactory.range().field(upperInstantField).atMost(upperBoundAsInstant) - ); - BooleanPredicateClausesStep booleanStep = myPredicateFactory.bool(); - predicateSteps.forEach(booleanStep::must); - return booleanStep; - } else if (ParamPrefixEnum.GREATERTHAN == prefix || ParamPrefixEnum.STARTS_AFTER == prefix) { - return myPredicateFactory.range().field(upperInstantField).greaterThan(lowerBoundAsInstant); - } else if (ParamPrefixEnum.GREATERTHAN_OR_EQUALS == prefix) { - return myPredicateFactory.range().field(upperInstantField).atLeast(lowerBoundAsInstant); - } else if (ParamPrefixEnum.LESSTHAN == prefix || ParamPrefixEnum.ENDS_BEFORE == prefix) { - return myPredicateFactory.range().field(lowerInstantField).lessThan(upperBoundAsInstant); - } else if (ParamPrefixEnum.LESSTHAN_OR_EQUALS == prefix) { - return myPredicateFactory.range().field(lowerInstantField).atMost(upperBoundAsInstant); - } - - throw new IllegalArgumentException(Msg.code(2026) + "Date search param does not support prefix of type: " + prefix); - } - - - /** - * Differences with DB search: - * _ is not all-normalized-or-all-not. Each parameter is applied on quantity or normalized quantity depending on UCUM fitness - * _ respects ranges for equal and approximate qualifiers - * - * Strategy: For each parameter, if it can be canonicalized, it is, and used against 'normalized-value-quantity' index - * otherwise it is applied as-is to 'value-quantity' - */ - public void addQuantityUnmodifiedSearch(String theSearchParamName, List> theQuantityAndOrTerms) { - - for (List nextAnd : theQuantityAndOrTerms) { - BooleanPredicateClausesStep quantityTerms = myPredicateFactory.bool(); - quantityTerms.minimumShouldMatchNumber(1); - - for (IQueryParameterType paramType : nextAnd) { - BooleanPredicateClausesStep orQuantityTerms = myPredicateFactory.bool(); - addQuantityOrClauses(theSearchParamName, paramType, orQuantityTerms); - quantityTerms.should(orQuantityTerms); - } - - myRootClause.must(quantityTerms); - } - } - - - private void addQuantityOrClauses(String theSearchParamName, - IQueryParameterType theParamType, BooleanPredicateClausesStep theQuantityTerms) { - - QuantityParam qtyParam = QuantityParam.toQuantityParam(theParamType); - ParamPrefixEnum activePrefix = qtyParam.getPrefix() == null ? ParamPrefixEnum.EQUAL : qtyParam.getPrefix(); - String fieldPath = NESTED_SEARCH_PARAM_ROOT + "." + theSearchParamName + "." + QTY_PARAM_NAME; - - if (myModelConfig.getNormalizedQuantitySearchLevel() == NormalizedQuantitySearchLevel.NORMALIZED_QUANTITY_SEARCH_SUPPORTED) { - QuantityParam canonicalQty = UcumServiceUtil.toCanonicalQuantityOrNull(qtyParam); - if (canonicalQty != null) { - String valueFieldPath = fieldPath + "." + QTY_VALUE_NORM; - setPrefixedQuantityPredicate(theQuantityTerms, activePrefix, canonicalQty, valueFieldPath); - theQuantityTerms.must(myPredicateFactory.match() - .field(fieldPath + "." + QTY_CODE_NORM) - .matching(canonicalQty.getUnits())); - return; - } - } - - // not NORMALIZED_QUANTITY_SEARCH_SUPPORTED or non-canonicalizable parameter - String valueFieldPath = fieldPath + "." + QTY_VALUE; - setPrefixedQuantityPredicate(theQuantityTerms, activePrefix, qtyParam, valueFieldPath); - - if ( isNotBlank(qtyParam.getSystem()) ) { - theQuantityTerms.must( - myPredicateFactory.match() - .field(fieldPath + "." + QTY_SYSTEM).matching(qtyParam.getSystem()) ); - } - - if ( isNotBlank(qtyParam.getUnits()) ) { - theQuantityTerms.must( - myPredicateFactory.match() - .field(fieldPath + "." + QTY_CODE).matching(qtyParam.getUnits()) ); - } - } - - - private void setPrefixedQuantityPredicate(BooleanPredicateClausesStep theQuantityTerms, - ParamPrefixEnum thePrefix, QuantityParam theQuantity, String valueFieldPath) { - - double value = theQuantity.getValue().doubleValue(); - double approxTolerance = value * QTY_APPROX_TOLERANCE_PERCENT; - double defaultTolerance = value * QTY_TOLERANCE_PERCENT; - - switch (thePrefix) { - // searches for resource quantity between passed param value +/- 10% - case APPROXIMATE: - theQuantityTerms.must( - myPredicateFactory.range() - .field(valueFieldPath) - .between(value-approxTolerance, value+approxTolerance)); - break; - - // searches for resource quantity between passed param value +/- 5% - case EQUAL: - theQuantityTerms.must( - myPredicateFactory.range() - .field(valueFieldPath) - .between(value-defaultTolerance, value+defaultTolerance)); - break; - - // searches for resource quantity > param value - case GREATERTHAN: - case STARTS_AFTER: // treated as GREATERTHAN because search doesn't handle ranges - theQuantityTerms.must( - myPredicateFactory.range() - .field(valueFieldPath) - .greaterThan(value)); - break; - - // searches for resource quantity not < param value - case GREATERTHAN_OR_EQUALS: - theQuantityTerms.must( - myPredicateFactory.range() - .field(valueFieldPath) - .atLeast(value)); - break; - - // searches for resource quantity < param value - case LESSTHAN: - case ENDS_BEFORE: // treated as LESSTHAN because search doesn't handle ranges - theQuantityTerms.must( - myPredicateFactory.range() - .field(valueFieldPath) - .lessThan(value)); - break; - - // searches for resource quantity not > param value - case LESSTHAN_OR_EQUALS: - theQuantityTerms.must( - myPredicateFactory.range() - .field(valueFieldPath) - .atMost(value)); - break; - - // NOT_EQUAL: searches for resource quantity not between passed param value +/- 5% - case NOT_EQUAL: - theQuantityTerms.mustNot( - myPredicateFactory.range() - .field(valueFieldPath) - .between(value-defaultTolerance, value+defaultTolerance)); - break; - } - } - - - public void addUriUnmodifiedSearch(String theParamName, List> theUriUnmodifiedAndOrTerms) { - for (List nextAnd : theUriUnmodifiedAndOrTerms) { - - List orTerms = nextAnd.stream().map(p -> ((UriParam) p).getValue()).collect(Collectors.toList()); - PredicateFinalStep orTermPredicate = myPredicateFactory.terms() - .field(String.join(".", SEARCH_PARAM_ROOT, theParamName, URI_VALUE)) - .matchingAny(orTerms); - - myRootClause.must(orTermPredicate); - } - } - - - public void addNumberUnmodifiedSearch(String theParamName, List> theNumberUnmodifiedAndOrTerms) { - String fieldPath = String.join(".", SEARCH_PARAM_ROOT, theParamName, NUMBER_VALUE); - - for (List nextAnd : theNumberUnmodifiedAndOrTerms) { - List orTerms = nextAnd.stream().map(NumberParam.class::cast).collect(Collectors.toList()); - - BooleanPredicateClausesStep numberPredicateStep = myPredicateFactory.bool(); - numberPredicateStep.minimumShouldMatchNumber(1); - - for (NumberParam orTerm : orTerms) { - double value = orTerm.getValue().doubleValue(); - double approxTolerance = value * QTY_APPROX_TOLERANCE_PERCENT; - double defaultTolerance = value * QTY_TOLERANCE_PERCENT; - - ParamPrefixEnum activePrefix = orTerm.getPrefix() == null ? ParamPrefixEnum.EQUAL : orTerm.getPrefix(); - switch (activePrefix) { - case APPROXIMATE: - numberPredicateStep.should(myPredicateFactory.range() - .field(fieldPath).between(value - approxTolerance, value + approxTolerance)); - break; - - case EQUAL: - numberPredicateStep.should(myPredicateFactory.range() - .field(fieldPath).between(value - defaultTolerance, value + defaultTolerance)); - break; - - case STARTS_AFTER: - case GREATERTHAN: - numberPredicateStep.should(myPredicateFactory.range().field(fieldPath).greaterThan(value)); - break; - - case GREATERTHAN_OR_EQUALS: - numberPredicateStep.should(myPredicateFactory.range().field(fieldPath).atLeast(value)); - break; - - case ENDS_BEFORE: - case LESSTHAN: - numberPredicateStep.should(myPredicateFactory.range().field(fieldPath).lessThan(value)); - break; - - case LESSTHAN_OR_EQUALS: - numberPredicateStep.should(myPredicateFactory.range().field(fieldPath).atMost(value)); - break; - - case NOT_EQUAL: - numberPredicateStep.mustNot(myPredicateFactory.match().field(fieldPath).matching(value)); - break; - } - } - - myRootClause.must(numberPredicateStep); - } - } - - -} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/ExtendedHSearchSearchBuilder.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/ExtendedHSearchSearchBuilder.java index bd0d7e8d2bf..f16597244f5 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/ExtendedHSearchSearchBuilder.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/ExtendedHSearchSearchBuilder.java @@ -126,7 +126,8 @@ private boolean isParamTypeSupported(IQueryParameterType param) { } } - public void addAndConsumeAdvancedQueryClauses(ExtendedHSearchClauseBuilder builder, String theResourceType, SearchParameterMap theParams, ISearchParamRegistry theSearchParamRegistry) { + public void addAndConsumeAdvancedQueryClauses(HSearchClauseProvider builder, String theResourceType, + SearchParameterMap theParams, ISearchParamRegistry theSearchParamRegistry) { // copy the keys to avoid concurrent modification error ArrayList paramNames = compileParamNames(theParams); for (String nextParam : paramNames) { @@ -141,52 +142,25 @@ public void addAndConsumeAdvancedQueryClauses(ExtendedHSearchClauseBuilder build // NOTE - keep this in sync with isParamSupported() above. switch (activeParam.getParamType()) { - case TOKEN: - List> tokenTextAndOrTerms = theParams.removeByNameAndModifier(nextParam, Constants.PARAMQUALIFIER_TOKEN_TEXT); - builder.addStringTextSearch(nextParam, tokenTextAndOrTerms); - - List> tokenUnmodifiedAndOrTerms = theParams.removeByNameUnmodified(nextParam); - builder.addTokenUnmodifiedSearch(nextParam, tokenUnmodifiedAndOrTerms); - break; - - case STRING: - List> stringTextAndOrTerms = theParams.removeByNameAndModifier(nextParam, Constants.PARAMQUALIFIER_TOKEN_TEXT); - builder.addStringTextSearch(nextParam, stringTextAndOrTerms); - - List> stringExactAndOrTerms = theParams.removeByNameAndModifier(nextParam, Constants.PARAMQUALIFIER_STRING_EXACT); - builder.addStringExactSearch(nextParam, stringExactAndOrTerms); - - List> stringContainsAndOrTerms = theParams.removeByNameAndModifier(nextParam, Constants.PARAMQUALIFIER_STRING_CONTAINS); - builder.addStringContainsSearch(nextParam, stringContainsAndOrTerms); - - List> stringAndOrTerms = theParams.removeByNameUnmodified(nextParam); - builder.addStringUnmodifiedSearch(nextParam, stringAndOrTerms); - break; - + case DATE: + if (nextParam.equalsIgnoreCase("_lastupdated")) { + List> andOrTerms = getLastUpdatedAndOrList(theParams); + builder.addAndConsumeAndPlusOrClauses(theResourceType, nextParam, andOrTerms); + break; + } + // flow to next case on purpose case QUANTITY: - List> quantityAndOrTerms = theParams.removeByNameUnmodified(nextParam); - builder.addQuantityUnmodifiedSearch(nextParam, quantityAndOrTerms); - break; - case REFERENCE: - List> referenceAndOrTerms = theParams.removeByNameUnmodified(nextParam); - builder.addReferenceUnchainedSearch(nextParam, referenceAndOrTerms); - break; - - case DATE: - List> dateAndOrTerms = nextParam.equalsIgnoreCase("_lastupdated") - ? getLastUpdatedAndOrList(theParams) : theParams.removeByNameUnmodified(nextParam); - builder.addDateUnmodifiedSearch(nextParam, dateAndOrTerms); - break; - case URI: - List> uriUnmodifiedAndOrTerms = theParams.removeByNameUnmodified(nextParam); - builder.addUriUnmodifiedSearch(nextParam, uriUnmodifiedAndOrTerms); + List> andOrTerms = theParams.removeByNameUnmodified(nextParam); + builder.addAndConsumeAndPlusOrClauses(theResourceType, nextParam, andOrTerms); break; + case STRING: + case TOKEN: case NUMBER: List> numberUnmodifiedAndOrTerms = theParams.remove(nextParam); - builder.addNumberUnmodifiedSearch(nextParam, numberUnmodifiedAndOrTerms); + builder.addAndConsumeAndPlusOrClauses(theResourceType, nextParam, numberUnmodifiedAndOrTerms); break; default: diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchClauseProvider.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchClauseProvider.java new file mode 100644 index 00000000000..af6ad27021b --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchClauseProvider.java @@ -0,0 +1,60 @@ +package ca.uhn.fhir.jpa.dao.search; + + +import ca.uhn.fhir.model.api.IQueryParameterType; +import org.hibernate.search.engine.search.predicate.dsl.BooleanPredicateClausesStep; +import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory; + +import java.util.List; + +public class HSearchClauseProvider { + + private static final String NESTED_PREFIX = "nsp."; + + private final SearchPredicateFactory myFactory; + private final BooleanPredicateClausesStep myRootBool; + private final IHSearchParamHelperProvider myHSearchParamHelperProvider; + + + public HSearchClauseProvider(IHSearchParamHelperProvider theHSearchParamHelperProvider, + SearchPredicateFactory theFactory, BooleanPredicateClausesStep theRootBoolean) { + myFactory = theFactory; + myRootBool = theRootBoolean; + myHSearchParamHelperProvider = theHSearchParamHelperProvider; + } + + + public void addAndConsumeAndPlusOrClauses(String theResourceTypeName, + String theParamName, List> theAndOrTerms) { + + if (theAndOrTerms.isEmpty()) { return; } + + HSearchParamHelper paramHelper = getParamHelper(theResourceTypeName, theParamName); + + BooleanPredicateClausesStep topBool = myFactory.bool(); + + for (List orTerms : theAndOrTerms) { + // need an extra bool level for nested properties (embedding it under topBool before leaving loop) + BooleanPredicateClausesStep activeBool = paramHelper.isNested() ? myFactory.bool() : topBool; + + paramHelper.processOrTerms(myFactory, activeBool, orTerms, theParamName); + + if ( paramHelper.isNested() ) { + topBool.must(myFactory.nested().objectField(NESTED_PREFIX + theParamName).nest(activeBool)); + } + } + + myRootBool.must(topBool); + } + + + private HSearchParamHelper getParamHelper(String theResourceTypeName, String theParamName) { + return myHSearchParamHelperProvider.provideHelper(theResourceTypeName, theParamName); + } + + + public void addResourceTypeClause(String theResourceType) { + myRootBool.must(myFactory.match().field("myResourceType").matching(theResourceType)); + + } +} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelper.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelper.java new file mode 100644 index 00000000000..d95890bcc90 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelper.java @@ -0,0 +1,129 @@ +package ca.uhn.fhir.jpa.dao.search; + +import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; +import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; +import org.hibernate.search.engine.search.common.BooleanOperator; +import org.hibernate.search.engine.search.predicate.dsl.BooleanPredicateClausesStep; +import org.hibernate.search.engine.search.predicate.dsl.PredicateFinalStep; +import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.IDX_STRING_TEXT; +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.SEARCH_PARAM_ROOT; + +@Component +public abstract class HSearchParamHelper { + protected final Logger ourLog = LoggerFactory.getLogger(getClass()); + + protected static final String TEXT_PATH = String.join(".", SEARCH_PARAM_ROOT, "*", "string", IDX_STRING_TEXT ); + + + @Autowired + private ISearchParamRegistry mySearchParamRegistry; + + /** + * Map of specific typed children, which must self-register in constructor + */ + private static final Map> ourTypedHelperMap = new HashMap<>(); + + + public abstract

Optional getParamPropertyValue(P theParam, String thePropName); + + protected abstract RestSearchParameterTypeEnum getParamEnumType(); + + public abstract boolean isNested(); + + + public static void registerChildHelper(HSearchParamHelper theChild) { + ourTypedHelperMap.put(theChild.getParamEnumType(), theChild); + } + + public List getParamPropertiesForParameter(String theParamName, IQueryParameterType theParam) { + return getParamProperties(theParam).stream() + .map(p -> mergeParamIntoProperty(p, theParamName)) .collect(Collectors.toList()); + } + + protected String mergeParamIntoProperty(String thePropertyName, String theParameterName) { + return thePropertyName.replace("*", theParameterName); + } + + public abstract List getParamProperties(IQueryParameterType theParam); + + public static Optional> getTypeHelperForParam(RestSearchParameterTypeEnum theParamType) { + return Optional.ofNullable( HSearchParamHelper.ourTypedHelperMap.get(theParamType) ); + } + + + /** + * Addition of clauses for most parameter types. Overrides for NUMBER, QUANTITY, etc + */ + public

void addOrClauses(SearchPredicateFactory theFactory, + BooleanPredicateClausesStep theBool, String theParamName, P theParam) { + + List paramProperties = getParamPropertiesForParameter(theParamName, theParam); + for (String paramProperty : paramProperties) { + Optional paramPropertyValue = getParamPropertyValue(theParam, paramProperty); + paramPropertyValue.ifPresent( v -> theBool.must( theFactory.match().field(paramProperty).matching(v) )); + } + } + + public void processOrTerms(SearchPredicateFactory theFactory, BooleanPredicateClausesStep theBool, + List theOrTerms, String theParamName) { + + if (theOrTerms.size() == 1) { + addOrClauses(theFactory, theBool, theParamName, theOrTerms.get(0)); + return; + } + + // multiple or predicates must be in must group with multiple should(s) with a minimumShouldMatchNumber(1) + theBool.must(theFactory.bool(b2 -> { + b2.minimumShouldMatchNumber(1); + + for (IQueryParameterType orTerm : theOrTerms) { + var paramBool = theFactory.bool(); + addOrClauses(theFactory, paramBool, theParamName, orTerm); + b2.should(paramBool); + } + })); + } + + /** + * Used ny STRING and TOKEN children + */ + protected PredicateFinalStep getPredicatesForTextQualifier(SearchPredicateFactory theFactory, Set terms, String theParamName) { + String fieldPath = getTextPath(theParamName); + + String query = terms.stream() .map(s -> "( " + s + " )") .collect(Collectors.joining(" | ")); + return theFactory.simpleQueryString().field(fieldPath).matching(query).defaultOperator(BooleanOperator.AND); + } + + + private String getTextPath(String theParamName) { +// fixme jm: why is this needed? Why not defined as other parameters? + switch (theParamName) { + case Constants.PARAM_CONTENT: + return "myContentText"; + + case Constants.PARAM_TEXT: + return "myNarrativeText"; + + default: + return mergeParamIntoProperty(TEXT_PATH, theParamName); + } + } + + + +} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperDate.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperDate.java new file mode 100644 index 00000000000..c27edc5cd7e --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperDate.java @@ -0,0 +1,186 @@ +package ca.uhn.fhir.jpa.dao.search; + + +import ca.uhn.fhir.i18n.Msg; +import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.model.api.TemporalPrecisionEnum; +import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; +import ca.uhn.fhir.rest.param.DateParam; +import ca.uhn.fhir.rest.param.DateRangeParam; +import ca.uhn.fhir.rest.param.ParamPrefixEnum; +import ca.uhn.fhir.util.DateUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.hibernate.search.engine.search.predicate.dsl.BooleanPredicateClausesStep; +import org.hibernate.search.engine.search.predicate.dsl.PredicateFinalStep; +import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory; + +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.DATE_LOWER; +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.DATE_LOWER_ORD; +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.DATE_UPPER; +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.DATE_UPPER_ORD; +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.SEARCH_PARAM_ROOT; + + +public class HSearchParamHelperDate extends HSearchParamHelper { + + private static final String LOWER_ORD_PATH = String.join(".", SEARCH_PARAM_ROOT, "*", "dt", DATE_LOWER_ORD); + private static final String LOWER_PATH = String.join(".", SEARCH_PARAM_ROOT, "*", "dt", DATE_LOWER); + private static final String UPPER_ORD_PATH = String.join(".", SEARCH_PARAM_ROOT, "*", "dt", DATE_UPPER_ORD); + private static final String UPPER_PATH = String.join(".", SEARCH_PARAM_ROOT, "*", "dt", DATE_UPPER); + + private static final List mySearchProperties = List.of( LOWER_ORD_PATH, LOWER_PATH, UPPER_ORD_PATH, UPPER_PATH ); + + final List ordinalSearchPrecisions = List.of( + TemporalPrecisionEnum.YEAR, TemporalPrecisionEnum.MONTH, TemporalPrecisionEnum.DAY); + + + @Override + public

void addOrClauses(SearchPredicateFactory theFactory, + BooleanPredicateClausesStep theBool, String theParamName, P theParam) { + + DateParam dateParam = (DateParam) theParam; + + String lowerOrdPathForParam = mergeParamIntoProperty(LOWER_ORD_PATH, theParamName); + String upperOrdPathForParam = mergeParamIntoProperty(UPPER_ORD_PATH, theParamName); + + String lowerPathForParam = mergeParamIntoProperty(LOWER_PATH, theParamName); + String upperPathForParam = mergeParamIntoProperty(UPPER_PATH, theParamName); + + boolean isOrdinalSearch = ordinalSearchPrecisions.contains(dateParam.getPrecision()); + PredicateFinalStep searchPredicate = isOrdinalSearch + ? generateDateOrdinalSearchTerms(dateParam, theFactory, lowerOrdPathForParam, upperOrdPathForParam) + : generateDateInstantSearchTerms(dateParam, theFactory, lowerPathForParam, upperPathForParam); + + theBool.must(searchPredicate); + } + + + @Override + public void processOrTerms(SearchPredicateFactory theFactory, BooleanPredicateClausesStep theBool, + List theOrTerms, String theParamName) { + + if (theOrTerms.size() > 1) { + throw new IllegalArgumentException(Msg.code(2032) + + "OR (,) searches on DATE search parameters are not supported for ElasticSearch/Lucene"); + } + + addOrClauses(theFactory, theBool, theParamName, theOrTerms.get(0)); + } + + + @Override + public

Optional getParamPropertyValue(P theParam, String thePropName) { + return Optional.empty(); + } + + + private PredicateFinalStep generateDateOrdinalSearchTerms(DateParam theDateParam, SearchPredicateFactory theFactory, + String theLowerOrdPathForParam, String theUpperOrdPathForParam) { + int lowerBoundAsOrdinal; + int upperBoundAsOrdinal; + ParamPrefixEnum prefix = theDateParam.getPrefix(); + + // default when handling 'Day' temporal types + lowerBoundAsOrdinal = upperBoundAsOrdinal = DateUtils.convertDateToDayInteger(theDateParam.getValue()); + TemporalPrecisionEnum precision = theDateParam.getPrecision(); + // complete the date from 'YYYY' and 'YYYY-MM' temporal types + if (precision == TemporalPrecisionEnum.YEAR || precision == TemporalPrecisionEnum.MONTH) { + Pair completedDate = DateUtils.getCompletedDate(theDateParam.getValueAsString()); + lowerBoundAsOrdinal = Integer.parseInt(completedDate.getLeft().replace("-", "")); + upperBoundAsOrdinal = Integer.parseInt(completedDate.getRight().replace("-", "")); + } + + if (Objects.isNull(prefix) || prefix == ParamPrefixEnum.EQUAL) { + // For equality prefix we would like the date to fall between the lower and upper bound + List predicateSteps = Arrays.asList( + theFactory.range().field(theLowerOrdPathForParam).atLeast(lowerBoundAsOrdinal), + theFactory.range().field(theUpperOrdPathForParam).atMost(upperBoundAsOrdinal) + ); + BooleanPredicateClausesStep booleanStep = theFactory.bool(); + predicateSteps.forEach(booleanStep::must); + return booleanStep; + } else if (ParamPrefixEnum.GREATERTHAN == prefix || ParamPrefixEnum.STARTS_AFTER == prefix) { + // TODO JB: more fine tuning needed for STARTS_AFTER + return theFactory.range().field(theUpperOrdPathForParam).greaterThan(upperBoundAsOrdinal); + } else if (ParamPrefixEnum.GREATERTHAN_OR_EQUALS == prefix) { + return theFactory.range().field(theUpperOrdPathForParam).atLeast(upperBoundAsOrdinal); + } else if (ParamPrefixEnum.LESSTHAN == prefix || ParamPrefixEnum.ENDS_BEFORE == prefix) { + // TODO JB: more fine tuning needed for END_BEFORE + return theFactory.range().field(theLowerOrdPathForParam).lessThan(lowerBoundAsOrdinal); + } else if (ParamPrefixEnum.LESSTHAN_OR_EQUALS == prefix) { + return theFactory.range().field(theLowerOrdPathForParam).atMost(lowerBoundAsOrdinal); + } else if (ParamPrefixEnum.NOT_EQUAL == prefix) { + List predicateSteps = Arrays.asList( + theFactory.range().field(theUpperOrdPathForParam).lessThan(lowerBoundAsOrdinal), + theFactory.range().field(theLowerOrdPathForParam).greaterThan(upperBoundAsOrdinal) + ); + BooleanPredicateClausesStep booleanStep = theFactory.bool(); + predicateSteps.forEach(booleanStep::should); + booleanStep.minimumShouldMatchNumber(1); + return booleanStep; + } + throw new IllegalArgumentException(Msg.code(2025) + "Date search param does not support prefix of type: " + prefix); + } + + + private PredicateFinalStep generateDateInstantSearchTerms(DateParam theDateParam, SearchPredicateFactory theFactory, + String theLowerPathForParam, String theUpperPathForParam) { + ParamPrefixEnum prefix = theDateParam.getPrefix(); + + if (ParamPrefixEnum.NOT_EQUAL == prefix) { + Instant dateInstant = theDateParam.getValue().toInstant(); + List predicateSteps = Arrays.asList( + theFactory.range().field(theUpperPathForParam).lessThan(dateInstant), + theFactory.range().field(theLowerPathForParam).greaterThan(dateInstant) + ); + BooleanPredicateClausesStep booleanStep = theFactory.bool(); + predicateSteps.forEach(booleanStep::should); + booleanStep.minimumShouldMatchNumber(1); + return booleanStep; + } + + // Consider lower and upper bounds for building range predicates + DateRangeParam dateRange = new DateRangeParam(theDateParam); + Instant lowerBoundAsInstant = Optional.ofNullable(dateRange.getLowerBound()).map(param -> param.getValue().toInstant()).orElse(null); + Instant upperBoundAsInstant = Optional.ofNullable(dateRange.getUpperBound()).map(param -> param.getValue().toInstant()).orElse(null); + + if (Objects.isNull(prefix) || prefix == ParamPrefixEnum.EQUAL) { + // For equality prefix we would like the date to fall between the lower and upper bound + List predicateSteps = Arrays.asList( + theFactory.range().field(theLowerPathForParam).atLeast(lowerBoundAsInstant), + theFactory.range().field(theUpperPathForParam).atMost(upperBoundAsInstant) + ); + BooleanPredicateClausesStep booleanStep = theFactory.bool(); + predicateSteps.forEach(booleanStep::must); + return booleanStep; + } else if (ParamPrefixEnum.GREATERTHAN == prefix || ParamPrefixEnum.STARTS_AFTER == prefix) { + return theFactory.range().field(theUpperPathForParam).greaterThan(lowerBoundAsInstant); + } else if (ParamPrefixEnum.GREATERTHAN_OR_EQUALS == prefix) { + return theFactory.range().field(theUpperPathForParam).atLeast(lowerBoundAsInstant); + } else if (ParamPrefixEnum.LESSTHAN == prefix || ParamPrefixEnum.ENDS_BEFORE == prefix) { + return theFactory.range().field(theLowerPathForParam).lessThan(upperBoundAsInstant); + } else if (ParamPrefixEnum.LESSTHAN_OR_EQUALS == prefix) { + return theFactory.range().field(theLowerPathForParam).atMost(upperBoundAsInstant); + } + + throw new IllegalArgumentException(Msg.code(2026) + "Date search param does not support prefix of type: " + prefix); + } + + + @Override + protected RestSearchParameterTypeEnum getParamEnumType() { return RestSearchParameterTypeEnum.DATE; } + + @Override + public List getParamProperties(IQueryParameterType theParam) { return mySearchProperties; } + + @Override + public boolean isNested() { return false; } + + +} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperNumber.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperNumber.java new file mode 100644 index 00000000000..cd9e04ed007 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperNumber.java @@ -0,0 +1,64 @@ +package ca.uhn.fhir.jpa.dao.search; + + +import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; +import ca.uhn.fhir.rest.param.NumberParam; +import ca.uhn.fhir.rest.param.ParamPrefixEnum; +import org.hibernate.search.engine.search.predicate.dsl.BooleanPredicateClausesStep; +import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory; + +import java.util.List; +import java.util.Optional; + +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.NESTED_SEARCH_PARAM_ROOT; +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.NUMBER_VALUE; +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.SEARCH_PARAM_ROOT; + + +public class HSearchParamHelperNumber extends HSearchParamHelper { + + private static final String PATH = String.join(".", SEARCH_PARAM_ROOT, "*", NUMBER_VALUE); + + private static final List mySearchProperties = List.of( PATH ); + + + private final IPrefixedNumberPredicateHelperImpl myPrefixedNumberPredicateHelper; + + public HSearchParamHelperNumber(IPrefixedNumberPredicateHelperImpl thePrefixedNumberPredicateHelper) { + myPrefixedNumberPredicateHelper = thePrefixedNumberPredicateHelper; + } + + + @Override + public

Optional getParamPropertyValue(P theParam, String thePropName) { + NumberParam numberParam = (NumberParam) theParam; + return Optional.of(numberParam.getValue().doubleValue()); + } + + @Override + protected RestSearchParameterTypeEnum getParamEnumType() { return RestSearchParameterTypeEnum.NUMBER; } + + @Override + public List getParamProperties(IQueryParameterType theParam) { return mySearchProperties; } + + @Override + public boolean isNested() { return PATH.startsWith(NESTED_SEARCH_PARAM_ROOT); } + + @Override + public

void addOrClauses(SearchPredicateFactory theFactory, + BooleanPredicateClausesStep theBool, String theParamName, P theParam) { + + NumberParam numberParam = (NumberParam) theParam; + + ParamPrefixEnum activePrefix = numberParam.getPrefix() == null ? ParamPrefixEnum.EQUAL : numberParam.getPrefix(); + double paramValue = numberParam.getValue().doubleValue(); + String propertyPath = getParamPropertiesForParameter(theParamName, theParam).iterator().next(); + + myPrefixedNumberPredicateHelper.addPredicate(theFactory, theBool, activePrefix, paramValue, propertyPath); + } + + + + +} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperProviderImpl.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperProviderImpl.java new file mode 100644 index 00000000000..ffa966cdc29 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperProviderImpl.java @@ -0,0 +1,79 @@ +package ca.uhn.fhir.jpa.dao.search; + +import ca.uhn.fhir.context.RuntimeSearchParam; +import ca.uhn.fhir.i18n.Msg; +import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; +import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; +import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; +import ca.uhn.fhir.rest.server.util.ResourceSearchParams; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class HSearchParamHelperProviderImpl implements IHSearchParamHelperProvider { + + @Autowired + private ISearchParamRegistry mySearchParamRegistry; + + private final Map>> providerCache = new ConcurrentHashMap<>(); + + + @Override + public HSearchParamHelper provideHelper(String theResourceTypeName, String theParamName) { + HSearchParamHelper cachedProvider = getCached(theResourceTypeName, theParamName); + if (cachedProvider != null) { return cachedProvider; } + + HSearchParamHelper helper = findHelper(theResourceTypeName, theParamName); + + cache(theResourceTypeName, theParamName, helper); + return helper; + } + + + private void cache(String theResourceTypeName, String theParamName, HSearchParamHelper theHelper) { + providerCache .computeIfAbsent(theResourceTypeName, k -> new HashMap<>()) .put(theParamName, theHelper); // IfAbsent(theResourceTypeName, new HashSet<>()) + } + + + @NotNull + private HSearchParamHelper findHelper(String theResourceTypeName, String theParamName) { + Optional paramTypeOpt = getParamType(theResourceTypeName, theParamName); + if (paramTypeOpt.isEmpty()) { + // fixme jm: code + throw new InternalErrorException(Msg.code(0) + "Failed to obtain parameter type for resource " + + theResourceTypeName + " and parameter " + theParamName); + } + + Optional> helperOpt = HSearchParamHelper.getTypeHelperForParam(paramTypeOpt.get()); + if (helperOpt.isEmpty()) { + // fixme jm: code + throw new InternalErrorException(Msg.code(0) + + "HSearchParamHelper.myTypeHelperMap doesn't contain an entry for " + paramTypeOpt.get()); + } + return helperOpt.get(); + } + + + private HSearchParamHelper getCached(String theResourceTypeName, String theParamName) { + return providerCache.getOrDefault(theResourceTypeName, Collections.emptyMap()).get(theParamName); + } + + + public Optional getParamType(String theResourceTypeName, String theParamName) { + ResourceSearchParams activeSearchParams = mySearchParamRegistry.getActiveSearchParams(theResourceTypeName); + RuntimeSearchParam searchParam = activeSearchParams.get(theParamName); + if (searchParam == null) { + return Optional.empty(); + } + + return Optional.of(searchParam.getParamType()); + } + + + +} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperQuantity.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperQuantity.java new file mode 100644 index 00000000000..4ba43fc3f33 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperQuantity.java @@ -0,0 +1,135 @@ +package ca.uhn.fhir.jpa.dao.search; + + +import ca.uhn.fhir.i18n.Msg; +import ca.uhn.fhir.jpa.model.entity.ModelConfig; +import ca.uhn.fhir.jpa.model.entity.NormalizedQuantitySearchLevel; +import ca.uhn.fhir.jpa.model.util.UcumServiceUtil; +import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; +import ca.uhn.fhir.rest.param.ParamPrefixEnum; +import ca.uhn.fhir.rest.param.QuantityParam; +import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; +import org.hibernate.search.engine.search.predicate.dsl.BooleanPredicateClausesStep; +import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory; + +import java.util.List; +import java.util.Optional; + +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.NESTED_SEARCH_PARAM_ROOT; +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_CODE; +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_CODE_NORM; +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_PARAM_NAME; +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_SYSTEM; +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_VALUE; +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_VALUE_NORM; +import static org.apache.commons.lang3.StringUtils.isNotBlank; + +public class HSearchParamHelperQuantity extends HSearchParamHelper { + + private static final String SYSTEM_PATH = String.join(".", NESTED_SEARCH_PARAM_ROOT, "*", QTY_PARAM_NAME, QTY_SYSTEM ); + private static final String CODE_PATH = String.join(".", NESTED_SEARCH_PARAM_ROOT, "*", QTY_PARAM_NAME, QTY_CODE ); + private static final String VALUE_PATH = String.join(".", NESTED_SEARCH_PARAM_ROOT, "*", QTY_PARAM_NAME, QTY_VALUE ); + private static final String CODE_NORM_PATH = String.join(".", NESTED_SEARCH_PARAM_ROOT, "*", QTY_PARAM_NAME, QTY_CODE_NORM ); + private static final String VALUE_NORM_PATH = String.join(".", NESTED_SEARCH_PARAM_ROOT, "*", QTY_PARAM_NAME, QTY_VALUE_NORM ); + + private static final List mySearchCanonicalQtyProperties = List.of( CODE_NORM_PATH, VALUE_NORM_PATH ); + private static final List mySearchQtyProperties = List.of( SYSTEM_PATH, CODE_PATH, VALUE_PATH ); + + private final ModelConfig myModelConfig; + private final IPrefixedNumberPredicateHelper myPrefixedNumberPredicateHelper; + + + public HSearchParamHelperQuantity(IPrefixedNumberPredicateHelper theIPrefixedNumberPredicateHelper, ModelConfig theModelConfig) { + myPrefixedNumberPredicateHelper = theIPrefixedNumberPredicateHelper; + myModelConfig = theModelConfig; + } + + + @Override + protected RestSearchParameterTypeEnum getParamEnumType() { return RestSearchParameterTypeEnum.QUANTITY; } + + @Override + public boolean isNested() { return SYSTEM_PATH.startsWith(NESTED_SEARCH_PARAM_ROOT); } + + + @Override + public Optional getParamPropertyValue(IQueryParameterType theParam, String thePropName) { + QuantityParam qtyParam = (QuantityParam) theParam; + + if (thePropName.endsWith(QTY_SYSTEM)) { return Optional.of( qtyParam.getSystem() ); } + if (thePropName.endsWith(QTY_CODE)) { return Optional.of( qtyParam.getUnits() ); } + if (thePropName.endsWith(QTY_VALUE)) { return Optional.of( qtyParam.getValue().doubleValue() ); } + if (thePropName.endsWith(QTY_CODE_NORM)) { return Optional.of( qtyParam.getSystem() ); } + + // no check for canonical config or possibility because that was already done in getParamProperties method + if (thePropName.endsWith(QTY_VALUE_NORM)) { + QuantityParam canonicalQty = UcumServiceUtil.toCanonicalQuantityOrNull( qtyParam ); + return Optional.of( canonicalQty == null ? qtyParam.getValue().doubleValue() : canonicalQty.getValue().doubleValue() ); + } + + return Optional.empty(); + } + + + /** + * Selects which properties to return based on the configuration and the parameter value. If supported in the + * configuration and a canonical quantity can be obtained from the parameter values, return the canonical properties. + * Otherwise, return the non-canonical properties. + */ + @Override + public List getParamProperties(IQueryParameterType theParam) { + if (myModelConfig.getNormalizedQuantitySearchLevel() == NormalizedQuantitySearchLevel.NORMALIZED_QUANTITY_SEARCH_SUPPORTED) { + QuantityParam qtyParam = (QuantityParam) theParam; + QuantityParam canonicalQty = UcumServiceUtil.toCanonicalQuantityOrNull( qtyParam ); + if (canonicalQty != null) { + return mySearchCanonicalQtyProperties; + } + } + + return mySearchQtyProperties; + } + + + @Override + public

void addOrClauses(SearchPredicateFactory theFactory, + BooleanPredicateClausesStep theBool, String theParamName, P theParam) { + + List propertyPaths = getParamPropertiesForParameter(theParamName, theParam); + + QuantityParam qtyParam = (QuantityParam) theParam; + ParamPrefixEnum activePrefix = qtyParam.getPrefix() == null ? ParamPrefixEnum.EQUAL : qtyParam.getPrefix(); + + String codeNormParamPath = mergeParamIntoProperty(CODE_NORM_PATH, theParamName); + if (propertyPaths.contains(codeNormParamPath)) { + QuantityParam canonicalQty = UcumServiceUtil.toCanonicalQuantityOrNull(qtyParam); + if (canonicalQty == null) { +// fixme jm: code + throw new InternalErrorException(Msg.code(0) + "Couldn't extract canonical value for parameter: " + theParam); + } + + String valueNormPathForParam = mergeParamIntoProperty(VALUE_NORM_PATH, theParamName); + myPrefixedNumberPredicateHelper.addPredicate(theFactory, theBool, activePrefix, canonicalQty.getValue().doubleValue(), valueNormPathForParam); + + String codeNormPathForParam= mergeParamIntoProperty(CODE_NORM_PATH, theParamName); + theBool.must( theFactory.match().field( codeNormPathForParam ) .matching(canonicalQty.getUnits()) ); + return; + } + + // adding not-normalized properties clauses + String valuePathForParam = mergeParamIntoProperty(VALUE_PATH, theParamName); + myPrefixedNumberPredicateHelper.addPredicate(theFactory, theBool, activePrefix, qtyParam.getValue().doubleValue(), valuePathForParam); + + if ( isNotBlank(qtyParam.getSystem()) ) { + String systemPathForParam = mergeParamIntoProperty(SYSTEM_PATH, theParamName); + theBool.must( theFactory.match().field(systemPathForParam).matching(qtyParam.getSystem()) ); + } + + if ( isNotBlank(qtyParam.getUnits()) ) { + String codePathForParam = mergeParamIntoProperty(CODE_PATH, theParamName); + theBool.must(theFactory.match().field(codePathForParam) .matching(qtyParam.getUnits()) ); + } + } + + +} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperReference.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperReference.java new file mode 100644 index 00000000000..ea2c1267440 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperReference.java @@ -0,0 +1,44 @@ +package ca.uhn.fhir.jpa.dao.search; + + +import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; +import ca.uhn.fhir.rest.param.ReferenceParam; + +import java.util.List; +import java.util.Optional; + +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.SEARCH_PARAM_ROOT; + + +public class HSearchParamHelperReference extends HSearchParamHelper { + + private static final String PATH = String.join(".", SEARCH_PARAM_ROOT, "*", "reference", "value"); + + private static final List mySearchProperties = List.of( PATH ); + + + + @Override + public

Optional getParamPropertyValue(P theParam, String thePropName) { + ReferenceParam referenceParam = (ReferenceParam) theParam; + + String valueTrimmed = referenceParam.getValue(); + if (valueTrimmed.contains("/_history")) { + valueTrimmed = valueTrimmed.substring(0, valueTrimmed.indexOf("/_history")); + } + + return Optional.ofNullable(valueTrimmed); + } + + + @Override + protected RestSearchParameterTypeEnum getParamEnumType() { return RestSearchParameterTypeEnum.REFERENCE; } + + @Override + public List getParamProperties(IQueryParameterType theParam) { return mySearchProperties; } + + @Override + public boolean isNested() { return false; } + +} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperString.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperString.java new file mode 100644 index 00000000000..1898940054f --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperString.java @@ -0,0 +1,178 @@ +package ca.uhn.fhir.jpa.dao.search; + + +import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; +import ca.uhn.fhir.rest.param.StringParam; +import ca.uhn.fhir.util.StringUtil; +import org.apache.commons.lang3.StringUtils; +import org.hibernate.search.engine.search.predicate.dsl.BooleanPredicateClausesStep; +import org.hibernate.search.engine.search.predicate.dsl.PredicateFinalStep; +import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory; + +import javax.annotation.Nonnull; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.IDX_STRING_EXACT; +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.IDX_STRING_LOWER; +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.IDX_STRING_NORMALIZED; +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.SEARCH_PARAM_ROOT; + +public class HSearchParamHelperString extends HSearchParamHelper { + + private static final String NORM_PATH = String.join(".", SEARCH_PARAM_ROOT, "*", "string", IDX_STRING_NORMALIZED ); + private static final String EXACT_PATH = String.join(".", SEARCH_PARAM_ROOT, "*", "string", IDX_STRING_EXACT ); + private static final String LOWER_PATH = String.join(".", SEARCH_PARAM_ROOT, "*", "string", IDX_STRING_LOWER ); + + private static final List mySearchProperties = List.of( NORM_PATH, EXACT_PATH, TEXT_PATH, LOWER_PATH ); + + + @Override + public void processOrTerms(SearchPredicateFactory theFactory, BooleanPredicateClausesStep theBool, + List theOrTerms, String theParamName) { + + if (theOrTerms.isEmpty()) { return; } + + StringParam stringParam = (StringParam) theOrTerms.iterator().next(); + + Set terms = extractOrStringParams(theOrTerms); + if (terms.isEmpty()) { + ourLog.warn("No Terms found in query parameter {}", theParamName); + return; + } + + if (theParamName.equals(Constants.PARAM_CONTENT) || theParamName.equals(Constants.PARAM_TEXT)) { + var predicates = getPredicatesForTextQualifier(theFactory, terms, theParamName); + theBool.must( predicates ); + return; + } + + PredicateFinalStep predicates = getPredicatesForQualifier(theFactory, theParamName, stringParam, terms); + + theBool.must(predicates); + } + + + private PredicateFinalStep getPredicatesForQualifier(SearchPredicateFactory theFactory, String theParamName, StringParam stringParam, Set terms) { + if ( stringParam.getQueryParameterQualifier() != null ) { + switch (stringParam.getQueryParameterQualifier()) { + case Constants.PARAMQUALIFIER_STRING_EXACT: + ourLog.debug("addStringExactSearch {} {}", theParamName, terms); + return getPredicatesForExactQualifier(theFactory, terms, theParamName); + + case Constants.PARAMQUALIFIER_STRING_CONTAINS: + ourLog.debug("addStringContainsSearch {} {}", theParamName, terms); + return getPredicatesForContainsQualifier(theFactory, terms, theParamName); + + default: + break; + } + } + + ourLog.debug("addStringUnqualifiedSearch {} {}", theParamName, terms); + return getPredicatesUnqualified(theFactory, terms, theParamName); + } + + + + + protected PredicateFinalStep getPredicatesForExactQualifier( + SearchPredicateFactory theFactory, Set terms, String theParamName) { + + String fieldPath = mergeParamIntoProperty(EXACT_PATH, theParamName); + + List orTerms = terms.stream() + .map(s -> theFactory.match().field(fieldPath).matching(s)) + .collect(Collectors.toList()); + return orPredicateOrSingle(orTerms, theFactory); + } + + + private PredicateFinalStep getPredicatesForContainsQualifier(SearchPredicateFactory theFactory, Set terms, String theParamName) { + String fieldPath = mergeParamIntoProperty(NORM_PATH, theParamName); + + List orTerms = terms.stream() + // wildcard is a term-level query, so queries aren't analyzed. Do our own normalization first. + .map(this::normalize) + .map(s -> theFactory .wildcard().field(fieldPath) .matching("*" + s + "*")) + .collect(Collectors.toList()); + return orPredicateOrSingle(orTerms, theFactory); + } + + + private PredicateFinalStep getPredicatesUnqualified(SearchPredicateFactory theFactory, Set terms, String theParamName) { + String fieldPath = mergeParamIntoProperty(NORM_PATH, theParamName); + + List orTerms = terms.stream() + .map( s -> theFactory.wildcard() .field(fieldPath) + // wildcard is a term-level query, so it isn't analyzed. Do our own case-folding to match the normStringAnalyzer + .matching(normalize(s) + "*")) + .collect(Collectors.toList()); + return orPredicateOrSingle(orTerms, theFactory); + } + + + /** + * Normalize the string to match our standardAnalyzer. + * @see ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers.HapiLuceneAnalysisConfigurer#STANDARD_ANALYZER + * + * @param theString the raw string + * @return a case and accent normalized version of the input + */ + @Nonnull + private String normalize(String theString) { + return StringUtil.normalizeStringForSearchIndexing(theString).toLowerCase(Locale.ROOT); + } + + + /** + * Provide an OR wrapper around a list of predicates. + * Returns the sole predicate if it solo, or wrap as a bool/should for OR semantics. + * + * @param theOrList a list containing at least 1 predicate + * @return a predicate providing or-sematics over the list. + */ + private PredicateFinalStep orPredicateOrSingle(List theOrList, SearchPredicateFactory theFactory) { + if (theOrList.size() == 1) { + return theOrList.get(0); + } + + PredicateFinalStep finalClause; + BooleanPredicateClausesStep orClause = theFactory.bool(); + theOrList.forEach(orClause::should); + return orClause; + } + + + private Set extractOrStringParams(List theOrTerms) { + return theOrTerms.stream() + .map( StringParam.class::cast ) + .map( p -> StringUtils.defaultString(p.getValue()) ) + .map( String::trim ) + .collect(Collectors.toSet()); + } + + + + + @Override + public

Optional getParamPropertyValue(P theParam, String thePropName) { + return Optional.empty(); + } + + @Override + protected RestSearchParameterTypeEnum getParamEnumType() { return RestSearchParameterTypeEnum.STRING; } + + @Override + public List getParamProperties(IQueryParameterType theParam) { return mySearchProperties; } + + @Override + public boolean isNested() { return false; } + + +} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperToken.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperToken.java new file mode 100644 index 00000000000..aefc8bac629 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperToken.java @@ -0,0 +1,97 @@ +package ca.uhn.fhir.jpa.dao.search; + + +import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; +import ca.uhn.fhir.rest.param.TokenParam; +import org.apache.commons.lang3.StringUtils; +import org.hibernate.search.engine.search.predicate.dsl.BooleanPredicateClausesStep; +import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.SEARCH_PARAM_ROOT; + + +public class HSearchParamHelperToken extends HSearchParamHelper { + + private static final String CODE = "code"; + private static final String SYSTEM = "system"; + private static final String CODE_SYSTEM = "code-system"; + + private static final String CODE_PATH = String.join(".", SEARCH_PARAM_ROOT, "*", "token", CODE); + private static final String SYSTEM_PATH = String.join(".", SEARCH_PARAM_ROOT, "*", "token", SYSTEM ); + private static final String CODE_SYSTEM_PATH = String.join(".", SEARCH_PARAM_ROOT, "*", "token", CODE_SYSTEM ); + + private static final List mySearchProperties = List.of( CODE_PATH, SYSTEM_PATH, CODE_SYSTEM_PATH ); + + + + @Override + public void processOrTerms(SearchPredicateFactory theFactory, BooleanPredicateClausesStep theBool, + List theOrTerms, String theParamName) { + + if (theOrTerms.isEmpty()) { return; } + + TokenParam tokenParam = (TokenParam) theOrTerms.iterator().next(); + if ( tokenParam.getQueryParameterQualifier() != null && + tokenParam.getQueryParameterQualifier().equals(Constants.PARAMQUALIFIER_TOKEN_TEXT) ) { + + Set terms = extractOrTokenParams(theOrTerms); + var predicates = getPredicatesForTextQualifier(theFactory, terms, theParamName); + theBool.must( predicates ); + return; + } + + // normal processing applies for other than TEXT token params + super.processOrTerms(theFactory, theBool, theOrTerms, theParamName); + } + + + + @Override + public

Optional getParamPropertyValue(P theParam, String thePropName) { + TokenParam tokenParam = (TokenParam) theParam; + + if (thePropName.endsWith(CODE)) { + return Optional.ofNullable( tokenParam.getValue() ); + } + + if (thePropName.endsWith("." + SYSTEM)) { + return Optional.ofNullable( tokenParam.getSystem() ); + } + +// fixme jm: when? +// if (thePropName.endsWith(CODE_SYSTEM)) { +// return Optional.ofNullable( tokenParam.getSystem() + "|" + tokenParam.getValue() ); +// } + + return Optional.empty(); + } + + + private Set extractOrTokenParams(List theOrTerms) { + return theOrTerms.stream() + .map( TokenParam.class::cast ) + .map( p -> StringUtils.defaultString(p.getValue()) ) + .map( String::trim ) + .collect(Collectors.toSet()); + } + + + @Override + protected RestSearchParameterTypeEnum getParamEnumType() { return RestSearchParameterTypeEnum.TOKEN; } + + @Override + public List getParamProperties(IQueryParameterType theParam) { return mySearchProperties; } + + @Override + public boolean isNested() { return false; } + + + +} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperUri.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperUri.java new file mode 100644 index 00000000000..ae2437618fa --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperUri.java @@ -0,0 +1,41 @@ +package ca.uhn.fhir.jpa.dao.search; + + +import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; +import ca.uhn.fhir.rest.param.NumberParam; +import ca.uhn.fhir.rest.param.UriParam; + +import java.util.List; +import java.util.Optional; + +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.NESTED_SEARCH_PARAM_ROOT; +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.SEARCH_PARAM_ROOT; +import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.URI_VALUE; + + +public class HSearchParamHelperUri extends HSearchParamHelper { + + private static final String PATH = String.join(".", SEARCH_PARAM_ROOT, "*", URI_VALUE); + + private static final List mySearchProperties = List.of( PATH ); + + + @Override + public

Optional getParamPropertyValue(P theParam, String thePropName) { + UriParam param = (UriParam) theParam; + return Optional.of(param.getValue()); + } + + + @Override + protected RestSearchParameterTypeEnum getParamEnumType() { return RestSearchParameterTypeEnum.URI; } + + @Override + public List getParamProperties(IQueryParameterType theParam) { return mySearchProperties; } + + @Override + public boolean isNested() { return PATH.startsWith(NESTED_SEARCH_PARAM_ROOT); } + + +} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/IHSearchParamHelperProvider.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/IHSearchParamHelperProvider.java new file mode 100644 index 00000000000..8fed3e0be7c --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/IHSearchParamHelperProvider.java @@ -0,0 +1,6 @@ +package ca.uhn.fhir.jpa.dao.search; + +public interface IHSearchParamHelperProvider { + + HSearchParamHelper provideHelper(String theResourceTypeName, String theParamName); +} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/IHSearchParameterClauseBuilder.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/IHSearchParameterClauseBuilder.java new file mode 100644 index 00000000000..d40f0394ace --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/IHSearchParameterClauseBuilder.java @@ -0,0 +1,10 @@ +package ca.uhn.fhir.jpa.dao.search; + +import ca.uhn.fhir.rest.param.ParamPrefixEnum; +import org.hibernate.search.engine.search.predicate.dsl.BooleanPredicateClausesStep; + +public interface IHSearchParameterClauseBuilder { + + void addPredicate(ParamPrefixEnum thePrefix, BooleanPredicateClausesStep thePredicateStep, String theFieldPath, double theValue); + +} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/IPrefixedNumberPredicateHelper.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/IPrefixedNumberPredicateHelper.java new file mode 100644 index 00000000000..e1b02ea5e2b --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/IPrefixedNumberPredicateHelper.java @@ -0,0 +1,11 @@ +package ca.uhn.fhir.jpa.dao.search; + +import ca.uhn.fhir.rest.param.ParamPrefixEnum; +import org.hibernate.search.engine.search.predicate.dsl.BooleanPredicateClausesStep; +import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory; + +public interface IPrefixedNumberPredicateHelper { + + void addPredicate(SearchPredicateFactory theFactory, BooleanPredicateClausesStep theBool, ParamPrefixEnum thePrefix, double theValue, String thePropertyPath); + +} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/IPrefixedNumberPredicateHelperImpl.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/IPrefixedNumberPredicateHelperImpl.java new file mode 100644 index 00000000000..e1412e0ee59 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/IPrefixedNumberPredicateHelperImpl.java @@ -0,0 +1,67 @@ +package ca.uhn.fhir.jpa.dao.search; + +import ca.uhn.fhir.rest.param.ParamPrefixEnum; +import org.hibernate.search.engine.search.predicate.dsl.BooleanPredicateClausesStep; +import org.hibernate.search.engine.search.predicate.dsl.SearchPredicateFactory; + +public class IPrefixedNumberPredicateHelperImpl implements IPrefixedNumberPredicateHelper { + + private static final double APPROX_TOLERANCE_PERCENT = .10; + private static final double TOLERANCE_PERCENT = .05; + + + @Override + public void addPredicate(SearchPredicateFactory theFactory, BooleanPredicateClausesStep theBool, + ParamPrefixEnum thePrefix, double theValue, String thePropertyPath) { + + double approxTolerance = theValue * APPROX_TOLERANCE_PERCENT; + double defaultTolerance = theValue * TOLERANCE_PERCENT; + + switch (thePrefix) { + // searches for resource quantity between passed param value +/- 10% + case APPROXIMATE: + theBool.must(theFactory.range() + .field(thePropertyPath).between(theValue-approxTolerance, theValue+approxTolerance)); + break; + + // searches for resource quantity between passed param value +/- 5% + case EQUAL: + theBool.must(theFactory.range() + .field(thePropertyPath).between(theValue-defaultTolerance, theValue+defaultTolerance)); + break; + + // searches for resource quantity > param value + case GREATERTHAN: + case STARTS_AFTER: // treated as GREATERTHAN because search doesn't handle ranges + theBool.must(theFactory.range() + .field(thePropertyPath).greaterThan(theValue)); + break; + + // searches for resource quantity not < param value + case GREATERTHAN_OR_EQUALS: + theBool.must(theFactory.range() + .field(thePropertyPath).atLeast(theValue)); + break; + + // searches for resource quantity < param value + case LESSTHAN: + case ENDS_BEFORE: // treated as LESSTHAN because search doesn't handle ranges + theBool.must(theFactory.range() + .field(thePropertyPath).lessThan(theValue)); + break; + + // searches for resource quantity not > param value + case LESSTHAN_OR_EQUALS: + theBool.must(theFactory.range() + .field(thePropertyPath).atMost(theValue)); + break; + + // NOT_EQUAL: searches for resource quantity not between passed param value +/- 5% + case NOT_EQUAL: + theBool.mustNot(theFactory.range() + .field(thePropertyPath).between(theValue-defaultTolerance, theValue+defaultTolerance)); + break; + } + + } +} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/LastNOperation.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/LastNOperation.java index b99b4a8d656..4103181aadf 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/LastNOperation.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/LastNOperation.java @@ -42,13 +42,15 @@ public class LastNOperation { private final ModelConfig myModelConfig; private final ISearchParamRegistry mySearchParamRegistry; private final ExtendedHSearchSearchBuilder myExtendedHSearchSearchBuilder = new ExtendedHSearchSearchBuilder(); + private final IHSearchParamHelperProvider myHSearchParamHelperProvider; public LastNOperation(SearchSession theSession, FhirContext theFhirContext, ModelConfig theModelConfig, - ISearchParamRegistry theSearchParamRegistry) { + ISearchParamRegistry theSearchParamRegistry, IHSearchParamHelperProvider theHSearchParamHelperProvider) { mySession = theSession; myFhirContext = theFhirContext; myModelConfig = theModelConfig; mySearchParamRegistry = theSearchParamRegistry; + myHSearchParamHelperProvider = theHSearchParamHelperProvider; } public List executeLastN(SearchParameterMap theParams, Integer theMaximumResults) { @@ -61,8 +63,8 @@ public List executeLastN(SearchParameterMap theParams, Integer theMaximumR .where(f -> f.bool(b -> { // Must match observation type b.must(f.match().field("myResourceType").matching(OBSERVATION_RES_TYPE)); - ExtendedHSearchClauseBuilder builder = new ExtendedHSearchClauseBuilder(myFhirContext, myModelConfig, b, f); - myExtendedHSearchSearchBuilder.addAndConsumeAdvancedQueryClauses(builder, OBSERVATION_RES_TYPE, theParams.clone(), mySearchParamRegistry); + HSearchClauseProvider clauseProvider = new HSearchClauseProvider(myHSearchParamHelperProvider, f, b); + myExtendedHSearchSearchBuilder.addAndConsumeAdvancedQueryClauses(clauseProvider, OBSERVATION_RES_TYPE, theParams.clone(), mySearchParamRegistry); })) .aggregation(observationsByCodeKey, f -> f.fromJson(lastNAggregation.toAggregation())) .fetch(0); diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/ParamPrefixEnumHelper.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/ParamPrefixEnumHelper.java new file mode 100644 index 00000000000..3ab9cd48106 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/ParamPrefixEnumHelper.java @@ -0,0 +1,51 @@ +package ca.uhn.fhir.jpa.dao.search; + +import ca.uhn.fhir.rest.param.ParamPrefixEnum; +import org.apache.commons.lang3.StringUtils; +import org.hibernate.search.engine.search.predicate.dsl.BooleanPredicateClausesStep; + +import java.util.Arrays; +import java.util.EnumMap; +import java.util.Map; + +public class ParamPrefixEnumHelper implements IHSearchParameterClauseBuilder { + + private final Map OPERATION_MAP; + + +// OPERATION_MAP.put(ParamPrefixEnum.APPROXIMATE, this::addPredicateForApproximate), +// OPERATION_MAP.put(ParamPrefixEnum.ENDS_BEFORE, +// OPERATION_MAP.put(ParamPrefixEnum.EQUAL, +// OPERATION_MAP.put(ParamPrefixEnum.GREATERTHAN, +// OPERATION_MAP.put(ParamPrefixEnum.GREATERTHAN_OR_EQUALS, +// OPERATION_MAP.put(ParamPrefixEnum.LESSTHAN, +// OPERATION_MAP.put(ParamPrefixEnum.LESSTHAN_OR_EQUALS, +// OPERATION_MAP.put(ParamPrefixEnum.NOT_EQUAL, +// OPERATION_MAP.put(ParamPrefixEnum.STARTS_AFTER, +// + + public ParamPrefixEnumHelper() { + OPERATION_MAP = new EnumMap<>(ParamPrefixEnum.class); + +// OPERATION_MAP.put(ParamPrefixEnum.APPROXIMATE, this::addPredicate), +// OPERATION_MAP.put(ParamPrefixEnum.ENDS_BEFORE, +// OPERATION_MAP.put(ParamPrefixEnum.EQUAL, +// OPERATION_MAP.put(ParamPrefixEnum.GREATERTHAN, +// OPERATION_MAP.put(ParamPrefixEnum.GREATERTHAN_OR_EQUALS, +// OPERATION_MAP.put(ParamPrefixEnum.LESSTHAN, +// OPERATION_MAP.put(ParamPrefixEnum.LESSTHAN_OR_EQUALS, +// OPERATION_MAP.put(ParamPrefixEnum.NOT_EQUAL, +// OPERATION_MAP.put(ParamPrefixEnum.STARTS_AFTER, + + if (Arrays.stream(ParamPrefixEnum.values()).anyMatch(it -> !OPERATION_MAP.containsKey(it))) { + throw new IllegalStateException("Unmapped enum constant found!"); + } + } + + + @Override + public void addPredicate(ParamPrefixEnum thePrefix, BooleanPredicateClausesStep thePredicateStep, String theFieldPath, double theValue) { + + } + +} diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/autocomplete/TokenAutocompleteSearch.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/autocomplete/TokenAutocompleteSearch.java index 76d19ba0644..d6c76cac27e 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/autocomplete/TokenAutocompleteSearch.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/autocomplete/TokenAutocompleteSearch.java @@ -21,7 +21,6 @@ */ import ca.uhn.fhir.context.FhirContext; -import ca.uhn.fhir.jpa.dao.search.ExtendedHSearchClauseBuilder; import ca.uhn.fhir.jpa.model.entity.ModelConfig; import ca.uhn.fhir.jpa.model.entity.ResourceTable; import com.google.gson.JsonObject; @@ -47,13 +46,9 @@ class TokenAutocompleteSearch { private static final Logger ourLog = LoggerFactory.getLogger(TokenAutocompleteSearch.class); private static final AggregationKey AGGREGATION_KEY = AggregationKey.of("autocomplete"); - private final FhirContext myFhirContext; - private final ModelConfig myModelConfig; private final SearchSession mySession; - public TokenAutocompleteSearch(FhirContext theFhirContext, ModelConfig theModelConfig, SearchSession theSession) { - myFhirContext = theFhirContext; - myModelConfig = theModelConfig; + public TokenAutocompleteSearch(SearchSession theSession) { mySession = theSession; } @@ -74,11 +69,9 @@ public List search(String theResourceName, String theSPNam // compose the query json SearchQueryOptionsStep query = mySession.search(ResourceTable.class) .where(predFactory -> predFactory.bool(boolBuilder -> { - ExtendedHSearchClauseBuilder clauseBuilder = new ExtendedHSearchClauseBuilder(myFhirContext, myModelConfig, boolBuilder, predFactory); - // we apply resource-level predicates here, at the top level if (isNotBlank(theResourceName)) { - clauseBuilder.addResourceTypeClause(theResourceName); + boolBuilder.must(predFactory.match().field("myResourceType").matching(theResourceName)); } })) .aggregation(AGGREGATION_KEY, buildAggregation(tokenAutocompleteAggregation)); diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/autocomplete/ValueSetAutocompleteSearch.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/autocomplete/ValueSetAutocompleteSearch.java index 04535371871..5b31c58eb6f 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/autocomplete/ValueSetAutocompleteSearch.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/autocomplete/ValueSetAutocompleteSearch.java @@ -42,7 +42,7 @@ public class ValueSetAutocompleteSearch { public ValueSetAutocompleteSearch(FhirContext theFhirContext, ModelConfig theModelConfig, SearchSession theSession) { myFhirContext = theFhirContext; myModelConfig = theModelConfig; - myAutocompleteSearch = new TokenAutocompleteSearch(myFhirContext, myModelConfig, theSession); + myAutocompleteSearch = new TokenAutocompleteSearch(theSession); } public IBaseResource search(ValueSetAutocompleteOptions theOptions) { diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/elastic/HapiElasticsearchAnalysisConfigurer.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/elastic/HapiElasticsearchAnalysisConfigurer.java new file mode 100644 index 00000000000..b994c457186 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/elastic/HapiElasticsearchAnalysisConfigurer.java @@ -0,0 +1,97 @@ +package ca.uhn.fhir.jpa.search.elastic; + +/*- + * #%L + * HAPI FHIR JPA Server + * %% + * Copyright (C) 2014 - 2022 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers; +import org.hibernate.search.backend.elasticsearch.analysis.ElasticsearchAnalysisConfigurationContext; +import org.hibernate.search.backend.elasticsearch.analysis.ElasticsearchAnalysisConfigurer; + +public class HapiElasticsearchAnalysisConfigurer implements ElasticsearchAnalysisConfigurer{ + + @Override + public void configure(ElasticsearchAnalysisConfigurationContext theConfigCtx) { + + theConfigCtx.analyzer("autocompleteEdgeAnalyzer").custom() + .tokenizer("pattern_all") + .tokenFilters("lowercase", "stop", "edgengram_3_50"); + + theConfigCtx.tokenizer("pattern_all") + .type("pattern") + .param("pattern", "(.*)") + .param("group", "1"); + + theConfigCtx.tokenFilter("edgengram_3_50") + .type("edgeNGram") + .param("min_gram", "3") + .param("max_gram", "50"); + + + theConfigCtx.analyzer("autocompleteWordEdgeAnalyzer").custom() + .tokenizer("standard") + .tokenFilters("lowercase", "stop", "wordedgengram_3_50"); + + theConfigCtx.tokenFilter("wordedgengram_3_50") + .type("edgeNGram") + .param("min_gram", "3") + .param("max_gram", "20"); + + theConfigCtx.analyzer("autocompletePhoneticAnalyzer").custom() + .tokenizer("standard") + .tokenFilters("stop", "snowball_english"); + + theConfigCtx.tokenFilter("snowball_english") + .type("snowball") + .param("language", "English"); + + theConfigCtx.analyzer("autocompleteNGramAnalyzer").custom() + .tokenizer("standard") + .tokenFilters("word_delimiter", "lowercase", "ngram_3_20"); + + theConfigCtx.tokenFilter("ngram_3_20") + .type("nGram") + .param("min_gram", "3") + .param("max_gram", "20"); + + + theConfigCtx.analyzer(HapiHSearchAnalysisConfigurers.HapiLuceneAnalysisConfigurer.STANDARD_ANALYZER).custom() + .tokenizer("standard") + .tokenFilters("lowercase", "asciifolding"); + + theConfigCtx.analyzer(HapiHSearchAnalysisConfigurers.HapiLuceneAnalysisConfigurer.NORM_STRING_ANALYZER).custom() + .tokenizer("keyword") // We need the whole string to match, including whitespace. + .tokenFilters("lowercase", "asciifolding"); + + theConfigCtx.analyzer("exactAnalyzer") + .custom() + .tokenizer("keyword") + .tokenFilters("unique"); + + theConfigCtx.analyzer("conceptParentPidsAnalyzer").custom() + .tokenizer("whitespace"); + + theConfigCtx.analyzer("termConceptPropertyAnalyzer").custom() + .tokenizer("whitespace"); + + theConfigCtx.normalizer( "lowercase" ).custom() + .tokenFilters( "lowercase", "asciifolding" ); + + } +} diff --git a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperProviderImplTest.java b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperProviderImplTest.java new file mode 100644 index 00000000000..458941cd096 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperProviderImplTest.java @@ -0,0 +1,82 @@ +package ca.uhn.fhir.jpa.dao.search; + +import ca.uhn.fhir.context.RuntimeSearchParam; +import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; +import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; +import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; +import ca.uhn.fhir.rest.server.util.ResourceSearchParams; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class HSearchParamHelperProviderImplTest { + + @InjectMocks + private final IHSearchParamHelperProvider tested = new HSearchParamHelperProviderImpl(); + + @Mock private ISearchParamRegistry mockSearchParamRegistry; + @Mock private ResourceSearchParams mockSearchParams; + @Mock private RuntimeSearchParam mockSearchParam; + + + + @Test + void provideHelperNotCachedThenCached() { + HSearchParamHelper.registerChildHelper(new HSearchParamHelperUri()); + when(mockSearchParamRegistry.getActiveSearchParams("Observation")).thenReturn(mockSearchParams); + when(mockSearchParams.get("_profile")).thenReturn(mockSearchParam); + when(mockSearchParam.getParamType()).thenReturn(RestSearchParameterTypeEnum.URI); + + // first request finds no cached helper + HSearchParamHelper providedHelper = tested.provideHelper("Observation", "_profile"); + + assertTrue( providedHelper instanceof HSearchParamHelperUri ); + + // second request finds cached helper (no request to mockSearchParamRegistry) + HSearchParamHelper providedHelper2 = tested.provideHelper("Observation", "_profile"); + + assertEquals(providedHelper, providedHelper2); + // only called once for the first call + verify(mockSearchParamRegistry, times(1)).getActiveSearchParams(any()); + } + + + @Test + void findHelperNoParamNameForResourceThrows() { + when(mockSearchParamRegistry.getActiveSearchParams("RiskAssessment")).thenReturn(mockSearchParams); + when(mockSearchParams.get("probability")).thenReturn(null); + + InternalErrorException thrown = assertThrows(InternalErrorException.class, + () -> tested.provideHelper("RiskAssessment", "probability")); + + assertTrue(thrown.getMessage().contains("Failed to obtain parameter type for resource")); + } + + @Test + void findHelperNoHelperRegisteredForParamThrows() { + when(mockSearchParamRegistry.getActiveSearchParams("RiskAssessment")).thenReturn(mockSearchParams); + when(mockSearchParams.get("probability")).thenReturn(mockSearchParam); + when(mockSearchParam.getParamType()).thenReturn(RestSearchParameterTypeEnum.NUMBER); + + InternalErrorException thrown = assertThrows(InternalErrorException.class, + () -> tested.provideHelper("RiskAssessment", "probability")); + + assertTrue(thrown.getMessage().contains("HSearchParamHelper.myTypeHelperMap doesn't contain an entry for")); + } +} diff --git a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperTest.java b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperTest.java new file mode 100644 index 00000000000..e39310622b4 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/dao/search/HSearchParamHelperTest.java @@ -0,0 +1,38 @@ +package ca.uhn.fhir.jpa.dao.search; + +import ca.uhn.fhir.context.RuntimeSearchParam; +import ca.uhn.fhir.jpa.searchparam.registry.SearchParamRegistryImpl; +import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; +import ca.uhn.fhir.rest.server.util.ResourceSearchParams; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class HSearchParamHelperTest { + + @InjectMocks + private HSearchParamHelper testedHelper = new HSearchParamHelperToken(); + + @Mock private SearchParamRegistryImpl mySearchParamRegistry; + @Mock private ResourceSearchParams mockedResourceSearchParams; + @Mock private RuntimeSearchParam mockedRuntimeSearchParam; + + + @Test + void testChildRegisteredWithParent() { +// when(mySearchParamRegistry.getActiveSearchParams("Observation")).thenReturn(mockedResourceSearchParams); +// when(mockedResourceSearchParams.get("_security")).thenReturn(mockedRuntimeSearchParam); +// when(mockedRuntimeSearchParam.getParamType()).thenReturn(RestSearchParameterTypeEnum.TOKEN); +// +// HSearchParamHelper typedHelper = testedHelper.getTypeHelper("Observation", "_security"); +// +// assertEquals( RestSearchParameterTypeEnum.TOKEN, typedHelper.getType() ); + + } +} diff --git a/hapi-fhir-jpaserver-model/src/main/java/ca/uhn/fhir/jpa/model/search/HSearchIndexWriter.java b/hapi-fhir-jpaserver-model/src/main/java/ca/uhn/fhir/jpa/model/search/HSearchIndexWriter.java index 79d4fdf43b5..8c6c5061eb7 100644 --- a/hapi-fhir-jpaserver-model/src/main/java/ca/uhn/fhir/jpa/model/search/HSearchIndexWriter.java +++ b/hapi-fhir-jpaserver-model/src/main/java/ca/uhn/fhir/jpa/model/search/HSearchIndexWriter.java @@ -42,6 +42,11 @@ public class HSearchIndexWriter { public static final String NESTED_SEARCH_PARAM_ROOT = "nsp"; public static final String SEARCH_PARAM_ROOT = "sp"; + public static final String DATE_LOWER_ORD = "lower-ord"; + public static final String DATE_LOWER = "lower"; + public static final String DATE_UPPER_ORD = "upper-ord"; + public static final String DATE_UPPER = "upper"; + public static final String QTY_PARAM_NAME = "quantity"; public static final String QTY_CODE = "code"; public static final String QTY_SYSTEM = "system"; @@ -118,17 +123,16 @@ public void writeReferenceIndex(String theSearchParam, String theValue) { public void writeDateIndex(String theSearchParam, DateSearchIndexData theValue) { DocumentElement dateIndexNode = getSearchParamIndexNode(theSearchParam, "dt"); // Lower bound - dateIndexNode.addValue("lower-ord", theValue.getLowerBoundOrdinal()); - dateIndexNode.addValue("lower", theValue.getLowerBoundDate().toInstant()); + dateIndexNode.addValue(DATE_LOWER_ORD, theValue.getLowerBoundOrdinal()); + dateIndexNode.addValue(DATE_LOWER, theValue.getLowerBoundDate().toInstant()); // Upper bound - dateIndexNode.addValue("upper-ord", theValue.getUpperBoundOrdinal()); - dateIndexNode.addValue("upper", theValue.getUpperBoundDate().toInstant()); + dateIndexNode.addValue(DATE_UPPER_ORD, theValue.getUpperBoundOrdinal()); + dateIndexNode.addValue(DATE_UPPER, theValue.getUpperBoundDate().toInstant()); ourLog.trace("Adding Search Param Date. param: {} -- {}", theSearchParam, theValue); } - public void writeQuantityIndex(String theSearchParam, Collection theValueCollection) { DocumentElement nestedRoot = myNodeCache.getObjectElement(NESTED_SEARCH_PARAM_ROOT); diff --git a/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/r4/FhirResourceDaoR4SearchWithElasticSearchIT.java b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/r4/FhirResourceDaoR4SearchWithElasticSearchIT.java index fd3b71c9d26..e4fb297eba9 100644 --- a/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/r4/FhirResourceDaoR4SearchWithElasticSearchIT.java +++ b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/r4/FhirResourceDaoR4SearchWithElasticSearchIT.java @@ -2360,13 +2360,17 @@ void specCase7() { @Test void specCase8() { - String raId1 = createRiskAssessmentWithPredictionProbability(99.4).getIdPart(); - String raId2 = createRiskAssessmentWithPredictionProbability(99.6).getIdPart(); - String raId3 = createRiskAssessmentWithPredictionProbability(100.4).getIdPart(); - String raId4 = createRiskAssessmentWithPredictionProbability(100.6).getIdPart(); - String raId5 = createRiskAssessmentWithPredictionProbability(100).getIdPart(); - // [parameter]=ne100 Values that are not equal to 100 (actually, in the range 99.5 to 100.5) - assertFindIds("when le", Set.of(raId1, raId2, raId3, raId4), "/RiskAssessment?probability=ne100"); + String raId1 = createRiskAssessmentWithPredictionProbability(91).getIdPart(); + String raId2 = createRiskAssessmentWithPredictionProbability(93).getIdPart(); + String raId3 = createRiskAssessmentWithPredictionProbability(96).getIdPart(); + String raId4 = createRiskAssessmentWithPredictionProbability(100).getIdPart(); + String raId5 = createRiskAssessmentWithPredictionProbability(101).getIdPart(); + String raId6 = createRiskAssessmentWithPredictionProbability(104).getIdPart(); + String raId7 = createRiskAssessmentWithPredictionProbability(105).getIdPart(); + String raId8 = createRiskAssessmentWithPredictionProbability(108).getIdPart(); + // [parameter]=ne100 Values that are not equal to 100 (actually, in the range 95 to 105) + assertFindIds("when le", Set.of(raId1, raId2, raId8), "/RiskAssessment?probability=ne100"); + } } diff --git a/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/r4/HSearchSandboxTest.java b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/r4/HSearchSandboxTest.java index a536b659ac5..c098f25c8be 100644 --- a/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/r4/HSearchSandboxTest.java +++ b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/r4/HSearchSandboxTest.java @@ -6,19 +6,26 @@ import ca.uhn.fhir.jpa.api.svc.ISearchCoordinatorSvc; import ca.uhn.fhir.jpa.bulk.export.api.IBulkDataExportJobSchedulingHelper; import ca.uhn.fhir.jpa.dao.TestDaoSearch; +import ca.uhn.fhir.jpa.dao.search.HSearchClauseProvider; +import ca.uhn.fhir.jpa.dao.search.IHSearchParamHelperProvider; import ca.uhn.fhir.jpa.model.entity.ResourceTable; import ca.uhn.fhir.jpa.search.reindex.IResourceReindexingSvc; import ca.uhn.fhir.jpa.test.BaseJpaTest; import ca.uhn.fhir.jpa.test.config.TestHSearchAddInConfig; import ca.uhn.fhir.jpa.test.config.TestR4Config; import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.rest.api.server.IBundleProvider; +import ca.uhn.fhir.rest.param.NumberParam; import ca.uhn.fhir.rest.param.ParamPrefixEnum; import ca.uhn.fhir.rest.param.QuantityParam; +import ca.uhn.fhir.rest.param.TokenParam; +import ca.uhn.fhir.rest.param.UriParam; import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; import ca.uhn.fhir.storage.test.DaoTestDataBuilder; import ca.uhn.fhir.test.utilities.ITestDataBuilder; import ca.uhn.fhir.test.utilities.docker.RequiresDocker; import com.google.common.collect.Lists; +import org.hamcrest.Matchers; import org.hibernate.search.engine.search.predicate.dsl.BooleanPredicateClausesStep; import org.hibernate.search.engine.search.predicate.dsl.MatchPredicateOptionsStep; import org.hibernate.search.engine.search.predicate.dsl.PredicateFinalStep; @@ -28,9 +35,12 @@ import org.hibernate.search.mapper.orm.session.SearchSession; import org.hl7.fhir.instance.model.api.IIdType; import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.CodeableConcept; +import org.hl7.fhir.r4.model.DecimalType; import org.hl7.fhir.r4.model.Meta; import org.hl7.fhir.r4.model.Observation; import org.hl7.fhir.r4.model.Quantity; +import org.hl7.fhir.r4.model.RiskAssessment; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Nested; @@ -43,18 +53,27 @@ import org.springframework.transaction.PlatformTransactionManager; import javax.persistence.EntityManager; +import java.math.BigDecimal; +import java.security.InvalidParameterException; +import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.stream.Collectors; import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.NESTED_SEARCH_PARAM_ROOT; import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_CODE; import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_PARAM_NAME; import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_SYSTEM; import static ca.uhn.fhir.jpa.model.search.HSearchIndexWriter.QTY_VALUE; +import static ca.uhn.fhir.jpa.model.util.UcumServiceUtil.UCUM_CODESYSTEM_URL; import static org.apache.commons.lang3.StringUtils.isNotBlank; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; /** - * Just a sandbox. Never intended to run by pipes + * Just a sandbox. Never intended to run in pipes */ @ExtendWith(SpringExtension.class) @RequiresDocker @@ -96,6 +115,17 @@ public class HSearchSandboxTest extends BaseJpaTest { @Qualifier("myObservationDaoR4") private IFhirResourceDao myObservationDao; + @Autowired + private TestDaoSearch myTestDaoSearch; + + @Autowired + private IHSearchParamHelperProvider myHSearchParamHelperProvider; + + @Autowired + @Qualifier("myRiskAssessmentDaoR4") + protected IFhirResourceDao myRiskAssessmentDao; + + // @BeforeEach // public void beforePurgeDatabase() { // purgeDatabase(myDaoConfig, mySystemDao, myResourceReindexingSvc, mySearchCoordinatorSvc, mySearchParamRegistry, myBulkDataScheduleHelper); @@ -115,7 +145,7 @@ public class NotNestedObjectQueries { * at the top level */ @Test - public void searchModelingMultipleAndWithOneOringClauseTest() { + public void searchWithMultipleAndWithOneOrClauseTest() { String system = "http://loinc.org"; Observation obs1 = new Observation(); obs1.getCode().setText("Systolic Blood Pressure"); @@ -147,7 +177,7 @@ public void searchModelingMultipleAndWithOneOringClauseTest() { * to be able to add a minimumShouldMatchNumber(1); to each group */ @Test - public void searchModelingMultipleAndWithMultipleOrClausesTest() { + public void searchWithMultipleAndWithMultipleOrClausesTest() { String system = "http://loinc.org"; Observation obs1 = new Observation(); obs1.getCode().setText("Systolic Blood Pressure"); @@ -182,9 +212,119 @@ public void searchModelingMultipleAndWithMultipleOrClausesTest() { }); } + @Test + public void searchWithMultipleAndMultipleOrClauseTestForNumber() { + String raId1 = createRiskAssessmentWithPredictionProbability(0.15).getIdPart(); + String raId2 = createRiskAssessmentWithPredictionProbability(0.20).getIdPart(); + String raId3 = createRiskAssessmentWithPredictionProbability(0.25).getIdPart(); + String raId4 = createRiskAssessmentWithPredictionProbability(0.35).getIdPart(); + String raId5 = createRiskAssessmentWithPredictionProbability(0.45).getIdPart(); + String raId6 = createRiskAssessmentWithPredictionProbability(0.55).getIdPart(); + + String paramName = "probability"; + String resourceTypeName = "RiskAssessment"; + // logically stupid but to test an and with 1 or and one with 2 ors + List> theAndOrTerms = List.of( + List.of( + new NumberParam().setValue( BigDecimal.valueOf(.35) ) + ), + List.of( + new NumberParam().setValue( BigDecimal.valueOf(.80) ), + new NumberParam().setValue( BigDecimal.valueOf(.35) ) + ) + ); + + runInTransaction(() -> { + SearchSession searchSession = Search.session(myEntityManager); + SearchResult result = searchSession.search(ResourceTable.class) + + .where(f -> f.bool(b -> { + b.must(f.match().field("myResourceType").matching(resourceTypeName)); + + HSearchClauseProvider clausesProvider = new HSearchClauseProvider(myHSearchParamHelperProvider, f, b); + clausesProvider.addAndConsumeAndPlusOrClauses(resourceTypeName, paramName, theAndOrTerms); + })) + .fetchAll(); + + List theResourceIds = result.hits().stream().map(r -> r.getIdType(myFhirContext).getIdPart()).collect(Collectors.toList()); + assertThat(theResourceIds, Matchers.contains(raId4)); + }); + } + + @Test + public void searchWithMultipleAndMultipleOrClauseTestForNumberWithPrefix() { + String raId1 = createRiskAssessmentWithPredictionProbability(99).getIdPart(); + String raId2 = createRiskAssessmentWithPredictionProbability(100).getIdPart(); + String raId3 = createRiskAssessmentWithPredictionProbability(101).getIdPart(); + // [parameter]=le100 Values that are less or equal to exactly 100 +// assertFindIds("when le", Set.of(raId1, raId2), "/RiskAssessment?probability=le100"); + + String paramName = "probability"; + String resourceTypeName = "RiskAssessment"; + // logically stupid but to test an and with 1 or and one with 2 ors + List> theAndOrTerms = List.of( + List.of( + new NumberParam().setValue( BigDecimal.valueOf(100) ).setPrefix(ParamPrefixEnum.LESSTHAN_OR_EQUALS) + )); + + runInTransaction(() -> { + SearchSession searchSession = Search.session(myEntityManager); + SearchResult result = searchSession.search(ResourceTable.class) + + .where(f -> f.bool(b -> { + b.must(f.match().field("myResourceType").matching(resourceTypeName)); + + HSearchClauseProvider clausesProvider = new HSearchClauseProvider(myHSearchParamHelperProvider, f, b); + clausesProvider.addAndConsumeAndPlusOrClauses(resourceTypeName, paramName, theAndOrTerms); + })) + .fetchAll(); + + List theResourceIds = result.hits().stream().map(r -> r.getIdType(myFhirContext).getIdPart()).collect(Collectors.toList()); + assertThat(theResourceIds, Matchers.contains(raId1, raId2)); + }); + } + + @Test + public void searchWithMultipleAndMultipleOrClauseTestForUri() { + String id = myTestDataBuilder.createObservation(List.of( + myTestDataBuilder.withProfile("http://example.com/theProfile"), + myTestDataBuilder.withProfile("http://example.com/anotherProfile"))).getIdPart(); + + String paramName = "_profile"; + String resourceTypeName = "Observation"; + // logically stupid but to test an and with 1 or and one with 2 ors + List> theAndOrTerms = List.of( + List.of( + new UriParam().setValue("http://example.com/theProfile") + ), + List.of( + new UriParam().setValue("http://example.com/non-existing-profile"), + new UriParam().setValue("http://example.com/anotherProfile") + ) + ); + + runInTransaction(() -> { + SearchSession searchSession = Search.session(myEntityManager); + SearchResult result = searchSession.search(ResourceTable.class) + + .where(f -> f.bool(b -> { + b.must(f.match().field("myResourceType").matching(resourceTypeName)); + + HSearchClauseProvider clausesProvider = new HSearchClauseProvider(myHSearchParamHelperProvider, f, b); + clausesProvider.addAndConsumeAndPlusOrClauses(resourceTypeName, paramName, theAndOrTerms); + })) + .fetchAll(); + + List theResourceIds = result.hits().stream().map(r -> r.getIdType(myFhirContext).getIdPart()).collect(Collectors.toList()); + assertThat(theResourceIds, Matchers.contains(id)); + }); + } + } + + @Nested public class NestedObjectQueries { @@ -193,11 +333,9 @@ public class NestedObjectQueries { * at the top level */ @Test - public void searchModelingAndMultipleAndWithOneOringClauseTest() { + public void searchWithAndMultipleAndWithOneOrClauseForQuantity() { IIdType myResourceId = myTestDataBuilder.createObservation(myTestDataBuilder.withElementAt("valueQuantity", myTestDataBuilder.withPrimitiveAttribute("value", 0.6) -// myTestDataBuilder.withPrimitiveAttribute("system", UCUM_CODESYSTEM_URL), -// myTestDataBuilder.withPrimitiveAttribute("code", "mm[Hg]") )); runInTransaction(() -> { @@ -214,83 +352,332 @@ public void searchModelingAndMultipleAndWithOneOringClauseTest() { )); })) .fetchAll(); -// long totalHitCount = result.total().hitCount(); -// List hits = result.hits(); }); } - /** * Shows that when there is multiple "and" clause with "or" entries, we need to group each one in a "must" clause * to be able to add a minimumShouldMatchNumber(1); to each group */ @Test - public void searchModelingMultipleAndWithMultipleOrClausesTest() { - IIdType myResourceId = myTestDataBuilder.createObservation(myTestDataBuilder.withElementAt("valueQuantity", - myTestDataBuilder.withPrimitiveAttribute("value", 0.6) -// myTestDataBuilder.withPrimitiveAttribute("system", UCUM_CODESYSTEM_URL), -// myTestDataBuilder.withPrimitiveAttribute("code", "mm[Hg]") - )); + public void searchWithMultipleAndWithMultipleOrClausesForQuantity() { + String obsId = myTestDataBuilder.createObservation(List.of( + myTestDataBuilder.withQuantityAtPath("valueQuantity", 0.02, UCUM_CODESYSTEM_URL, "10*6/L") + )).getIdPart(); + +// runInTransaction(() -> { +// SearchSession searchSession = Search.session(myEntityManager); +// SearchResult result = searchSession.search(ResourceTable.class) +// .where(f -> f.bool(b -> { +// b.must(f.match().field("myResourceType").matching("Observation")); +// b.must(f.nested().objectField("nsp.value-quantity") +// .nest(f.bool() +// .must(f.range().field("nsp.value-quantity.quantity.value").lessThan(0.7)) +// +// .must(f.bool(b1 -> { +// b1.should(f.range().field("nsp.value-quantity.quantity.value").between(0.475, 0.525)); +// b1.should(f.range().field("nsp.value-quantity.quantity.value").between(0.57, 0.63)); +// b1.minimumShouldMatchNumber(1); +// })) +// +// .must(f.bool(b1 -> { +// b1.should(f.range().field("nsp.value-quantity.quantity.value").between(0.2, 0.8)); +// b1.should(f.range().field("nsp.value-quantity.quantity.value").between(0.7, 0.9)); +// b1.minimumShouldMatchNumber(1); +// })) +// )); +// })) +// .fetchAll(); +// assertEquals(1, result.total().hitCount()); +// }); + + String paramName = "value-quantity"; + String resourceTypeName = "Observation"; + List> theAndOrTerms = List.of( + List.of( + new QuantityParam().setSystem(UCUM_CODESYSTEM_URL).setValue(0.02).setUnits("10*6/L") ), + List.of( + new QuantityParam().setSystem(UCUM_CODESYSTEM_URL).setValue(0.02).setUnits("10*6/L"), + new QuantityParam().setSystem("http://example.com").setValue(0.04).setUnits("10*3/L") + ) + ); runInTransaction(() -> { SearchSession searchSession = Search.session(myEntityManager); SearchResult result = searchSession.search(ResourceTable.class) + .where(f -> f.bool(b -> { - b.must(f.match().field("myResourceType").matching("Observation")); - b.must(f.nested().objectField("nsp.value-quantity") - .nest(f.bool() - .must(f.range().field("nsp.value-quantity.quantity.value").lessThan(0.7)) + b.must(f.match().field("myResourceType").matching(resourceTypeName)); - .must(f.bool(p -> { - p.should(f.range().field("nsp.value-quantity.quantity.value").between(0.475, 0.525)); - p.should(f.range().field("nsp.value-quantity.quantity.value").between(0.57, 0.63)); - p.minimumShouldMatchNumber(1); - })) + HSearchClauseProvider clausesProvider = new HSearchClauseProvider(myHSearchParamHelperProvider, f, b); + clausesProvider.addAndConsumeAndPlusOrClauses(resourceTypeName, paramName, theAndOrTerms); + })) + .fetchAll(); + assertEquals(1, result.total().hitCount()); + }); - .must(f.bool(p -> { - p.should(f.range().field("nsp.value-quantity.quantity.value").between(0.2, 0.8)); - p.should(f.range().field("nsp.value-quantity.quantity.value").between(0.7, 0.9)); - p.minimumShouldMatchNumber(1); - })) + } - .minimumShouldMatchNumber(1) - )); + @Test + public void searchWithMultipleAndWithMultipleOrClausesForQuantityWithPrefix() { + String obsId = myTestDataBuilder.createObservation(List.of( + myTestDataBuilder.withQuantityAtPath("valueQuantity", 0.02, UCUM_CODESYSTEM_URL, "10*6/L") + )).getIdPart(); + + String paramName = "value-quantity"; + String resourceTypeName = "Observation"; + List> theAndOrTerms = List.of( + List.of( + new QuantityParam().setSystem(UCUM_CODESYSTEM_URL).setValue(0.028).setUnits("10*6/L").setPrefix(ParamPrefixEnum.LESSTHAN_OR_EQUALS) ), + List.of( + new QuantityParam().setSystem("http://example.com").setValue(0.03).setUnits("10*6/L").setPrefix(ParamPrefixEnum.GREATERTHAN), + new QuantityParam().setSystem(UCUM_CODESYSTEM_URL).setValue(0.025).setUnits("10*3/L").setPrefix(ParamPrefixEnum.LESSTHAN) + ) + ); + + runInTransaction(() -> { + SearchSession searchSession = Search.session(myEntityManager); + SearchResult result = searchSession.search(ResourceTable.class) + + .where(f -> f.bool(b -> { + b.must(f.match().field("myResourceType").matching(resourceTypeName)); + + HSearchClauseProvider clausesProvider = new HSearchClauseProvider(myHSearchParamHelperProvider, f, b); + clausesProvider.addAndConsumeAndPlusOrClauses(resourceTypeName, paramName, theAndOrTerms); })) .fetchAll(); -// long totalHitCount = result.total().hitCount(); -// List hits = result.hits(); + assertEquals(1, result.total().hitCount()); }); + + } + + @Test + public void searchWithMultipleAndMultipleOrClauseTestForToken() { + String id = myTestDataBuilder.createObservation(List.of( + myTestDataBuilder.withSecurity("http://example1.com", "security-label-1"), + myTestDataBuilder.withSecurity("http://example2.com", "security-label-2"))).getIdPart(); + +// List allIds = myTestDaoSearch.searchForIds("/Observation" + +// "?_security=http://example1.com|security-label-1" + +// "&_security=http://example3.com|non-existing-security-label,http://example1.com|security-label-1" + +// "&_security=http://example3.com|other-non-existing-security-label,http://example2.com|security-label-2"); +// +// assertThat(allIds, contains(id)); + + String paramName = "_security"; + String resourceTypeName = "Observation"; + List> theAndOrTerms = List.of( + List.of( + new TokenParam().setSystem("http://example1.com").setValue("security-label-1")), + List.of( + new TokenParam().setSystem("http://example3.com").setValue("non-existing-security-label"), + new TokenParam().setSystem("http://example1.com").setValue("security-label-1") + ) + ); + +////////////// model query. Don't delete ///////////////////////////////////// + // runInTransaction(() -> { // SearchSession searchSession = Search.session(myEntityManager); // SearchResult result = searchSession.search(ResourceTable.class) +// // .where(f -> f.bool(b -> { // b.must(f.match().field("myResourceType").matching("Observation")); -// b.must(f.bool() -// .must(f.range().field("nsp.value-quantity.quantity.value").lessThan(0.7)) // -// .must(f.bool(p -> { -// p.should(f.range().field("nsp.value-quantity.quantity.value").between(0.475, 0.525)); -// p.should(f.range().field("nsp.value-quantity.quantity.value").between(0.57, 0.63)); -// p.minimumShouldMatchNumber(1); -// })) +// b.must(f.nested().objectField("nsp._security") +// .nest( f.bool( b1 -> { +//// // and clause with 1 or clause +// b1.must(f.match().field("nsp._security.token.system").matching("http://example1.com")); +// b1.must(f.match().field("nsp._security.token.code").matching("security-label-1")); // -// .must(f.bool(p -> { -// p.should(f.range().field("nsp.value-quantity.quantity.value").between(0.2, 0.8)); -// p.should(f.range().field("nsp.value-quantity.quantity.value").between(0.7, 0.9)); -// p.minimumShouldMatchNumber(1); -// })) +// // and clause with 2 (composite) or clauses +// b1.must( f.bool( b2 -> { +// b2.minimumShouldMatchNumber(1); // -// .minimumShouldMatchNumber(1) -// ); +// var paramPred1 = f.bool(); +// paramPred1.must( f.match().field("nsp._security.token.system").matching("http://example3.com") ); +// paramPred1.must( f.match().field("nsp._security.token.code").matching("non-existing-security-label") ); +// b2.should(paramPred1); +// +// var paramPred2 = f.bool(); +// paramPred2.must( f.match().field("nsp._security.token.system").matching("http://example1.com") ); +// paramPred2.must( f.match().field("nsp._security.token.code").matching("security-label-1") ); +// b2.should(paramPred2); +// })); +// })) +// ); // })) // .fetchAll(); -//// long totalHitCount = result.total().hitCount(); -//// List hits = result.hits(); +// assertEquals(1, result.total().hitCount()); // }); + runInTransaction(() -> { + SearchSession searchSession = Search.session(myEntityManager); + SearchResult result = searchSession.search(ResourceTable.class) + + .where(f -> f.bool(b -> { + b.must(f.match().field("myResourceType").matching(resourceTypeName)); + + HSearchClauseProvider clausesProvider = new HSearchClauseProvider(myHSearchParamHelperProvider, f, b); + clausesProvider.addAndConsumeAndPlusOrClauses(resourceTypeName, paramName, theAndOrTerms); + })) + .fetchAll(); + assertEquals(1, result.total().hitCount()); + }); } + + + @Test + void testMultipleComponentsHandlesAndOr() { + Observation obs1 = getObservation(); + addComponentWithCodeAndQuantity(obs1, "8480-6", 107); + addComponentWithCodeAndQuantity(obs1, "8462-4", 60); + + IIdType obs1Id = myObservationDao.create(obs1, mySrd).getId().toUnqualifiedVersionless(); + + Observation obs2 = getObservation(); + addComponentWithCodeAndQuantity(obs2, "8480-6",107); + addComponentWithCodeAndQuantity(obs2, "8462-4",260); + + myObservationDao.create(obs2, mySrd).getId().toUnqualifiedVersionless(); + +// // andClauses +// { +// String theUrl = "/Observation?component-value-quantity=107&component-value-quantity=60"; +// List resourceIds = myTestDaoSearch.searchForIds(theUrl); +// assertThat("when same component with qtys 107 and 60", resourceIds, hasItem(equalTo(obs1Id.getIdPart()))); +// } + + runInTransaction(() -> { + SearchSession searchSession = Search.session(myEntityManager); + SearchResult result = searchSession.search(ResourceTable.class) + .where(f -> f.bool(b -> { + b.must( f.match().field("myResourceType").matching("Observation")); + b.must( f.nested().objectField("nsp.component-value-quantity") + .nest( f.range().field("nsp.component-value-quantity.quantity.value").between(101.65, 112.35)) ); + b.must( f.nested().objectField("nsp.component-value-quantity") + .nest( f.range().field("nsp.component-value-quantity.quantity.value").between(57.0, 63.0)) ); + })) + .fetchAll(); + assertEquals(1, result.total().hitCount()); + }); + // { +// String theUrl = "/Observation?component-value-quantity=107&component-value-quantity=260"; +// List resourceIds = myTestDaoSearch.searchForIds(theUrl); +// assertThat("when same component with qtys 107 and 260", resourceIds, empty()); +// } +// +// //andAndOrClauses +// { +// String theUrl = "/Observation?component-value-quantity=107&component-value-quantity=gt50,lt70"; +// List resourceIds = myTestDaoSearch.searchForIds(theUrl); +// assertThat("when same component with qtys 107 and lt70,gt80", resourceIds, hasItem(equalTo(obs1Id.getIdPart()))); +// } +// { +// String theUrl = "/Observation?component-value-quantity=50,70&component-value-quantity=260"; +// List resourceIds = myTestDaoSearch.searchForIds(theUrl); +// assertThat("when same component with qtys 50,70 and 260", resourceIds, empty()); +// } +// +// // multipleAndsWithMultipleOrsEach +// { +// String theUrl = "/Observation?component-value-quantity=50,60&component-value-quantity=105,107"; +// List resourceIds = myTestDaoSearch.searchForIds(theUrl); +// assertThat("when same component with qtys 50,60 and 105,107", resourceIds, hasItem(equalTo(obs1Id.getIdPart()))); +// } +// { +// String theUrl = "/Observation?component-value-quantity=50,60&component-value-quantity=250,260"; +// List resourceIds = myTestDaoSearch.searchForIds(theUrl); +// assertThat("when same component with qtys 50,60 and 250,260", resourceIds, empty()); +// } + } + + + private Observation getObservation() { + Observation obs = new Observation(); + obs.getCode().addCoding().setCode("85354-9").setSystem("http://loinc.org"); + obs.setStatus(Observation.ObservationStatus.FINAL); + return obs; + } + + private Quantity getQuantity(double theValue) { + return new Quantity().setValue(theValue).setUnit("mmHg").setSystem("http://unitsofmeasure.org").setCode("mm[Hg]"); + } + + private Observation.ObservationComponentComponent addComponentWithCodeAndQuantity(Observation theObservation, String theConceptCode, double theQuantityValue) { + Observation.ObservationComponentComponent comp = theObservation.addComponent(); + CodeableConcept cc1_1 = new CodeableConcept(); + cc1_1.addCoding().setCode(theConceptCode).setSystem("http://loinc.org"); + comp.setCode(cc1_1); + comp.setValue(getQuantity(theQuantityValue)); + return comp; + } + } + //////////////////// last model before moving it to HSearchClauseProvider class - DON'T DELETE /////////////// + +// private PredicateFinalStep getAndPlusOrClauses(SearchPredicateFactory theF, BooleanPredicateClausesStep theB, +// String theResourceTypeName, String theParamName, List> theAndOrTerms) { +// +// boolean isPropertyNested = isNested(theParamName); +// +// BooleanPredicateClausesStep topBool = theF.bool(); +// // need an extra bool level for nested properties (embedding it under topBool before leaving method) +// BooleanPredicateClausesStep activeBool = isPropertyNested ? theF.bool() : topBool; +// +// for (List orTerms : theAndOrTerms) { +// if (orTerms.size() == 1) { +// addOrClause(theF, activeBool, theResourceTypeName, theParamName, orTerms.get(0)); +// continue; +// } +// +// // multiple or predicates must be in must group with multiple should(s) with a minimumShouldMatchNumber(1) +// activeBool.must(theF.bool(b2 -> { +// b2.minimumShouldMatchNumber(1); +// +// for (IQueryParameterType orTerm : orTerms) { +// var paramBool = theF.bool(); +// addOrClause(theF, paramBool, theResourceTypeName, theParamName, orTerm); +// b2.should(paramBool); +// } +// })); +// } +// +// if ( isPropertyNested ) { +// topBool.must(theF.nested().objectField("nsp." + theParamName).nest(activeBool)); +// } +// +// return topBool; +// } +// +// +// private HSearchParamHelper getParamHelper(String theResourceTypeName, String theParamName) { +// return myHSearchParamHelperProvider.provideHelper(theResourceTypeName, theParamName); +// } +// +// +// /** +// * Adding an OR clause doesn't necessarily mean one must clause, because for parameters with more than +// * one property, a must clause must be added for each property present +// */ +// private void addOrClause(SearchPredicateFactory theF, BooleanPredicateClausesStep theBool, +// String theResourceTypeName, String theParamName, IQueryParameterType theParam) { +// +// HSearchParamHelper paramHelper = getParamHelper(theResourceTypeName, theParamName); +// List paramProperties = paramHelper.getParamPropertiesForParameter(theParamName, theParam); +// for (String paramProperty : paramProperties) { +// Optional paramPropertyValue = paramHelper.getParamPropertyValue(theParam, paramProperty); +// paramPropertyValue.ifPresent(v -> theBool.must(theF.match().field(paramProperty).matching(v)) ); +// } +// } + + + + + + + /** * Following code is the beginning of refactoring the queries for cleaner structure, which means @@ -301,9 +688,8 @@ public class FragmentedCodeNotNested { private SearchPredicateFactory fact; - @Test - public void searchModelingMultipleAndOneOrClauseTest() { + public void searchWithMultipleAndOneOrClauseTest() { String system = "http://loinc.org"; Observation obs1 = new Observation(); obs1.getCode().setText("Systolic Blood Pressure"); @@ -337,54 +723,152 @@ public void searchModelingMultipleAndOneOrClauseTest() { }); } - @Test - public void searchModelingMultipleAndMultipleOrClauseTest() { - String system = "http://loinc.org"; - Observation obs1 = new Observation(); - obs1.getCode().setText("Systolic Blood Pressure"); - obs1.getCode().addCoding().setCode("obs1").setSystem(system).setDisplay("Systolic Blood Pressure"); - obs1.setStatus(Observation.ObservationStatus.FINAL); - obs1.setValue(new Quantity(123)); - obs1.getNoteFirstRep().setText("obs1"); - IIdType id1 = myObservationDao.create(obs1, mySrd).getId().toUnqualifiedVersionless(); - - String paramName = "value-quantity"; - List> theQuantityAndOrTerms = Lists.newArrayList(); - - theQuantityAndOrTerms.add(Collections.singletonList( - new QuantityParam().setValue(0.7))); - - theQuantityAndOrTerms.add(Lists.newArrayList( - new QuantityParam().setValue(0.5), - new QuantityParam().setValue(0.6) + public void searchWithMultipleAndMultipleOrClauseTestWithQuantity() { + IIdType myResourceId = myTestDataBuilder.createObservation(myTestDataBuilder.withElementAt("valueQuantity", + myTestDataBuilder.withPrimitiveAttribute("value", 0.6) )); - theQuantityAndOrTerms.add(Lists.newArrayList( - new QuantityParam().setValue(0.9), - new QuantityParam().setValue(0.6) - )); + String paramName = "value-quantity"; + List> theQuantityAndOrTerms = List.of( + List.of( + new QuantityParam().setValue(0.61)), + List.of( + new QuantityParam().setValue(0.5), + new QuantityParam().setValue(0.6) + ), + List.of( + new QuantityParam().setValue(0.9), + new QuantityParam().setValue(0.6) + ) + ); runInTransaction(() -> { SearchSession searchSession = Search.session(myEntityManager); SearchResult result = searchSession.search(ResourceTable.class) .where(f -> { + var b = f.bool(); + b.must(f.match().field("myResourceType").matching("Observation")); + + for (List orTerms : theQuantityAndOrTerms) { + b.must( getOrTermClauses(f, paramName, orTerms) ); + } + TestPredBuilder builder = new TestPredBuilder(f); - return builder.buildAndOrPredicates(paramName, theQuantityAndOrTerms); + b.must( builder.buildAndOrPredicates(paramName, theQuantityAndOrTerms) ); + return b; }) .fetchAll(); long totalHitCount = result.total().hitCount(); // List hits = result.hits(); + assertEquals(1, totalHitCount); }); } + @Test + public void searchWithMultipleAndMultipleOrClauseTestWithUri() { + String obsId1 = myTestDataBuilder.createObservation(List.of(myTestDataBuilder.withProfile("http://example.org/profile-1"))).getIdPart(); + + String paramName = "_profile"; + List> theQuantityAndOrTerms = List.of( + List.of( + new UriParam().setValue("http://example.org/profile-1")), + List.of( + new UriParam().setValue("http://example.org/profile-2"), + new UriParam().setValue("http://example.org/profile-1") + ), + List.of( + new UriParam().setValue("http://example.org/profile-3"), + new UriParam().setValue("http://example.org/profile-1") + ) + ); + + runInTransaction(() -> { + SearchSession searchSession = Search.session(myEntityManager); + SearchResult result = searchSession.search(ResourceTable.class) + .where(f -> f.bool(b -> { + b.must(f.match().field("myResourceType").matching("Observation")); + + addAndUriClauses(f, b, paramName, theQuantityAndOrTerms); + })) + .fetchAll(); + long totalHitCount = result.total().hitCount(); + assertEquals(Collections.singletonList(obsId1), result.hits().stream() + .map(r -> r.getIdType(getFhirContext()).getIdPart()).collect(Collectors.toList())); + }); + } + + + private void addAndUriClauses(SearchPredicateFactory f, BooleanPredicateClausesStep b, + String theParamName, List> theAndOrTerms) { + + String propertyPath = getPathForParamName(theParamName); + + for (List orTerms : theAndOrTerms) { + + if (orTerms.size() == 1) { + b.must(f.match().field(propertyPath).matching( ((UriParam) orTerms.get(0)).getValue() )); + continue; + } + + b.must( f.bool( b1 -> { + for (IQueryParameterType orTerm : orTerms) { + b1.should(f.match().field(propertyPath).matching( ((UriParam) orTerm).getValue() )); + b1.should(f.match().field(propertyPath).matching( ((UriParam) orTerm).getValue() )); + } + b1.minimumShouldMatchNumber(1); + })); + } + } + } + + + + + + + + + + + + + + + + private List getResultIds(IBundleProvider theResult) { + return theResult.getAllResources().stream().map(r -> r.getIdElement().getIdPart()).collect(Collectors.toList()); + } + + private PredicateFinalStep getOrTermClauses(SearchPredicateFactory theF, String theParamName, List theOrTerms) { + if (theOrTerms.size() == 1) { + return theF.match().field( getPathForParamName(theParamName) + ".system" ).matching( ((TokenParam) theOrTerms.get(0)).getSystem() ); + } + + // multiple or clauses + var boolStep = theF.bool(); + for (IQueryParameterType orTerm : theOrTerms) { + boolStep.should(theF.match().field( getPathForParamName(theParamName) + ".code" ).matching( ((TokenParam) orTerm).getValue() ) ); + } + return boolStep; } + private String getPathForParamName(String theParamName) { + switch (theParamName) { + case "code": return "sp.code.token"; + case "_profile": return "sp._profile.uri-value"; + } + + throw new InvalidParameterException("don't know how to handle paramName: " + theParamName); + } + - private static class TestPredBuilder { + + + private class TestPredBuilder { private static final double QTY_APPROX_TOLERANCE_PERCENT = .10; private static final double QTY_TOLERANCE_PERCENT = .05; @@ -399,44 +883,18 @@ public PredicateFinalStep buildAndOrPredicates( boolean isNested = isNested(theSearchParamName); - // we need to know if there is more than one "and" predicate (outer list) with more than one "or" predicate (inner list) - long maxOrPredicateSize = theAndOrTerms.stream().map(List::size).filter(s -> s > 1).count(); +// // we need to know if there is any "and" predicate (outer list) with more than one "or" predicate (inner list) +// long maxOrPredicateSize = theAndOrTerms.stream().map(List::size).filter(s -> s > 1).count(); BooleanPredicateClausesStep topBool = myPredicateFactory.bool(); - topBool.must(myPredicateFactory.match().field("myResourceType").matching("Observation")); - - BooleanPredicateClausesStep activeBool = topBool; - if (isNested) { - BooleanPredicateClausesStep nestedBool = myPredicateFactory.bool(); - activeBool = nestedBool; - } - - for (List andTerm : theAndOrTerms) { - if (andTerm.size() == 1) { - // buildSinglePredicate -// activeBool.must(myPredicateFactory.match().field("nsp.value-quantity.quantity.value").matching(0.7)); - addOnePredicate(activeBool, true, theSearchParamName, andTerm.get(0)); - continue; - } - - if (maxOrPredicateSize <= 1) { - // this is the only list of or predicates with more than 1 entry so - // no need to separate it in a group. Can be part of main and clauses - for (IQueryParameterType orTerm : andTerm) { - addOnePredicate(activeBool, false, theSearchParamName, orTerm); - } - activeBool.minimumShouldMatchNumber(1); - - } else { - // this is not the only list of or predicates with more than 1 entry - // so all of them need to be separated in groups with a minimumShouldMatchNumber(1) - activeBool.must(myPredicateFactory.bool(p -> { - for (IQueryParameterType orTerm : andTerm) { - addOnePredicate(p, false, theSearchParamName, orTerm); - } - p.minimumShouldMatchNumber(1); - })); - } + // need an extra bool level for nested properties + BooleanPredicateClausesStep activeBool = isNested ? topBool : myPredicateFactory.bool(); + + for (List orTerms : theAndOrTerms) { + // multiple or predicates must be in must group of should(s) with a minimumShouldMatchNumber(1) + activeBool.must(myPredicateFactory.bool(p -> { +// p.must( getOrPredicates(p, theSearchParamName, orTerms) ); + })); } if (isNested) { @@ -446,14 +904,32 @@ public PredicateFinalStep buildAndOrPredicates( } +// private PredicateFinalStep getOrPredicates(SearchPredicateFactory theF, String theParamName, List theOrTerms) { +// if (theOrTerms.size() == 1) { +// theF.should( getPredicate(theBool, theParamName, theOrTerms.get(0)) ); +// } else { +// theF.should(f -> f.match().field("").matching("")); +// } +// } + +// private PredicateFinalStep getPredicate(BooleanPredicateClausesStep theBool, String theParamName, IQueryParameterType theParameterType) { +// if (theParameterType instanceof QuantityParam) { +// addQuantityOrClauses(theBool, theParamName, theParameterType); +// return; +// } +// +// throw new IllegalStateException("Shouldn't reach this code"); +// } + + + + + + + + - private boolean isNested(String theSearchParamName) { - if (theSearchParamName.equals("value-quantity")) { - return true; - } - return false; - } private void addOnePredicate(BooleanPredicateClausesStep theTopBool, boolean theIsMust, @@ -574,10 +1050,10 @@ private PredicateFinalStep getPrefixedRangePredicate( // NOT_EQUAL: searches for resource quantity not between passed param value +/- 5% case NOT_EQUAL: return myPredicateFactory.bool(b -> { - b.should(myPredicateFactory.range() + b.must(myPredicateFactory.range() .field(valueFieldPath) .between(null, value - defaultTolerance)); - b.should(myPredicateFactory.range() + b.must(myPredicateFactory.range() .field(valueFieldPath) .between(value + defaultTolerance, null)); b.minimumShouldMatchNumber(1); @@ -588,6 +1064,44 @@ private PredicateFinalStep getPrefixedRangePredicate( } + private boolean isNested(String theParamName) { + switch (theParamName) { + case "_security": + case "value-quantity": + return true; + default: + return false; + } + } + + private String getParamPath(String theParamName) { + switch (theParamName) { + case "_security": return "nsp._security.token"; + default: + fail("Don't know the path name for param: " + theParamName); + } + fail("Don't know the path name for param: " + theParamName); + return null; + } + + + + private IIdType createRiskAssessmentWithPredictionProbability(Number theProbability) { + RiskAssessment ra1 = new RiskAssessment(); + if (theProbability != null) { + RiskAssessment.RiskAssessmentPredictionComponent component = ra1.addPrediction(); + component.setProbability(new DecimalType(theProbability.doubleValue())); + } + return myRiskAssessmentDao.create(ra1).getId().toUnqualifiedVersionless(); + } + + private void assertFindIds(String theMessage, Collection theResourceIds, String theUrl) { + List resourceIds = myTestDaoSearch.searchForIds(theUrl); + assertEquals(theResourceIds, new HashSet<>(resourceIds), theMessage); + } + + + @Override protected FhirContext getFhirContext() { @@ -598,4 +1112,7 @@ protected FhirContext getFhirContext() { protected PlatformTransactionManager getTxManager() { return myTxManager; } + + + } diff --git a/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/search/autocomplete/TokenAutocompleteElasticsearchIT.java b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/search/autocomplete/TokenAutocompleteElasticsearchIT.java index 426ef934ef0..c51b0810313 100644 --- a/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/search/autocomplete/TokenAutocompleteElasticsearchIT.java +++ b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/search/autocomplete/TokenAutocompleteElasticsearchIT.java @@ -186,7 +186,7 @@ public void testAutocompleteDistinguishesMultipleCodes() { List autocompleteSearch(String theResourceType, String theSPName, String theModifier, String theSearchText) { return new TransactionTemplate(myTxManager).execute(s -> { - TokenAutocompleteSearch tokenAutocompleteSearch = new TokenAutocompleteSearch(myFhirCtx, myModelConfig, Search.session(myEntityManager)); + TokenAutocompleteSearch tokenAutocompleteSearch = new TokenAutocompleteSearch(Search.session(myEntityManager)); return tokenAutocompleteSearch.search(theResourceType, theSPName, theSearchText, theModifier,30); }); }