Implement support for Quantity search parameters in the Hibernate Search index (#3477)

* markers for new quantity hibernate search support

* Add quantity search

* Add normalized (canonical) quantity search

* Add comment

* Use double values instead of BigDecimal to avoid scaling

* Remove multiple case labels which are not supported until source version 14

* Remove switch rules which are not supported until source version 14

* Use constant for error message used in tests

* A failing test for multiple indexed quantity paths

* Disallow dirtying context for nested classes

* failing test for normalized units

* Make sp.value-quantity.quantity a nested structure to handle correlated queries for code and value.
Save units for canonical case and consider them when querying.
Collapse normalized sub-structure under *.quantity.

* Nest sp.value-quantity instead of sp.value-quantity.quantity for compatibility with other nested types (IE. token)

* Comment out debug statements

* Update hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/ExtendedLuceneClauseBuilder.java

Co-authored-by: Olivia You <46392181+oliviayou@users.noreply.github.com>

* Add test for nested correlated quantity query

* Address MR comments

* Add proper handling of or query predicates

* Use positive conditions for better readability

* Add tests to validate behavior of combined and and or clauses, including some making no sense but still syntactically correct.

* Only consider normalized quantity searching if ModelConfig search level is NORMALIZED_QUANTITY_SEARCH_SUPPORTED

Co-authored-by: juan.marchionatto <juan.marchionatto@smilecdr.com>
Co-authored-by: jmarchionatto <60409882+jmarchionatto@users.noreply.github.com>
Co-authored-by: Olivia You <46392181+oliviayou@users.noreply.github.com>
This commit is contained in:
michaelabuckley 2022-04-08 10:25:27 -04:00 committed by GitHub
parent 01d6e15f90
commit 1a8678cb1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 952 additions and 42 deletions

View File

@ -25,7 +25,7 @@ import ca.uhn.fhir.rest.gclient.NumberClientParam.IMatches;
import ca.uhn.fhir.rest.param.ParamPrefixEnum;
/**
* Token parameter type for use in fluent client interfaces
* Quantity parameter type for use in fluent client interfaces
*/
@SuppressWarnings("deprecation")
public class QuantityClientParam extends BaseClientParam implements IParam {

View File

@ -29,7 +29,10 @@ public abstract class BaseParamWithPrefix<T extends BaseParam> extends BaseParam
private static final long serialVersionUID = 1L;
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(BaseParamWithPrefix.class);
public static final String MSG_PREFIX_INVALID_FORMAT = "Invalid date/time/quantity format: ";
private ParamPrefixEnum myPrefix;
/**
@ -58,7 +61,7 @@ public abstract class BaseParamWithPrefix<T extends BaseParam> extends BaseParam
}
if (offset > 0 && theString.length() == offset) {
throw new DataFormatException(Msg.code(1940) + "Invalid date/time format: \"" + theString + "\"");
throw new DataFormatException(Msg.code(1940) + MSG_PREFIX_INVALID_FORMAT + "\"" + theString + "\"");
}
String prefix = theString.substring(0, offset);

View File

@ -29,6 +29,7 @@ import ca.uhn.fhir.jpa.dao.search.ExtendedLuceneIndexExtractor;
import ca.uhn.fhir.jpa.dao.search.ExtendedLuceneResourceProjection;
import ca.uhn.fhir.jpa.dao.search.ExtendedLuceneSearchBuilder;
import ca.uhn.fhir.jpa.dao.search.LastNOperation;
import ca.uhn.fhir.jpa.model.entity.ModelConfig;
import ca.uhn.fhir.jpa.model.entity.ResourceTable;
import ca.uhn.fhir.jpa.model.search.ExtendedLuceneIndexData;
import ca.uhn.fhir.jpa.search.autocomplete.ValueSetAutocompleteOptions;
@ -81,6 +82,9 @@ public class FulltextSearchSvcImpl implements IFulltextSearchSvc {
@Autowired
IIdHelperService myIdHelperService;
@Autowired
ModelConfig myModelConfig;
final private ExtendedLuceneSearchBuilder myAdvancedIndexQueryBuilder = new ExtendedLuceneSearchBuilder();
private Boolean ourDisabled;
@ -95,7 +99,8 @@ public class FulltextSearchSvcImpl implements IFulltextSearchSvc {
public ExtendedLuceneIndexData extractLuceneIndexData(IBaseResource theResource, ResourceIndexedSearchParams theNewParams) {
String resourceType = myFhirContext.getResourceType(theResource);
ResourceSearchParams activeSearchParams = mySearchParamRegistry.getActiveSearchParams(resourceType);
ExtendedLuceneIndexExtractor extractor = new ExtendedLuceneIndexExtractor(myDaoConfig, myFhirContext, activeSearchParams, mySearchParamExtractor);
ExtendedLuceneIndexExtractor extractor = new ExtendedLuceneIndexExtractor(
myDaoConfig, myFhirContext, activeSearchParams, mySearchParamExtractor, myModelConfig);
return extractor.extract(theResource,theNewParams);
}
@ -126,7 +131,7 @@ public class FulltextSearchSvcImpl implements IFulltextSearchSvc {
)
.where(
f -> f.bool(b -> {
ExtendedLuceneClauseBuilder builder = new ExtendedLuceneClauseBuilder(myFhirContext, b, f);
ExtendedLuceneClauseBuilder builder = new ExtendedLuceneClauseBuilder(myFhirContext, myModelConfig, b, f);
/*
* Handle _content parameter (resource body content)
@ -226,7 +231,7 @@ public class FulltextSearchSvcImpl implements IFulltextSearchSvc {
public IBaseResource tokenAutocompleteValueSetSearch(ValueSetAutocompleteOptions theOptions) {
ensureElastic();
ValueSetAutocompleteSearch autocomplete = new ValueSetAutocompleteSearch(myFhirContext, getSearchSession());
ValueSetAutocompleteSearch autocomplete = new ValueSetAutocompleteSearch(myFhirContext, myModelConfig, getSearchSession());
return autocomplete.search(theOptions);
}
@ -253,7 +258,7 @@ public class FulltextSearchSvcImpl implements IFulltextSearchSvc {
@Override
public List<ResourcePersistentId> lastN(SearchParameterMap theParams, Integer theMaximumResults) {
ensureElastic();
List<Long> pidList = new LastNOperation(getSearchSession(), myFhirContext, mySearchParamRegistry)
List<Long> pidList = new LastNOperation(getSearchSession(), myFhirContext, myModelConfig, mySearchParamRegistry)
.executeLastN(theParams, theMaximumResults);
return convertLongsToResourcePersistentIds(pidList);
}

View File

@ -22,12 +22,16 @@ package ca.uhn.fhir.jpa.dao.search;
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.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.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;
@ -56,20 +60,33 @@ import java.util.stream.Collectors;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.IDX_STRING_EXACT;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.IDX_STRING_NORMALIZED;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.IDX_STRING_TEXT;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.NESTED_SEARCH_PARAM_ROOT;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.QTY_CODE;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.QTY_CODE_NORM;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.QTY_PARAM_NAME;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.QTY_SYSTEM;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.QTY_VALUE;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.QTY_VALUE_NORM;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.SEARCH_PARAM_ROOT;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
public class ExtendedLuceneClauseBuilder {
private static final Logger ourLog = LoggerFactory.getLogger(ExtendedLuceneClauseBuilder.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<TemporalPrecisionEnum> ordinalSearchPrecisions = Arrays.asList(TemporalPrecisionEnum.YEAR, TemporalPrecisionEnum.MONTH, TemporalPrecisionEnum.DAY);
public ExtendedLuceneClauseBuilder(FhirContext myFhirContext, BooleanPredicateClausesStep<?> myRootClause, SearchPredicateFactory myPredicateFactory) {
public ExtendedLuceneClauseBuilder(FhirContext myFhirContext, ModelConfig theModelConfig,
BooleanPredicateClausesStep<?> myRootClause, SearchPredicateFactory myPredicateFactory) {
this.myFhirContext = myFhirContext;
this.myModelConfig = theModelConfig;
this.myRootClause = myRootClause;
this.myPredicateFactory = myPredicateFactory;
}
@ -453,4 +470,137 @@ public class ExtendedLuceneClauseBuilder {
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<List<IQueryParameterType>> theQuantityAndOrTerms) {
for (List<IQueryParameterType> 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;
}
}
}

View File

@ -22,6 +22,7 @@ package ca.uhn.fhir.jpa.dao.search;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.RuntimeSearchParam;
import ca.uhn.fhir.jpa.model.entity.ModelConfig;
import ca.uhn.fhir.jpa.api.config.DaoConfig;
import ca.uhn.fhir.jpa.model.entity.ResourceLink;
import ca.uhn.fhir.jpa.model.search.ExtendedLuceneIndexData;
@ -54,17 +55,20 @@ public class ExtendedLuceneIndexExtractor {
private final FhirContext myContext;
private final ResourceSearchParams myParams;
private final ISearchParamExtractor mySearchParamExtractor;
private final ModelConfig myModelConfig;
public ExtendedLuceneIndexExtractor(DaoConfig theDaoConfig, FhirContext theContext, ResourceSearchParams theActiveParams, ISearchParamExtractor theSearchParamExtractor) {
public ExtendedLuceneIndexExtractor(DaoConfig theDaoConfig, FhirContext theContext, ResourceSearchParams theActiveParams,
ISearchParamExtractor theSearchParamExtractor, ModelConfig theModelConfig) {
myDaoConfig = theDaoConfig;
myContext = theContext;
myParams = theActiveParams;
mySearchParamExtractor = theSearchParamExtractor;
myModelConfig = theModelConfig;
}
@NotNull
public ExtendedLuceneIndexData extract(IBaseResource theResource, ResourceIndexedSearchParams theNewParams) {
ExtendedLuceneIndexData retVal = new ExtendedLuceneIndexData(myContext);
ExtendedLuceneIndexData retVal = new ExtendedLuceneIndexData(myContext, myModelConfig);
if(myDaoConfig.isStoreResourceInLuceneIndex()) {
retVal.setRawResourceData(myContext.newJsonParser().encodeResourceToString(theResource));
@ -84,6 +88,10 @@ public class ExtendedLuceneIndexExtractor {
retVal.addDateIndexData(nextParam.getParamName(), nextParam.getValueLow(), nextParam.getValueLowDateOrdinal(),
nextParam.getValueHigh(), nextParam.getValueHighDateOrdinal()));
theNewParams.myQuantityParams.forEach(nextParam ->
retVal.addQuantityIndexData(nextParam.getParamName(), nextParam.getUnits(), nextParam.getSystem(), nextParam.getValue().doubleValue()));
if (!theNewParams.myLinks.isEmpty()) {
// awkwardly, links are indexed by jsonpath, not by search param.

View File

@ -91,7 +91,8 @@ public class ExtendedLuceneSearchBuilder {
return false;
}
} else if (param instanceof QuantityParam) {
return false;
return modifier.equals(EMPTY_MODIFIER);
} else if (param instanceof ReferenceParam) {
//We cannot search by chain.
if (((ReferenceParam) param).getChain() != null) {
@ -152,6 +153,8 @@ public class ExtendedLuceneSearchBuilder {
break;
case QUANTITY:
List<List<IQueryParameterType>> quantityAndOrTerms = theParams.removeByNameUnmodified(nextParam);
builder.addQuantityUnmodifiedSearch(nextParam, quantityAndOrTerms);
break;
case REFERENCE:

View File

@ -21,6 +21,7 @@ package ca.uhn.fhir.jpa.dao.search;
*/
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.jpa.model.entity.ModelConfig;
import ca.uhn.fhir.jpa.model.entity.ResourceTable;
import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
import ca.uhn.fhir.jpa.searchparam.util.LastNParameterHelper;
@ -38,12 +39,15 @@ public class LastNOperation {
public static final String OBSERVATION_RES_TYPE = "Observation";
private final SearchSession mySession;
private final FhirContext myFhirContext;
private final ModelConfig myModelConfig;
private final ISearchParamRegistry mySearchParamRegistry;
private final ExtendedLuceneSearchBuilder myExtendedLuceneSearchBuilder = new ExtendedLuceneSearchBuilder();
public LastNOperation(SearchSession theSession, FhirContext theFhirContext, ISearchParamRegistry theSearchParamRegistry) {
public LastNOperation(SearchSession theSession, FhirContext theFhirContext, ModelConfig theModelConfig,
ISearchParamRegistry theSearchParamRegistry) {
mySession = theSession;
myFhirContext = theFhirContext;
myModelConfig = theModelConfig;
mySearchParamRegistry = theSearchParamRegistry;
}
@ -57,7 +61,7 @@ public class LastNOperation {
.where(f -> f.bool(b -> {
// Must match observation type
b.must(f.match().field("myResourceType").matching(OBSERVATION_RES_TYPE));
ExtendedLuceneClauseBuilder builder = new ExtendedLuceneClauseBuilder(myFhirContext, b, f);
ExtendedLuceneClauseBuilder builder = new ExtendedLuceneClauseBuilder(myFhirContext, myModelConfig, b, f);
myExtendedLuceneSearchBuilder.addAndConsumeAdvancedQueryClauses(builder, OBSERVATION_RES_TYPE, theParams.clone(), mySearchParamRegistry);
}))
.aggregation(observationsByCodeKey, f -> f.fromJson(lastNAggregation.toAggregation()))

View File

@ -22,6 +22,7 @@ package ca.uhn.fhir.jpa.search.autocomplete;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.jpa.dao.search.ExtendedLuceneClauseBuilder;
import ca.uhn.fhir.jpa.model.entity.ModelConfig;
import ca.uhn.fhir.jpa.model.entity.ResourceTable;
import com.google.gson.JsonObject;
import org.hibernate.search.backend.elasticsearch.ElasticsearchExtension;
@ -47,10 +48,12 @@ class TokenAutocompleteSearch {
private static final AggregationKey<JsonObject> AGGREGATION_KEY = AggregationKey.of("autocomplete");
private final FhirContext myFhirContext;
private final ModelConfig myModelConfig;
private final SearchSession mySession;
public TokenAutocompleteSearch(FhirContext theFhirContext, SearchSession theSession) {
public TokenAutocompleteSearch(FhirContext theFhirContext, ModelConfig theModelConfig, SearchSession theSession) {
myFhirContext = theFhirContext;
myModelConfig = theModelConfig;
mySession = theSession;
}
@ -71,7 +74,7 @@ class TokenAutocompleteSearch {
// compose the query json
SearchQueryOptionsStep<?, ?, SearchLoadingOptionsStep, ?, ?> query = mySession.search(ResourceTable.class)
.where(predFactory -> predFactory.bool(boolBuilder -> {
ExtendedLuceneClauseBuilder clauseBuilder = new ExtendedLuceneClauseBuilder(myFhirContext, boolBuilder, predFactory);
ExtendedLuceneClauseBuilder clauseBuilder = new ExtendedLuceneClauseBuilder(myFhirContext, myModelConfig, boolBuilder, predFactory);
// we apply resource-level predicates here, at the top level
if (isNotBlank(theResourceName)) {

View File

@ -21,6 +21,7 @@ package ca.uhn.fhir.jpa.search.autocomplete;
*/
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.jpa.model.entity.ModelConfig;
import ca.uhn.fhir.rest.param.TokenParam;
import org.hibernate.search.mapper.orm.session.SearchSession;
import org.hl7.fhir.instance.model.api.IBaseResource;
@ -34,12 +35,14 @@ import java.util.List;
*/
public class ValueSetAutocompleteSearch {
private final FhirContext myFhirContext;
private final ModelConfig myModelConfig;
private final TokenAutocompleteSearch myAutocompleteSearch;
static final int DEFAULT_SIZE = 30;
public ValueSetAutocompleteSearch(FhirContext theFhirContext, SearchSession theSession) {
public ValueSetAutocompleteSearch(FhirContext theFhirContext, ModelConfig theModelConfig, SearchSession theSession) {
myFhirContext = theFhirContext;
myAutocompleteSearch = new TokenAutocompleteSearch(myFhirContext, theSession);
myModelConfig = theModelConfig;
myAutocompleteSearch = new TokenAutocompleteSearch(myFhirContext, myModelConfig, theSession);
}
public IBaseResource search(ValueSetAutocompleteOptions theOptions) {

View File

@ -5,6 +5,7 @@ import ca.uhn.fhir.jpa.dao.BaseHapiFhirDao;
import ca.uhn.fhir.jpa.search.autocomplete.ValueSetAutocompleteOptions;
import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.param.QuantityParam;
import ca.uhn.fhir.rest.param.StringAndListParam;
import ca.uhn.fhir.rest.param.StringOrListParam;
import ca.uhn.fhir.rest.param.StringParam;
@ -30,6 +31,7 @@ import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.startsWith;
import static org.hl7.fhir.r4.model.Observation.SP_VALUE_QUANTITY;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.Mockito.mock;
@ -125,8 +127,8 @@ public class FhirResourceDaoR4SearchFtTest extends BaseJpaR4Test {
IIdType id1 = myObservationDao.create(obs1, mockSrd()).getId().toUnqualifiedVersionless();
Observation obs2 = new Observation();
obs1.getCode().setText("AAAAA");
obs1.setValue(new StringType("Diastolic Blood Pressure"));
obs2.getCode().setText("AAAAA");
obs2.setValue(new StringType("Diastolic Blood Pressure"));
obs2.setStatus(ObservationStatus.FINAL);
IIdType id2 = myObservationDao.create(obs2, mockSrd()).getId().toUnqualifiedVersionless();
@ -483,4 +485,41 @@ public class FhirResourceDaoR4SearchFtTest extends BaseJpaR4Test {
}
}
@Test
public void testResourceQuantitySearch() {
Observation obs1 = new Observation();
obs1.getCode().setText("Systolic Blood Pressure");
obs1.setStatus(ObservationStatus.FINAL);
obs1.setValue(new Quantity(123));
obs1.getNoteFirstRep().setText("obs1");
IIdType id1 = myObservationDao.create(obs1, mySrd).getId().toUnqualifiedVersionless();
Observation obs2 = new Observation();
obs2.getCode().setText("Diastolic Blood Pressure");
obs2.setStatus(ObservationStatus.FINAL);
obs2.setValue(new Quantity(81));
IIdType id2 = myObservationDao.create(obs2, mySrd).getId().toUnqualifiedVersionless();
SearchParameterMap map;
map = new SearchParameterMap();
map.add(SP_VALUE_QUANTITY, new QuantityParam("ap122"));
assertThat(toUnqualifiedVersionlessIdValues(myObservationDao.search(map)), contains(toValues(id1)));
map = new SearchParameterMap();
map.add(SP_VALUE_QUANTITY, new QuantityParam("le90"));
assertThat(toUnqualifiedVersionlessIdValues(myObservationDao.search(map)), contains(toValues(id2)));
map = new SearchParameterMap();
map.add(SP_VALUE_QUANTITY, new QuantityParam("gt80"));
assertThat(toUnqualifiedVersionlessIdValues(myObservationDao.search(map)), containsInAnyOrder(toValues(id1, id2)));
map = new SearchParameterMap();
map.add(SP_VALUE_QUANTITY, new QuantityParam("gt80"));
map.add(SP_VALUE_QUANTITY, new QuantityParam("lt90"));
assertThat(toUnqualifiedVersionlessIdValues(myObservationDao.search(map)), contains(toValues(id2)));
}
}

View File

@ -21,6 +21,7 @@ import ca.uhn.fhir.jpa.entity.TermCodeSystemVersion;
import ca.uhn.fhir.jpa.entity.TermConcept;
import ca.uhn.fhir.jpa.entity.TermConceptParentChildLink;
import ca.uhn.fhir.jpa.model.entity.ModelConfig;
import ca.uhn.fhir.jpa.model.entity.NormalizedQuantitySearchLevel;
import ca.uhn.fhir.jpa.model.entity.ResourceTable;
import ca.uhn.fhir.jpa.partition.SystemRequestDetails;
import ca.uhn.fhir.jpa.search.reindex.IResourceReindexingSvc;
@ -28,6 +29,7 @@ import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
import ca.uhn.fhir.jpa.sp.ISearchParamPresenceSvc;
import ca.uhn.fhir.jpa.term.api.ITermCodeSystemStorageSvc;
import ca.uhn.fhir.jpa.term.api.ITermReadSvcR4;
import ca.uhn.fhir.parser.DataFormatException;
import ca.uhn.fhir.parser.IParser;
import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.server.IBundleProvider;
@ -39,6 +41,7 @@ import ca.uhn.fhir.rest.param.TokenParam;
import ca.uhn.fhir.rest.param.TokenParamModifier;
import ca.uhn.fhir.rest.server.util.ISearchParamRegistry;
import ca.uhn.fhir.test.utilities.ITestDataBuilder;
import ca.uhn.fhir.test.utilities.LogbackLevelOverrideExtension;
import ca.uhn.fhir.test.utilities.docker.RequiresDocker;
import ca.uhn.fhir.validation.FhirValidator;
import ca.uhn.fhir.validation.ValidationResult;
@ -67,11 +70,16 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestContext;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
import org.springframework.test.context.support.DirtiesContextTestExecutionListener;
import org.springframework.transaction.PlatformTransactionManager;
import javax.persistence.EntityManager;
@ -80,14 +88,18 @@ import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import static ca.uhn.fhir.jpa.model.util.UcumServiceUtil.UCUM_CODESYSTEM_URL;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.stringContainsInOrder;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@ExtendWith(SpringExtension.class)
@RequiresDocker
@ -98,6 +110,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
TestDaoSearch.Config.class
})
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@TestExecutionListeners(listeners = {
DependencyInjectionTestExecutionListener.class
,FhirResourceDaoR4SearchWithElasticSearchIT.TestDirtiesContextTestExecutionListener.class
})
public class FhirResourceDaoR4SearchWithElasticSearchIT extends BaseJpaTest {
public static final String URL_MY_CODE_SYSTEM = "http://example.com/my_code_system";
public static final String URL_MY_VALUE_SET = "http://example.com/my_value_set";
@ -156,6 +172,8 @@ public class FhirResourceDaoR4SearchWithElasticSearchIT extends BaseJpaTest {
@Autowired
@Qualifier("myQuestionnaireResponseDaoR4")
private IFhirResourceDao<QuestionnaireResponse> myQuestionnaireResponseDao;
@RegisterExtension
LogbackLevelOverrideExtension myLogbackLevelOverrideExtension = new LogbackLevelOverrideExtension();
@BeforeEach
@ -702,7 +720,7 @@ public class FhirResourceDaoR4SearchWithElasticSearchIT extends BaseJpaTest {
.getExpansion()
.getContains()
.stream()
.map(t -> t.getCode())
.map(ValueSet.ValueSetExpansionContainsComponent::getCode)
.sorted()
.collect(Collectors.toList());
assertThat(codes.toString(), codes, Matchers.contains("advice", "message", "note", "notification"));
@ -727,7 +745,7 @@ public class FhirResourceDaoR4SearchWithElasticSearchIT extends BaseJpaTest {
.getExpansion()
.getContains()
.stream()
.map(t -> t.getCode())
.map(ValueSet.ValueSetExpansionContainsComponent::getCode)
.sorted()
.collect(Collectors.toList());
assertThat(codes.toString(), codes, Matchers.contains("advice", "note"));
@ -942,4 +960,475 @@ public class FhirResourceDaoR4SearchWithElasticSearchIT extends BaseJpaTest {
}
@Nested
public class QuantityAndNormalizedQuantitySearch {
private IIdType myResourceId;
@Nested
public class QuantitySearch {
@Nested
public class SimpleQueries {
@Test
public void noQuantityThrows() {
String invalidQtyParam = "|http://another.org";
DataFormatException thrown = assertThrows(DataFormatException.class,
() -> myTestDaoSearch.searchForIds("/Observation?value-quantity=" + invalidQtyParam));
assertTrue(thrown.getMessage().startsWith("HAPI-1940: Invalid"));
assertTrue(thrown.getMessage().contains(invalidQtyParam));
}
@Test
public void invalidPrefixThrows() {
DataFormatException thrown = assertThrows(DataFormatException.class,
() -> myTestDaoSearch.searchForIds("/Observation?value-quantity=st5.35"));
assertEquals("HAPI-1941: Invalid prefix: \"st\"", thrown.getMessage());
}
@Test
public void eq() {
withObservationWithValueQuantity(0.6);
assertNotFind("when lt unitless", "/Observation?value-quantity=0.5");
assertNotFind("when wrong system", "/Observation?value-quantity=0.6|http://another.org");
assertNotFind("when wrong units", "/Observation?value-quantity=0.6||mmHg");
assertNotFind("when gt unitless", "/Observation?value-quantity=0.7");
assertNotFind("when gt", "/Observation?value-quantity=0.7||mmHg");
assertFind("when a little gt - default is approx", "/Observation?value-quantity=0.599");
assertFind("when a little lt - default is approx", "/Observation?value-quantity=0.601");
assertFind("when eq unitless", "/Observation?value-quantity=0.6");
assertFind("when eq with units", "/Observation?value-quantity=0.6||mm[Hg]");
}
@Test
public void ne() {
withObservationWithValueQuantity(0.6);
assertFind("when gt", "/Observation?value-quantity=ne0.5");
assertNotFind("when eq", "/Observation?value-quantity=ne0.6");
assertFind("when lt", "/Observation?value-quantity=ne0.7");
}
@Test
public void ap() {
withObservationWithValueQuantity(0.6);
assertNotFind("when gt", "/Observation?value-quantity=ap0.5");
assertFind("when a little gt", "/Observation?value-quantity=ap0.58");
assertFind("when eq", "/Observation?value-quantity=ap0.6");
assertFind("when a little lt", "/Observation?value-quantity=ap0.62");
assertNotFind("when lt", "/Observation?value-quantity=ap0.7");
}
@Test
public void gt() {
withObservationWithValueQuantity(0.6);
assertFind("when gt", "/Observation?value-quantity=gt0.5");
assertNotFind("when eq", "/Observation?value-quantity=gt0.6");
assertNotFind("when lt", "/Observation?value-quantity=gt0.7");
}
@Test
public void ge() {
withObservationWithValueQuantity(0.6);
assertFind("when gt", "/Observation?value-quantity=ge0.5");
assertFind("when eq", "/Observation?value-quantity=ge0.6");
assertNotFind("when lt", "/Observation?value-quantity=ge0.7");
}
@Test
public void lt() {
withObservationWithValueQuantity(0.6);
assertNotFind("when gt", "/Observation?value-quantity=lt0.5");
assertNotFind("when eq", "/Observation?value-quantity=lt0.6");
assertFind("when lt", "/Observation?value-quantity=lt0.7");
}
@Test
public void le() {
withObservationWithValueQuantity(0.6);
assertNotFind("when gt", "/Observation?value-quantity=le0.5");
assertFind("when eq", "/Observation?value-quantity=le0.6");
assertFind("when lt", "/Observation?value-quantity=le0.7");
}
}
@Nested
public class CombinedQueries {
@Test
void gtAndLt() {
withObservationWithValueQuantity(0.6);
assertFind("when gt0.5 and lt0.7", "/Observation?value-quantity=gt0.5&value-quantity=lt0.7");
assertNotFind("when gt0.5 and lt0.6", "/Observation?value-quantity=gt0.5&value-quantity=lt0.6");
assertNotFind("when gt6.5 and lt0.7", "/Observation?value-quantity=gt6.5&value-quantity=lt0.7");
assertNotFind("impossible matching", "/Observation?value-quantity=gt0.7&value-quantity=lt0.5");
}
@Test
void orClauses() {
withObservationWithValueQuantity(0.6);
assertFind("when gt0.5 and lt0.7", "/Observation?value-quantity=0.5,0.6");
// make sure it doesn't find everything when using or clauses
assertNotFind("when gt0.5 and lt0.7", "/Observation?value-quantity=0.5,0.7");
}
@Nested
public class CombinedAndPlusOr {
@Test
void ltAndOrClauses() {
withObservationWithValueQuantity(0.6);
assertFind("when lt0.7 and eq (0.5 or 0.6)", "/Observation?value-quantity=lt0.7&value-quantity=0.5,0.6");
// make sure it doesn't find everything when using or clauses
assertNotFind("when lt0.4 and eq (0.5 or 0.6)", "/Observation?value-quantity=lt0.4&value-quantity=0.5,0.6");
assertNotFind("when lt0.7 and eq (0.4 or 0.5)", "/Observation?value-quantity=lt0.7&value-quantity=0.4,0.5");
}
@Test
void gtAndOrClauses() {
withObservationWithValueQuantity(0.6);
assertFind("when gt0.4 and eq (0.5 or 0.6)", "/Observation?value-quantity=gt0.4&value-quantity=0.5,0.6");
assertNotFind("when gt0.7 and eq (0.5 or 0.7)", "/Observation?value-quantity=gt0.7&value-quantity=0.5,0.7");
assertNotFind("when gt0.3 and eq (0.4 or 0.5)", "/Observation?value-quantity=gt0.3&value-quantity=0.4,0.5");
}
}
@Nested
public class QualifiedOrClauses {
@Test
void gtOrLt() {
withObservationWithValueQuantity(0.6);
assertFind("when gt0.5 or lt0.3", "/Observation?value-quantity=gt0.5,lt0.3");
assertNotFind("when gt0.6 or lt0.55", "/Observation?value-quantity=gt0.6,lt0.55");
}
@Test
void gtOrLe() {
withObservationWithValueQuantity(0.6);
assertFind("when gt0.5 or le0.3", "/Observation?value-quantity=gt0.5,le0.3");
assertNotFind("when gt0.6 or le0.55", "/Observation?value-quantity=gt0.6,le0.55");
}
@Test
void ltOrGt() {
withObservationWithValueQuantity(0.6);
assertFind("when lt0.7 or gt0.9", "/Observation?value-quantity=lt0.7,gt0.9");
// make sure it doesn't find everything when using or clauses
assertNotFind("when lt0.6 or gt0.6", "/Observation?value-quantity=lt0.6,gt0.6");
assertNotFind("when lt0.3 or gt0.9", "/Observation?value-quantity=lt0.3,gt0.9");
}
@Test
void ltOrGe() {
withObservationWithValueQuantity(0.6);
assertFind("when lt0.7 or ge0.2", "/Observation?value-quantity=lt0.7,ge0.2");
assertNotFind("when lt0.6 or ge0.8", "/Observation?value-quantity=lt0.6,ge0.8");
}
@Test
void gtOrGt() {
withObservationWithValueQuantity(0.6);
assertFind("when gt0.5 or gt0.8", "/Observation?value-quantity=gt0.5,gt0.8");
assertNotFind("when gt0.6 or gt0.8", "/Observation?value-quantity=gt0.6,gt0.8");
}
@Test
void geOrGe() {
withObservationWithValueQuantity(0.6);
assertFind("when ge0.5 or ge0.7", "/Observation?value-quantity=ge0.5,ge0.7");
assertNotFind("when ge0.65 or ge0.7", "/Observation?value-quantity=ge0.65,ge0.7");
}
@Test
void ltOrLt() {
withObservationWithValueQuantity(0.6);
assertFind("when lt0.5 or lt0.7", "/Observation?value-quantity=lt0.5,lt0.7");
assertNotFind("when lt0.55 or lt0.3", "/Observation?value-quantity=lt0.55,lt0.3");
}
@Test
void leOrLe() {
withObservationWithValueQuantity(0.6);
assertFind("when le0.5 or le0.6", "/Observation?value-quantity=le0.5,le0.6");
assertNotFind("when le0.5 or le0.59", "/Observation?value-quantity=le0.5,le0.59");
}
}
}
@Nested
public class Sorting {
@Test
public void sortByNumeric() {
String idAlpha7 = withObservationWithValueQuantity(0.7).getIdPart();
String idAlpha2 = withObservationWithValueQuantity(0.2).getIdPart();
String idAlpha5 = withObservationWithValueQuantity(0.5).getIdPart();
List<String> allIds = myTestDaoSearch.searchForIds("/Observation?_sort=value-quantity");
assertThat(allIds, contains(idAlpha2, idAlpha5, idAlpha7));
}
}
}
@Nested
public class QuantityNormalizedSearch {
@BeforeEach
void setUp() {
myDaoConfig.getModelConfig().setNormalizedQuantitySearchLevel(
NormalizedQuantitySearchLevel.NORMALIZED_QUANTITY_SEARCH_SUPPORTED);
}
@Nested
public class SimpleQueries {
@Test
public void ne() {
withObservationWithQuantity(0.06, UCUM_CODESYSTEM_URL, "10*6/L" );
assertFind("when lt UCUM", "/Observation?value-quantity=ne70|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertFind("when gt UCUM", "/Observation?value-quantity=ne50|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertNotFind("when eq UCUM", "/Observation?value-quantity=ne60|" + UCUM_CODESYSTEM_URL + "|10*3/L");
}
@Test
public void eq() {
withObservationWithQuantity(0.06, UCUM_CODESYSTEM_URL, "10*6/L" );
assertFind("when eq UCUM 10*3/L ", "/Observation?value-quantity=60|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertFind("when eq UCUM 10*9/L", "/Observation?value-quantity=0.000060|" + UCUM_CODESYSTEM_URL + "|10*9/L");
assertFind("when gt UCUM 10*3/L", "/Observation?value-quantity=eq58|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertFind("when le UCUM 10*3/L", "/Observation?value-quantity=eq63|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertNotFind("when ne UCUM 10*3/L", "/Observation?value-quantity=80|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertNotFind("when gt UCUM 10*3/L", "/Observation?value-quantity=50|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertNotFind("when lt UCUM 10*3/L", "/Observation?value-quantity=70|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertFind("Units required to match and do", "/Observation?value-quantity=60000|" + UCUM_CODESYSTEM_URL + "|/L");
// request generates a quantity which value matches the "value-norm", but not the "code-norm"
assertNotFind("Units required to match and don't", "/Observation?value-quantity=6000000000|" + UCUM_CODESYSTEM_URL + "|cm");
}
@Test
public void ap() {
withObservationWithQuantity(0.06, UCUM_CODESYSTEM_URL, "10*6/L" );
assertNotFind("when gt UCUM", "/Observation?value-quantity=ap50|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertFind("when little gt UCUM", "/Observation?value-quantity=ap58|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertFind("when eq UCUM", "/Observation?value-quantity=ap60|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertFind("when a little lt UCUM", "/Observation?value-quantity=ap63|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertNotFind("when lt UCUM", "/Observation?value-quantity=ap71|" + UCUM_CODESYSTEM_URL + "|10*3/L");
}
@Test
public void gt() {
withObservationWithQuantity(0.06, UCUM_CODESYSTEM_URL, "10*6/L" );
assertFind("when gt UCUM", "/Observation?value-quantity=gt50|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertNotFind("when eq UCUM", "/Observation?value-quantity=gt60|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertNotFind("when lt UCUM", "/Observation?value-quantity=gt71|" + UCUM_CODESYSTEM_URL + "|10*3/L");
}
@Test
public void ge() {
withObservationWithQuantity(0.06, UCUM_CODESYSTEM_URL, "10*6/L" );
assertFind("when gt UCUM", "/Observation?value-quantity=ge50|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertFind("when eq UCUM", "/Observation?value-quantity=ge60|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertNotFind("when lt UCUM", "/Observation?value-quantity=ge62|" + UCUM_CODESYSTEM_URL + "|10*3/L");
}
@Test
public void lt() {
withObservationWithQuantity(0.06, UCUM_CODESYSTEM_URL, "10*6/L" );
assertNotFind("when gt", "/Observation?value-quantity=lt50|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertNotFind("when eq", "/Observation?value-quantity=lt60|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertFind("when lt", "/Observation?value-quantity=lt70|" + UCUM_CODESYSTEM_URL + "|10*3/L");
}
@Test
public void le() {
withObservationWithQuantity(0.06, UCUM_CODESYSTEM_URL, "10*6/L" );
assertNotFind("when gt", "/Observation?value-quantity=le50|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertFind("when eq", "/Observation?value-quantity=le60|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertFind("when lt", "/Observation?value-quantity=le70|" + UCUM_CODESYSTEM_URL + "|10*3/L");
}
/**
* "value-quantity" data is stored in a nested object, so if not queried properly
* it could return false positives. For instance: two Observations for following
* combinations of code and value:
* Obs 1 code AAA1 value: 123
* Obs 2 code BBB2 value: 456
* A search for code: AAA1 and value: 456 would bring both observations instead of the expected empty reply,
* unless both predicates are enclosed in a "nested"
* */
@Test
void nestedMustCorrelate() {
withObservationWithQuantity(0.06, UCUM_CODESYSTEM_URL, "10*6/L" );
withObservationWithQuantity(0.02, UCUM_CODESYSTEM_URL, "10*3/L" );
assertNotFind("when one predicate matches each object", "/Observation" +
"?value-quantity=0.06|" + UCUM_CODESYSTEM_URL + "|10*3/L");
}
}
@Nested
public class CombinedQueries {
@Test
void gtAndLt() {
withObservationWithQuantity(0.06, UCUM_CODESYSTEM_URL, "10*6/L" );
assertFind("when gt 50 and lt 70", "/Observation" +
"?value-quantity=gt50|" + UCUM_CODESYSTEM_URL + "|10*3/L" +
"&value-quantity=lt70|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertNotFind("when gt50 and lt60", "/Observation" +
"?value-quantity=gt50|" + UCUM_CODESYSTEM_URL + "|10*3/L" +
"&value-quantity=lt60|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertNotFind("when gt65 and lt70", "/Observation" +
"?value-quantity=gt65|" + UCUM_CODESYSTEM_URL + "|10*3/L" +
"&value-quantity=lt70|" + UCUM_CODESYSTEM_URL + "|10*3/L");
assertNotFind("when gt 70 and lt 50", "/Observation" +
"?value-quantity=gt70|" + UCUM_CODESYSTEM_URL + "|10*3/L" +
"&value-quantity=lt50|" + UCUM_CODESYSTEM_URL + "|10*3/L");
}
@Test
void gtAndLtWithMixedUnits() {
withObservationWithQuantity(0.06, UCUM_CODESYSTEM_URL, "10*6/L" );
assertFind("when gt 50|10*3/L and lt 70|10*9/L", "/Observation" +
"?value-quantity=gt50|" + UCUM_CODESYSTEM_URL + "|10*3/L" +
"&value-quantity=lt0.000070|" + UCUM_CODESYSTEM_URL + "|10*9/L");
}
@Test
public void multipleSearchParamsAreSeparate() {
// for debugging
// myLogbackLevelOverrideExtension.setLogLevel(DaoTestDataBuilder.class, Level.DEBUG);
// this configuration must generate a combo-value-quantity entry with both quantity objects
myResourceId = myTestDataBuilder.createObservation(
myTestDataBuilder.withQuantityAtPath("valueQuantity", 0.02, UCUM_CODESYSTEM_URL, "10*6/L"),
myTestDataBuilder.withQuantityAtPath("component.valueQuantity", 0.06, UCUM_CODESYSTEM_URL, "10*6/L")
);
// myLogbackLevelOverrideExtension.resetLevel(DaoTestDataBuilder.class);
assertFind("by value", "Observation?value-quantity=0.02|" + UCUM_CODESYSTEM_URL + "|10*6/L");
assertFind("by component value", "Observation?component-value-quantity=0.06|" + UCUM_CODESYSTEM_URL + "|10*6/L");
assertNotFind("by value", "Observation?value-quantity=0.06|" + UCUM_CODESYSTEM_URL + "|10*6/L");
assertNotFind("by component value", "Observation?component-value-quantity=0.02|" + UCUM_CODESYSTEM_URL + "|10*6/L");
}
}
/**
* Sorting is not implemented for normalized quantities, so quantities will be sorted
* by their absolute values (with no unit conversions)
*/
@Nested
public class Sorting {
@Test
public void sortByNumeric() {
String idAlpha6 = withObservationWithQuantity(0.06, UCUM_CODESYSTEM_URL, "10*6/L" ).getIdPart();
String idAlpha5 = withObservationWithQuantity(50, UCUM_CODESYSTEM_URL, "10*3/L" ).getIdPart();
String idAlpha7 = withObservationWithQuantity(0.000070, UCUM_CODESYSTEM_URL, "10*9/L" ).getIdPart();
// this search is not freetext because there is no freetext-known parameter name
List<String> allIds = myTestDaoSearch.searchForIds("/Observation?_sort=value-quantity");
assertThat(allIds, contains(idAlpha7, idAlpha6, idAlpha5));
}
}
}
private void assertFind(String theMessage, String theUrl) {
List<String> resourceIds = myTestDaoSearch.searchForIds(theUrl);
assertThat(theMessage, resourceIds, hasItem(equalTo(myResourceId.getIdPart())));
}
private void assertNotFind(String theMessage, String theUrl) {
List<String> resourceIds = myTestDaoSearch.searchForIds(theUrl);
assertThat(theMessage, resourceIds, not(hasItem(equalTo(myResourceId.getIdPart()))));
}
private IIdType withObservationWithQuantity(double theValue, String theSystem, String theCode) {
myResourceId = myTestDataBuilder.createObservation(
myTestDataBuilder.withQuantityAtPath("valueQuantity", theValue, theSystem, theCode)
);
return myResourceId;
}
private IIdType withObservationWithValueQuantity(double theValue) {
myResourceId = myTestDataBuilder.createObservation(myTestDataBuilder.withElementAt("valueQuantity",
myTestDataBuilder.withPrimitiveAttribute("value", theValue),
myTestDataBuilder.withPrimitiveAttribute("system", UCUM_CODESYSTEM_URL),
myTestDataBuilder.withPrimitiveAttribute("code", "mm[Hg]")
));
return myResourceId;
}
}
/**
* Disallow context dirtying for nested classes
*/
public static final class TestDirtiesContextTestExecutionListener extends DirtiesContextTestExecutionListener {
@Override
protected void beforeOrAfterTestClass(TestContext testContext, DirtiesContext.ClassMode requiredClassMode) throws Exception {
if ( ! testContext.getTestClass().getName().contains("$")) {
super.beforeOrAfterTestClass(testContext, requiredClassMode);
}
}
}
}

View File

@ -386,7 +386,7 @@ public class FhirResourceDaoR4StandardQueriesNoFTTest extends BaseJpaTest {
// myDataBuilder.withPrimitiveAttribute("value", theValue),
// myDataBuilder.withPrimitiveAttribute("unit", "mmHg"),
// myDataBuilder.withPrimitiveAttribute("system", "http://unitsofmeasure.org"));
myResourceId = myDataBuilder.createObservation(myDataBuilder.withAttribute("valueQuantity",
myResourceId = myDataBuilder.createObservation(myDataBuilder.withElementAt("valueQuantity",
myDataBuilder.withPrimitiveAttribute("value", theValue),
myDataBuilder.withPrimitiveAttribute("unit", "mmHg"),
myDataBuilder.withPrimitiveAttribute("system", "http://unitsofmeasure.org"),

View File

@ -164,6 +164,7 @@ import java.util.stream.Collectors;
import static ca.uhn.fhir.jpa.config.r4.FhirContextR4Config.DEFAULT_PRESERVE_VERSION_REFS;
import static ca.uhn.fhir.jpa.util.TestUtil.sleepOneClick;
import static ca.uhn.fhir.rest.param.BaseParamWithPrefix.MSG_PREFIX_INVALID_FORMAT;
import static ca.uhn.fhir.util.TestUtil.sleepAtLeast;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import static org.hamcrest.MatcherAssert.assertThat;
@ -366,21 +367,21 @@ public class ResourceProviderR4Test extends BaseResourceProviderR4Test {
HttpGet get = new HttpGet(ourServerBase + "/Condition?onset-date=junk");
try (CloseableHttpResponse resp = ourHttpClient.execute(get)) {
String output = IOUtils.toString(resp.getEntity().getContent(), Charsets.UTF_8);
assertThat(output, containsString("Invalid date/time format: &quot;junk&quot;"));
assertThat(output, containsString(MSG_PREFIX_INVALID_FORMAT + "&quot;junk&quot;"));
assertEquals(400, resp.getStatusLine().getStatusCode());
}
get = new HttpGet(ourServerBase + "/Condition?onset-date=ge");
try (CloseableHttpResponse resp = ourHttpClient.execute(get)) {
String output = IOUtils.toString(resp.getEntity().getContent(), Charsets.UTF_8);
assertThat(output, containsString("Invalid date/time format: &quot;ge&quot;"));
assertThat(output, containsString(MSG_PREFIX_INVALID_FORMAT + "&quot;ge&quot;"));
assertEquals(400, resp.getStatusLine().getStatusCode());
}
get = new HttpGet(ourServerBase + "/Condition?onset-date=" + UrlUtil.escapeUrlParam(">"));
try (CloseableHttpResponse resp = ourHttpClient.execute(get)) {
String output = IOUtils.toString(resp.getEntity().getContent(), Charsets.UTF_8);
assertThat(output, containsString("Invalid date/time format: &quot;&gt;&quot;"));
assertThat(output, containsString(MSG_PREFIX_INVALID_FORMAT + "&quot;&gt;&quot;"));
assertEquals(400, resp.getStatusLine().getStatusCode());
}

View File

@ -10,6 +10,7 @@ import ca.uhn.fhir.jpa.config.TestHibernateSearchAddInConfig;
import ca.uhn.fhir.jpa.config.TestR4Config;
import ca.uhn.fhir.jpa.dao.BaseJpaTest;
import ca.uhn.fhir.jpa.dao.DaoTestDataBuilder;
import ca.uhn.fhir.jpa.model.entity.ModelConfig;
import ca.uhn.fhir.jpa.search.reindex.IResourceReindexingSvc;
import ca.uhn.fhir.jpa.sp.ISearchParamPresenceSvc;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
@ -81,6 +82,9 @@ public class TokenAutocompleteElasticsearchIT extends BaseJpaTest{
@Autowired
ITestDataBuilder myDataBuilder;
@Autowired
private ModelConfig myModelConfig;
// a few different codes
static final Coding mean_blood_pressure = new Coding("http://loinc.org", "8478-0", "Mean blood pressure");
static final Coding gram_positive_culture = new Coding("http://loinc.org", "88262-1", "Gram positive blood culture panel by Probe in Positive blood culture");
@ -180,7 +184,7 @@ public class TokenAutocompleteElasticsearchIT extends BaseJpaTest{
List<TokenAutocompleteHit> autocompleteSearch(String theResourceType, String theSPName, String theModifier, String theSearchText) {
return new TransactionTemplate(myTxManager).execute(s -> {
TokenAutocompleteSearch tokenAutocompleteSearch = new TokenAutocompleteSearch(myFhirCtx, Search.session(myEntityManager));
TokenAutocompleteSearch tokenAutocompleteSearch = new TokenAutocompleteSearch(myFhirCtx, myModelConfig, Search.session(myEntityManager));
return tokenAutocompleteSearch.search(theResourceType, theSPName, theSearchText, theModifier,30);
});
}

View File

@ -1,6 +1,7 @@
package ca.uhn.fhir.jpa.search.autocomplete;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.jpa.model.entity.ModelConfig;
import org.hl7.fhir.r4.model.ValueSet;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@ -13,7 +14,8 @@ import static org.hamcrest.Matchers.nullValue;
class ValueSetAutocompleteSearchTest {
FhirContext myFhirContext = FhirContext.forR4();
ValueSetAutocompleteSearch myValueSetAutocompleteSearch = new ValueSetAutocompleteSearch(myFhirContext, null);
private ModelConfig myModelConfig;
ValueSetAutocompleteSearch myValueSetAutocompleteSearch = new ValueSetAutocompleteSearch(myFhirContext, myModelConfig, null);
@Nested
public class HitToValueSetConversion {

View File

@ -45,7 +45,7 @@ public enum NormalizedQuantitySearchLevel {
* and {@link ResourceIndexedSearchParamQuantityNormalized},
* {@link ResourceIndexedSearchParamQuantityNormalized} is used by searching.
*/
NORMALIZED_QUANTITY_SEARCH_SUPPORTED,
NORMALIZED_QUANTITY_SEARCH_SUPPORTED;
/**
* Quantity is stored in only in {@link ResourceIndexedSearchParamQuantityNormalized},
@ -55,4 +55,11 @@ public enum NormalizedQuantitySearchLevel {
*/
// When this is enabled, we can enable testSortByQuantityWithNormalizedQuantitySearchFullSupported()
//NORMALIZED_QUANTITY_SEARCH_FULL_SUPPORTED,
public boolean storageOrSearchSupported() {
return this.equals(NormalizedQuantitySearchLevel.NORMALIZED_QUANTITY_STORAGE_SUPPORTED)
|| this.equals(NormalizedQuantitySearchLevel.NORMALIZED_QUANTITY_SEARCH_SUPPORTED);
}
}

View File

@ -21,8 +21,10 @@ package ca.uhn.fhir.jpa.model.search;
*/
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.jpa.model.entity.ModelConfig;
import ca.uhn.fhir.model.dstu2.composite.CodingDt;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.Multimaps;
import com.google.common.collect.SetMultimap;
import org.hibernate.search.engine.backend.document.DocumentElement;
import org.hl7.fhir.instance.model.api.IBaseCoding;
@ -41,15 +43,19 @@ public class ExtendedLuceneIndexData {
private static final Logger ourLog = LoggerFactory.getLogger(ExtendedLuceneIndexData.class);
final FhirContext myFhirContext;
final ModelConfig myModelConfig;
final SetMultimap<String, String> mySearchParamStrings = HashMultimap.create();
final SetMultimap<String, IBaseCoding> mySearchParamTokens = HashMultimap.create();
final SetMultimap<String, String> mySearchParamLinks = HashMultimap.create();
final SetMultimap<String, DateSearchIndexData> mySearchParamDates = HashMultimap.create();
final SetMultimap<String, QuantitySearchIndexData> mySearchParamQuantities = HashMultimap.create();
private String myForcedId;
private String myResourceJSON;
public ExtendedLuceneIndexData(FhirContext theFhirContext) {
public ExtendedLuceneIndexData(FhirContext theFhirContext, ModelConfig theModelConfig) {
this.myFhirContext = theFhirContext;
this.myModelConfig = theModelConfig;
}
private <V> BiConsumer<String, V> ifNotContained(BiConsumer<String, V> theIndexWriter) {
@ -70,7 +76,7 @@ public class ExtendedLuceneIndexData {
* @param theDocument the Hibernate Search document for ResourceTable
*/
public void writeIndexElements(DocumentElement theDocument) {
HibernateSearchIndexWriter indexWriter = HibernateSearchIndexWriter.forRoot(myFhirContext, theDocument);
HibernateSearchIndexWriter indexWriter = HibernateSearchIndexWriter.forRoot(myFhirContext, myModelConfig, theDocument);
ourLog.debug("Writing JPA index to Hibernate Search");
@ -84,6 +90,8 @@ public class ExtendedLuceneIndexData {
mySearchParamStrings.forEach(ifNotContained(indexWriter::writeStringIndex));
mySearchParamTokens.forEach(ifNotContained(indexWriter::writeTokenIndex));
mySearchParamLinks.forEach(ifNotContained(indexWriter::writeReferenceIndex));
// we want to receive the whole entry collection for each invocation
Multimaps.asMap(mySearchParamQuantities).forEach(ifNotContained(indexWriter::writeQuantityIndex));
// TODO MB Use RestSearchParameterTypeEnum to define templates.
mySearchParamDates.forEach(ifNotContained(indexWriter::writeDateIndex));
}
@ -115,6 +123,10 @@ public class ExtendedLuceneIndexData {
mySearchParamDates.put(theSpName, new DateSearchIndexData(theLowerBound, theLowerBoundOrdinal, theUpperBound, theUpperBoundOrdinal));
}
public void addQuantityIndexData(String theSpName, String theUnits, String theSystem, double theValue) {
mySearchParamQuantities.put(theSpName, new QuantitySearchIndexData(theUnits, theSystem, theValue));
}
public void setForcedId(String theForcedId) {
myForcedId = theForcedId;
}

View File

@ -21,12 +21,20 @@ package ca.uhn.fhir.jpa.model.search;
*/
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.jpa.model.entity.ModelConfig;
import ca.uhn.fhir.jpa.model.util.UcumServiceUtil;
import org.apache.commons.lang3.StringUtils;
import org.fhir.ucum.Pair;
import org.hibernate.search.engine.backend.document.DocumentElement;
import org.hl7.fhir.instance.model.api.IBaseCoding;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal;
import java.util.Collection;
import static org.hl7.fhir.r4.model.Observation.SP_VALUE_QUANTITY;
public class HibernateSearchIndexWriter {
private static final Logger ourLog = LoggerFactory.getLogger(HibernateSearchIndexWriter.class);
public static final String IDX_STRING_NORMALIZED = "norm";
@ -34,21 +42,33 @@ public class HibernateSearchIndexWriter {
public static final String IDX_STRING_TEXT = "text";
public static final String NESTED_SEARCH_PARAM_ROOT = "nsp";
public static final String SEARCH_PARAM_ROOT = "sp";
public static final String QTY_PARAM_NAME = "quantity";
public static final String QTY_CODE = "code";
public static final String QTY_SYSTEM = "system";
public static final String QTY_VALUE = "value";
public static final String QTY_CODE_NORM = "code-norm";
public static final String QTY_VALUE_NORM = "value-norm";
final HibernateSearchElementCache myNodeCache;
final FhirContext myFhirContext;
final ModelConfig myModelConfig;
HibernateSearchIndexWriter(FhirContext theFhirContext, DocumentElement theRoot) {
HibernateSearchIndexWriter(FhirContext theFhirContext, ModelConfig theModelConfig, DocumentElement theRoot) {
myFhirContext = theFhirContext;
myModelConfig = theModelConfig;
myNodeCache = new HibernateSearchElementCache(theRoot);
}
public DocumentElement getSearchParamIndexNode(String theSearchParamName, String theIndexType) {
return myNodeCache.getObjectElement(SEARCH_PARAM_ROOT, theSearchParamName, theIndexType);
}
public static HibernateSearchIndexWriter forRoot(FhirContext theFhirContext, DocumentElement theDocument) {
return new HibernateSearchIndexWriter(theFhirContext, theDocument);
public static HibernateSearchIndexWriter forRoot(
FhirContext theFhirContext, ModelConfig theModelConfig, DocumentElement theDocument) {
return new HibernateSearchIndexWriter(theFhirContext, theModelConfig, theDocument);
}
public void writeStringIndex(String theSearchParam, String theValue) {
@ -97,4 +117,38 @@ public class HibernateSearchIndexWriter {
dateIndexNode.addValue("upper", theValue.getUpperBoundDate().toInstant());
ourLog.trace("Adding Search Param Reference: {} -- {}", theSearchParam, theValue);
}
public void writeQuantityIndex(String theSearchParam, Collection<QuantitySearchIndexData> theValueCollection) {
DocumentElement nestedRoot = myNodeCache.getObjectElement(NESTED_SEARCH_PARAM_ROOT);
for (QuantitySearchIndexData theValue : theValueCollection) {
DocumentElement nestedSpNode = nestedRoot.addObject(theSearchParam);
DocumentElement nestedQtyNode = nestedSpNode.addObject(QTY_PARAM_NAME);
ourLog.trace("Adding Search Param Quantity: {} -- {}", theSearchParam, theValue);
nestedQtyNode.addValue(QTY_CODE, theValue.getCode());
nestedQtyNode.addValue(QTY_SYSTEM, theValue.getSystem());
nestedQtyNode.addValue(QTY_VALUE, theValue.getValue());
if ( ! myModelConfig.getNormalizedQuantitySearchLevel().storageOrSearchSupported()) { return; }
//-- convert the value/unit to the canonical form if any
Pair canonicalForm = UcumServiceUtil.getCanonicalForm(theValue.getSystem(),
BigDecimal.valueOf(theValue.getValue()), theValue.getCode());
if (canonicalForm == null) { return; }
double canonicalValue = Double.parseDouble(canonicalForm.getValue().asDecimal());
String canonicalUnits = canonicalForm.getCode();
ourLog.trace("Adding search param normalized code and value: {} -- code:{}, value:{}",
theSearchParam, canonicalUnits, canonicalValue);
nestedQtyNode.addValue(QTY_CODE_NORM, canonicalUnits);
nestedQtyNode.addValue(QTY_VALUE_NORM, canonicalValue);
}
}
}

View File

@ -0,0 +1,45 @@
package ca.uhn.fhir.jpa.model.search;
/*-
* #%L
* HAPI FHIR JPA Model
* %%
* 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 java.math.BigDecimal;
class QuantitySearchIndexData {
// unit is also referred as code
private final String myCode;
private final String mySystem;
private final double myValue;
QuantitySearchIndexData(String theCode, String theSystem, double theValue) {
myCode = theCode;
mySystem = theSystem;
myValue = theValue;
}
public String getCode() { return myCode; }
public String getSystem() { return mySystem; }
public double getValue() { return myValue; }
}

View File

@ -43,6 +43,11 @@ import java.time.Instant;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.IDX_STRING_EXACT;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.IDX_STRING_NORMALIZED;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.IDX_STRING_TEXT;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.QTY_CODE;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.QTY_CODE_NORM;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.QTY_SYSTEM;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.QTY_VALUE;
import static ca.uhn.fhir.jpa.model.search.HibernateSearchIndexWriter.QTY_VALUE_NORM;
/**
* Allows hibernate search to index
@ -60,7 +65,9 @@ public class SearchParamTextPropertyBinder implements PropertyBinder, PropertyBr
public void bind(PropertyBindingContext thePropertyBindingContext) {
// TODO Is it safe to use object identity of the Map to track dirty?
// N.B. GGG I would hazard that it is not, we could potentially use Version of the resource.
thePropertyBindingContext.dependencies().use("mySearchParamStrings");
thePropertyBindingContext.dependencies()
.use("mySearchParamStrings")
.use("mySearchParamQuantities");
defineIndexingTemplate(thePropertyBindingContext);
@ -101,6 +108,10 @@ public class SearchParamTextPropertyBinder implements PropertyBinder, PropertyBr
.projectable(Projectable.NO)
.sortable(Sortable.YES);
StandardIndexFieldTypeOptionsStep<?, Double> bigDecimalFieldType = indexFieldTypeFactory.asDouble()
.projectable(Projectable.NO)
.sortable(Sortable.YES);
StringIndexFieldTypeOptionsStep<?> forcedIdType = indexFieldTypeFactory.asString()
.projectable(Projectable.YES)
.aggregable(Aggregable.NO);
@ -161,11 +172,18 @@ public class SearchParamTextPropertyBinder implements PropertyBinder, PropertyBr
nestedSpField.fieldTemplate("token-code-system", keywordFieldType).matchingPathGlob(tokenPathGlob + ".code-system").multiValued();
nestedSpField.fieldTemplate("token-system", keywordFieldType).matchingPathGlob(tokenPathGlob + ".system").multiValued();
// reference
// reference
spfield.fieldTemplate("reference-value", keywordFieldType).matchingPathGlob("*.reference.value").multiValued();
//quantity
String quantityPathGlob = "*.quantity";
nestedSpField.objectFieldTemplate("quantityTemplate", ObjectStructure.FLATTENED).matchingPathGlob(quantityPathGlob);
nestedSpField.fieldTemplate(QTY_SYSTEM, keywordFieldType).matchingPathGlob(quantityPathGlob + "." + QTY_SYSTEM);
nestedSpField.fieldTemplate(QTY_CODE, keywordFieldType).matchingPathGlob(quantityPathGlob + "." + QTY_CODE);
nestedSpField.fieldTemplate(QTY_VALUE, bigDecimalFieldType).matchingPathGlob(quantityPathGlob + "." + QTY_VALUE);
nestedSpField.fieldTemplate(QTY_CODE_NORM, keywordFieldType).matchingPathGlob(quantityPathGlob + "." + QTY_CODE_NORM);
nestedSpField.fieldTemplate(QTY_VALUE_NORM, bigDecimalFieldType).matchingPathGlob(quantityPathGlob + "." + QTY_VALUE_NORM);
// date
String dateTimePathGlob = "*.dt";
spfield.objectFieldTemplate("datetimeIndex", ObjectStructure.FLATTENED).matchingPathGlob(dateTimePathGlob);

View File

@ -33,6 +33,9 @@ public class DaoTestDataBuilder implements ITestDataBuilder, AfterEachCallback {
@Override
public IIdType doCreateResource(IBaseResource theResource) {
if (ourLog.isDebugEnabled()) {
ourLog.debug("create resource {}", myFhirCtx.newJsonParser().encodeResourceToString(theResource));
}
//noinspection rawtypes
IFhirResourceDao dao = myDaoRegistry.getResourceDao(theResource.getClass());
//noinspection unchecked
@ -74,12 +77,11 @@ public class DaoTestDataBuilder implements ITestDataBuilder, AfterEachCallback {
@Configuration
public static class Config {
@Autowired FhirContext myFhirContext;
@Autowired DaoRegistry myDaoRegistry;
@Bean
DaoTestDataBuilder testDataBuilder(
@Autowired FhirContext myFhirContext,
@Autowired DaoRegistry myDaoRegistry
) {
DaoTestDataBuilder testDataBuilder() {
return new DaoTestDataBuilder(myFhirContext, myDaoRegistry, new SystemRequestDetails());
}
}

View File

@ -192,7 +192,7 @@ public interface ITestDataBuilder {
};
}
default <T extends IBase> Consumer<T> withAttribute(String thePath, Consumer<IBase>... theModifiers) {
default <T extends IBase> Consumer<T> withElementAt(String thePath, Consumer<IBase>... theModifiers) {
return t->{
FhirTerser terser = getFhirContext().newTerser();
IBase element = terser.addElement(t, thePath);
@ -200,6 +200,15 @@ public interface ITestDataBuilder {
};
}
default Consumer<IBaseResource> withQuantityAtPath(String thePath, double theValue, String theSystem, String theCode) {
return withElementAt(thePath,
withPrimitiveAttribute("value", theValue),
withPrimitiveAttribute("system", theSystem),
withPrimitiveAttribute("code", theCode)
);
}
/**
* Create an Element and apply modifiers
* @param theElementType the FHIR Element type to create

View File

@ -0,0 +1,49 @@
package ca.uhn.fhir.test.utilities;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.Map;
/**
* Extension to allow temporary log elevation for a single test.
*
*/
public class LogbackLevelOverrideExtension implements AfterEachCallback {
private final Map<String, Level> mySavedLevels = new HashMap<>();
public void setLogLevel(Class theClass, Level theLevel) {
String name = theClass.getName();
Logger logger = getClassicLogger(name);
if (!mySavedLevels.containsKey(name)) {
// level can be null
mySavedLevels.put(name, logger.getLevel());
}
logger.setLevel(theLevel);
}
private Logger getClassicLogger(String name) {
return (Logger) LoggerFactory.getLogger(name);
}
@Override
public void afterEach(ExtensionContext context) throws Exception {
mySavedLevels.forEach((name,level) ->{
getClassicLogger(name).setLevel(level);
});
mySavedLevels.clear();
}
public void resetLevel(Class theClass) {
String name = theClass.getName();
if (mySavedLevels.containsKey(name)) {
getClassicLogger(name).setLevel(mySavedLevels.get(name));
mySavedLevels.remove(name);
}
}
}