Better error messages for ValueSet $expand contextDirection=existing (#3446)

* Better error messages for ValueSet $expand contextDirection=existing
* Apply to $lastn too - it uses aggregations
This commit is contained in:
michaelabuckley 2022-03-03 23:11:08 -05:00 committed by GitHub
parent d11a312abf
commit 20e092ba6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 115 additions and 41 deletions

View File

@ -25,7 +25,7 @@ public final class Msg {
/** /**
* IMPORTANT: Please update the following comment after you add a new code * IMPORTANT: Please update the following comment after you add a new code
* Last code value: 2068 * Last code value: 2070
*/ */
private Msg() {} private Msg() {}

View File

@ -83,6 +83,14 @@ public enum TokenParamModifier {
return myValue; return myValue;
} }
/**
* The modifier without the :
* @return the string after the leading :
*/
public String getBareModifier() {
return myValue.substring(1);
}
public static TokenParamModifier forValue(String theValue) { public static TokenParamModifier forValue(String theValue) {
return VALUE_TO_ENUM.get(theValue); return VALUE_TO_ENUM.get(theValue);
} }

View File

@ -22,6 +22,7 @@ package ca.uhn.fhir.jpa.dao;
import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.RuntimeSearchParam; import ca.uhn.fhir.context.RuntimeSearchParam;
import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.jpa.api.config.DaoConfig; import ca.uhn.fhir.jpa.api.config.DaoConfig;
import ca.uhn.fhir.jpa.dao.data.IForcedIdDao; import ca.uhn.fhir.jpa.dao.data.IForcedIdDao;
import ca.uhn.fhir.jpa.dao.search.ExtendedLuceneClauseBuilder; import ca.uhn.fhir.jpa.dao.search.ExtendedLuceneClauseBuilder;
@ -42,9 +43,11 @@ import ca.uhn.fhir.rest.api.server.storage.ResourcePersistentId;
import ca.uhn.fhir.rest.param.StringParam; import ca.uhn.fhir.rest.param.StringParam;
import ca.uhn.fhir.rest.param.TokenParam; import ca.uhn.fhir.rest.param.TokenParam;
import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; import ca.uhn.fhir.rest.server.util.ISearchParamRegistry;
import org.hibernate.search.backend.elasticsearch.ElasticsearchExtension;
import org.hibernate.search.mapper.orm.Search; import org.hibernate.search.mapper.orm.Search;
import org.hibernate.search.mapper.orm.session.SearchSession; import org.hibernate.search.mapper.orm.session.SearchSession;
import org.hibernate.search.mapper.orm.work.SearchIndexingPlan; import org.hibernate.search.mapper.orm.work.SearchIndexingPlan;
import org.hibernate.search.util.common.SearchException;
import org.hl7.fhir.instance.model.api.IAnyResource; import org.hl7.fhir.instance.model.api.IAnyResource;
import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IBaseResource;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -235,13 +238,35 @@ public class FulltextSearchSvcImpl implements IFulltextSearchSvc {
@Transactional() @Transactional()
@Override @Override
public IBaseResource tokenAutocompleteValueSetSearch(ValueSetAutocompleteOptions theOptions) { public IBaseResource tokenAutocompleteValueSetSearch(ValueSetAutocompleteOptions theOptions) {
ensureElastic();
ValueSetAutocompleteSearch autocomplete = new ValueSetAutocompleteSearch(myFhirContext, getSearchSession()); ValueSetAutocompleteSearch autocomplete = new ValueSetAutocompleteSearch(myFhirContext, getSearchSession());
return autocomplete.search(theOptions); return autocomplete.search(theOptions);
} }
/**
* Throws an error if configured with Lucene.
*
* Some features only work with Elasticsearch.
* Lastn and the autocomplete search use nested aggregations which are Elasticsearch-only
*/
private void ensureElastic() {
//String hibernateSearchBackend = (String) myEntityManager.g.getJpaPropertyMap().get(BackendSettings.backendKey(BackendSettings.TYPE));
try {
getSearchSession().scope( ResourceTable.class )
.aggregation()
.extension(ElasticsearchExtension.get());
} catch (SearchException e) {
// unsupported. we are probably running Lucene.
throw new IllegalStateException(Msg.code(2070) + "This operation requires Elasticsearch. Lucene is not supported.");
}
}
@Override @Override
public List<ResourcePersistentId> lastN(SearchParameterMap theParams, Integer theMaximumResults) { public List<ResourcePersistentId> lastN(SearchParameterMap theParams, Integer theMaximumResults) {
ensureElastic();
List<Long> pidList = new LastNOperation(getSearchSession(), myFhirContext, mySearchParamRegistry) List<Long> pidList = new LastNOperation(getSearchSession(), myFhirContext, mySearchParamRegistry)
.executeLastN(theParams, theMaximumResults); .executeLastN(theParams, theMaximumResults);
return convertLongsToResourcePersistentIds(pidList); return convertLongsToResourcePersistentIds(pidList);

View File

@ -22,22 +22,50 @@ package ca.uhn.fhir.jpa.search.autocomplete;
import ca.uhn.fhir.i18n.Msg; import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.jpa.api.config.DaoConfig; import ca.uhn.fhir.jpa.api.config.DaoConfig;
import ca.uhn.fhir.rest.param.TokenParamModifier;
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IIdType; import org.hl7.fhir.instance.model.api.IIdType;
import org.hl7.fhir.instance.model.api.IPrimitiveType; import org.hl7.fhir.instance.model.api.IPrimitiveType;
import java.util.Arrays;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import static org.apache.commons.lang3.StringUtils.defaultString;
import static org.apache.commons.lang3.StringUtils.isNotBlank; import static org.apache.commons.lang3.StringUtils.isNotBlank;
public class ValueSetAutocompleteOptions { public class ValueSetAutocompleteOptions {
private String myResourceType; private final String myResourceType;
private String mySearchParamCode; private final String mySearchParamCode;
private String mySearchParamModifier; private final String mySearchParamModifier;
private String myFilter; private final String myFilter;
private Integer myCount; private final Integer myCount;
static final List<String> ourSupportedModifiers = Arrays.asList("", TokenParamModifier.TEXT.getBareModifier());
public ValueSetAutocompleteOptions(String theContext, String theFilter, Integer theCount) {
myFilter = theFilter;
myCount = theCount;
int separatorIdx = theContext.indexOf('.');
String codeWithPossibleModifier;
if (separatorIdx >= 0) {
myResourceType = theContext.substring(0, separatorIdx);
codeWithPossibleModifier = theContext.substring(separatorIdx + 1);
} else {
myResourceType = null;
codeWithPossibleModifier = theContext;
}
int modifierIdx = codeWithPossibleModifier.indexOf(':');
if (modifierIdx >= 0) {
mySearchParamCode = codeWithPossibleModifier.substring(0, modifierIdx);
mySearchParamModifier = codeWithPossibleModifier.substring(modifierIdx + 1);
} else {
mySearchParamCode = codeWithPossibleModifier;
mySearchParamModifier = null;
}
}
public static ValueSetAutocompleteOptions validateAndParseOptions( public static ValueSetAutocompleteOptions validateAndParseOptions(
DaoConfig theDaoConfig, DaoConfig theDaoConfig,
@ -57,38 +85,19 @@ public class ValueSetAutocompleteOptions {
if (!theDaoConfig.isAdvancedLuceneIndexing()) { if (!theDaoConfig.isAdvancedLuceneIndexing()) {
throw new InvalidRequestException(Msg.code(2022) + "$expand with contexDirection='existing' requires Extended Lucene Indexing."); throw new InvalidRequestException(Msg.code(2022) + "$expand with contexDirection='existing' requires Extended Lucene Indexing.");
} }
ValueSetAutocompleteOptions result = new ValueSetAutocompleteOptions(); if (theContext == null || theContext.isEmpty()) {
throw new InvalidRequestException(Msg.code(2021) + "$expand with contexDirection='existing' requires a context");
}
String filter = theFilter == null ? null : theFilter.getValue();
ValueSetAutocompleteOptions result = new ValueSetAutocompleteOptions(theContext.getValue(), filter, IPrimitiveType.toValueOrNull(theCount));
result.parseContext(theContext); if (!ourSupportedModifiers.contains(defaultString(result.getSearchParamModifier()))) {
result.myFilter = throw new InvalidRequestException(Msg.code(2069) + "$expand with contexDirection='existing' only supports plain token search, or the :text modifier. Received " + result.getSearchParamModifier());
theFilter == null ? null : theFilter.getValue(); }
result.myCount = IPrimitiveType.toValueOrNull(theCount);
return result; return result;
} }
private void parseContext(IPrimitiveType<String> theContextWrapper) {
if (theContextWrapper == null || theContextWrapper.isEmpty()) {
throw new InvalidRequestException(Msg.code(2021) + "$expand with contexDirection='existing' requires a context");
}
String theContext = theContextWrapper.getValue();
int separatorIdx = theContext.indexOf('.');
String codeWithPossibleModifier;
if (separatorIdx >= 0) {
myResourceType = theContext.substring(0, separatorIdx);
codeWithPossibleModifier = theContext.substring(separatorIdx + 1);
} else {
codeWithPossibleModifier = theContext;
}
int modifierIdx = codeWithPossibleModifier.indexOf(':');
if (modifierIdx >= 0) {
mySearchParamCode = codeWithPossibleModifier.substring(0, modifierIdx);
mySearchParamModifier = codeWithPossibleModifier.substring(modifierIdx + 1);
} else {
mySearchParamCode = codeWithPossibleModifier;
}
}
public String getResourceType() { public String getResourceType() {
return myResourceType; return myResourceType;

View File

@ -1,24 +1,37 @@
package ca.uhn.fhir.jpa.dao.r4; package ca.uhn.fhir.jpa.dao.r4;
import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.jpa.dao.BaseHapiFhirDao; 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.jpa.searchparam.SearchParameterMap;
import ca.uhn.fhir.rest.api.Constants; import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.server.IBundleProvider; import ca.uhn.fhir.rest.param.StringAndListParam;
import ca.uhn.fhir.rest.param.*; import ca.uhn.fhir.rest.param.StringOrListParam;
import ca.uhn.fhir.rest.param.StringParam;
import ca.uhn.fhir.rest.param.TokenParam;
import ca.uhn.fhir.rest.param.TokenParamModifier;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
import org.hl7.fhir.instance.model.api.IIdType; import org.hl7.fhir.instance.model.api.IIdType;
import org.hl7.fhir.r4.model.*; import org.hl7.fhir.r4.model.Device;
import org.hl7.fhir.r4.model.Observation;
import org.hl7.fhir.r4.model.Observation.ObservationStatus; import org.hl7.fhir.r4.model.Observation.ObservationStatus;
import org.hl7.fhir.r4.model.Patient;
import org.hl7.fhir.r4.model.Quantity;
import org.hl7.fhir.r4.model.StringType;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import java.util.List; import java.util.List;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*; import static org.hamcrest.Matchers.contains;
import static org.junit.jupiter.api.Assertions.*; 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.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
public class FhirResourceDaoR4SearchFtTest extends BaseJpaR4Test { public class FhirResourceDaoR4SearchFtTest extends BaseJpaR4Test {
@ -457,5 +470,17 @@ public class FhirResourceDaoR4SearchFtTest extends BaseJpaR4Test {
} }
/**
* make sure we provide a clear error message when a feature requires Elastic
*/
@Test
public void tokenAutocompleteFailsWithLucene() {
try {
myFulltestSearchSvc.tokenAutocompleteValueSetSearch(new ValueSetAutocompleteOptions("Observation.code", null, null));
fail("Expected exception");
} catch (IllegalStateException e) {
assertThat(e.getMessage(), startsWith(Msg.code(2070)));
}
}
} }

View File

@ -36,7 +36,7 @@ class ValueSetAutocompleteOptionsTest {
private IPrimitiveType<String> myUrl; private IPrimitiveType<String> myUrl;
private ValueSet myValueSet; private ValueSet myValueSet;
private ValueSetAutocompleteOptions myOptionsResult; private ValueSetAutocompleteOptions myOptionsResult;
private DaoConfig myDaoConfig = new DaoConfig(); final private DaoConfig myDaoConfig = new DaoConfig();
{ {
myDaoConfig.setAdvancedLuceneIndexing(true); myDaoConfig.setAdvancedLuceneIndexing(true);
@ -159,6 +159,14 @@ class ValueSetAutocompleteOptionsTest {
assertParseThrowsInvalidRequestWithErrorCode(ERROR_AUTOCOMPLETE_REQUIRES_CONTEXT); assertParseThrowsInvalidRequestWithErrorCode(ERROR_AUTOCOMPLETE_REQUIRES_CONTEXT);
} }
@Test
public void withUnsupportedModifier() {
myFilter = new StringDt("blood");
myContext = new StringDt("Observation.code:exact");
assertParseThrowsInvalidRequestWithErrorCode(2069);
}
@Test @Test
public void whenAdvancedIndexingOff() { public void whenAdvancedIndexingOff() {
// given // given
@ -168,7 +176,6 @@ class ValueSetAutocompleteOptionsTest {
} }
private void assertParseThrowsInvalidRequestWithErrorCode(int theErrorCode) { private void assertParseThrowsInvalidRequestWithErrorCode(int theErrorCode) {
InvalidRequestException e = assertThrows(InvalidRequestException.class, ValueSetAutocompleteOptionsTest.this::parseOptions); InvalidRequestException e = assertThrows(InvalidRequestException.class, ValueSetAutocompleteOptionsTest.this::parseOptions);
assertThat(e.getMessage(), startsWith(Msg.code(theErrorCode))); assertThat(e.getMessage(), startsWith(Msg.code(theErrorCode)));