From cedeaa04f185ee132696715857ec9ec7407bda9e Mon Sep 17 00:00:00 2001 From: JasonRoberts-smile <85363818+JasonRoberts-smile@users.noreply.github.com> Date: Thu, 16 Jun 2022 16:17:57 -0400 Subject: [PATCH] add support for in and not-in qualifiers (#3680) * add support for in and not-in qualifiers * fix failing test * fix broken test * fix broken tests * code review comments * Make dependency on IValidationSupport optional * create IValidationSupport dependency only if needed * small design improvements * changelog * rework validation support initialization * Update hapi-fhir-jpaserver-searchparam/src/main/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryResourceMatcher.java Co-authored-by: Ken Stevens Co-authored-by: Ken Stevens --- .../src/main/java/ca/uhn/fhir/i18n/Msg.java | 2 +- ...3620-support-in-and-not-in-qualifiers.yaml | 4 + .../fhir/jpa/term/BaseTermReadSvcImpl.java | 2 +- .../uhn/fhir/jpa/term/TermReadSvcDstu2.java | 7 +- .../matcher/InMemoryResourceMatcher.java | 216 ++++++++++++++---- ...oryResourceMatcherConfigurationR5Test.java | 133 +++++++++++ .../InMemoryResourceMatcherR5Test.java | 98 ++++++-- .../registry/SearchParamRegistryImplTest.java | 6 + 8 files changed, 406 insertions(+), 62 deletions(-) create mode 100644 hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/6_1_0/3620-support-in-and-not-in-qualifiers.yaml create mode 100644 hapi-fhir-jpaserver-searchparam/src/test/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryResourceMatcherConfigurationR5Test.java diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/i18n/Msg.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/i18n/Msg.java index e05a911556c..d5e5e744fe0 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/i18n/Msg.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/i18n/Msg.java @@ -25,7 +25,7 @@ public final class Msg { /** * IMPORTANT: Please update the following comment after you add a new code - * Last code value: 2094 + * Last code value: 2096 */ private Msg() {} diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/6_1_0/3620-support-in-and-not-in-qualifiers.yaml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/6_1_0/3620-support-in-and-not-in-qualifiers.yaml new file mode 100644 index 00000000000..7a659a3e4fb --- /dev/null +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/6_1_0/3620-support-in-and-not-in-qualifiers.yaml @@ -0,0 +1,4 @@ +--- +type: add +issue: 3620 +title: "Add support for the `:in` and `:not-in` qualifiers for use in SMART on FHIR v2 granular scope definition." diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/term/BaseTermReadSvcImpl.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/term/BaseTermReadSvcImpl.java index 256bccd7468..4fba12f4e9b 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/term/BaseTermReadSvcImpl.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/term/BaseTermReadSvcImpl.java @@ -2389,7 +2389,7 @@ public abstract class BaseTermReadSvcImpl implements ITermReadSvc { } @Nonnull - private IValidationSupport provideValidationSupport() { + protected IValidationSupport provideValidationSupport() { IValidationSupport validationSupport = myValidationSupport; if (validationSupport == null) { validationSupport = myApplicationContext.getBean(IValidationSupport.class); diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/term/TermReadSvcDstu2.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/term/TermReadSvcDstu2.java index aee5b2af2b2..687f0b7fc28 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/term/TermReadSvcDstu2.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/term/TermReadSvcDstu2.java @@ -45,9 +45,6 @@ import static org.apache.commons.lang3.StringUtils.isNotBlank; public class TermReadSvcDstu2 extends BaseTermReadSvcImpl { - @Autowired - private IValidationSupport myValidationSupport; - private void addAllChildren(String theSystemString, ca.uhn.fhir.model.dstu2.resource.ValueSet.CodeSystemConcept theCode, List theListToPopulate) { if (isNotBlank(theCode.getCode())) { theListToPopulate.add(new FhirVersionIndependentConcept(theSystemString, theCode.getCode())); @@ -106,7 +103,7 @@ public class TermReadSvcDstu2 extends BaseTermReadSvcImpl { @Override public List findCodesAboveUsingBuiltInSystems(String theSystem, String theCode) { ArrayList retVal = new ArrayList<>(); - ca.uhn.fhir.model.dstu2.resource.ValueSet system = (ca.uhn.fhir.model.dstu2.resource.ValueSet) myValidationSupport.fetchCodeSystem(theSystem); + ca.uhn.fhir.model.dstu2.resource.ValueSet system = (ca.uhn.fhir.model.dstu2.resource.ValueSet) provideValidationSupport().fetchCodeSystem(theSystem); if (system != null) { findCodesAbove(system, theSystem, theCode, retVal); } @@ -131,7 +128,7 @@ public class TermReadSvcDstu2 extends BaseTermReadSvcImpl { @Override public List findCodesBelowUsingBuiltInSystems(String theSystem, String theCode) { ArrayList retVal = new ArrayList<>(); - ca.uhn.fhir.model.dstu2.resource.ValueSet system = (ca.uhn.fhir.model.dstu2.resource.ValueSet) myValidationSupport.fetchCodeSystem(theSystem); + ca.uhn.fhir.model.dstu2.resource.ValueSet system = (ca.uhn.fhir.model.dstu2.resource.ValueSet) provideValidationSupport().fetchCodeSystem(theSystem); if (system != null) { findCodesBelow(system, theSystem, theCode, retVal); } diff --git a/hapi-fhir-jpaserver-searchparam/src/main/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryResourceMatcher.java b/hapi-fhir-jpaserver-searchparam/src/main/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryResourceMatcher.java index 6d57fb31b33..c4332acab73 100644 --- a/hapi-fhir-jpaserver-searchparam/src/main/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryResourceMatcher.java +++ b/hapi-fhir-jpaserver-searchparam/src/main/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryResourceMatcher.java @@ -20,11 +20,16 @@ package ca.uhn.fhir.jpa.searchparam.matcher; * #L% */ +import ca.uhn.fhir.context.ConfigurationException; import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.context.RuntimeResourceDefinition; import ca.uhn.fhir.context.RuntimeSearchParam; +import ca.uhn.fhir.context.support.ConceptValidationOptions; +import ca.uhn.fhir.context.support.IValidationSupport; +import ca.uhn.fhir.context.support.ValidationSupportContext; import ca.uhn.fhir.i18n.Msg; import ca.uhn.fhir.jpa.model.entity.ModelConfig; +import ca.uhn.fhir.jpa.model.entity.ResourceIndexedSearchParamToken; import ca.uhn.fhir.jpa.searchparam.MatchUrlService; import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; import ca.uhn.fhir.jpa.searchparam.extractor.ResourceIndexedSearchParams; @@ -36,24 +41,35 @@ import ca.uhn.fhir.rest.param.BaseParamWithPrefix; import ca.uhn.fhir.rest.param.ParamPrefixEnum; 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.server.exceptions.InvalidRequestException; import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; import ca.uhn.fhir.util.MetaUtil; import ca.uhn.fhir.util.UrlUtil; +import com.google.common.collect.Sets; import org.apache.commons.lang3.Validate; import org.hl7.fhir.dstu3.model.Location; import org.hl7.fhir.instance.model.api.IAnyResource; import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IIdType; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; import javax.annotation.Nonnull; import java.util.List; import java.util.Map; -import java.util.Optional; +import java.util.Set; public class InMemoryResourceMatcher { + private enum ValidationSupportInitializationState {NOT_INITIALIZED, INITIALIZED, FAILED} + + public static final Set UNSUPPORTED_PARAMETER_NAMES = Sets.newHashSet(Constants.PARAM_HAS, Constants.PARAM_TAG, Constants.PARAM_PROFILE, Constants.PARAM_SECURITY); + private static final org.slf4j.Logger ourLog = LoggerFactory.getLogger(InMemoryResourceMatcher.class); + @Autowired + ApplicationContext myApplicationContext; @Autowired private MatchUrlService myMatchUrlService; @Autowired @@ -63,8 +79,32 @@ public class InMemoryResourceMatcher { @Autowired FhirContext myFhirContext; + private ValidationSupportInitializationState validationSupportState = ValidationSupportInitializationState.NOT_INITIALIZED; + private IValidationSupport myValidationSupport = null; + public InMemoryResourceMatcher() {} + /** + * Lazy loads a {@link IValidationSupport} implementation just-in-time. + * If no suitable bean is available, or if a {@link ca.uhn.fhir.context.ConfigurationException} is thrown, matching + * can proceed, but the qualifiers that depend on the validation support will be disabled. + * + * @return A bean implementing {@link IValidationSupport} if one is available, otherwise null + */ + private IValidationSupport getValidationSupportOrNull() { + if (validationSupportState == ValidationSupportInitializationState.NOT_INITIALIZED) { + try { + myValidationSupport = myApplicationContext.getBean(IValidationSupport.class); + validationSupportState = ValidationSupportInitializationState.INITIALIZED; + } catch (BeansException | ConfigurationException ignore) { + // We couldn't get a validation support bean, and we don't want to waste cycles trying again + ourLog.warn(Msg.code(2095) + "No bean satisfying IValidationSupport could be initialized. Qualifiers dependent on IValidationSupport will not be supported."); + validationSupportState = ValidationSupportInitializationState.FAILED; + } + } + return myValidationSupport; + } + /** * This method is called in two different scenarios. With a null theResource, it determines whether database matching might be required. * Otherwise, it tries to perform the match in-memory, returning UNSUPPORTED if it's not possible. @@ -139,26 +179,9 @@ public class InMemoryResourceMatcher { return InMemoryMatchResult.successfulMatch(); } - if (hasQualifiers(theAndOrParams)) { - Optional optionalParameter = theAndOrParams.stream().flatMap(List::stream).filter(param -> param.getQueryParameterQualifier() != null).findAny(); - if (optionalParameter.isPresent()) { - IQueryParameterType parameter = optionalParameter.get(); - if (parameter instanceof ReferenceParam) { - ReferenceParam referenceParam = (ReferenceParam) parameter; - return InMemoryMatchResult.unsupportedFromParameterAndReason(theParamName + "." + referenceParam.getChain(), InMemoryMatchResult.CHAIN); - } - return InMemoryMatchResult.unsupportedFromParameterAndReason(theParamName + parameter.getQueryParameterQualifier(), InMemoryMatchResult.QUALIFIER); - } - } - - if (hasChain(theAndOrParams)) { - return InMemoryMatchResult.unsupportedFromParameterAndReason(theParamName, InMemoryMatchResult.CHAIN); - } - String resourceName = theResourceDefinition.getName(); RuntimeSearchParam paramDef = mySearchParamRegistry.getActiveSearchParam(resourceName, theParamName); - InMemoryMatchResult checkUnsupportedResult = checkUnsupportedPrefixes(theParamName, paramDef, theAndOrParams); - + InMemoryMatchResult checkUnsupportedResult = checkForUnsupportedParameters(theParamName, paramDef, theAndOrParams); if (!checkUnsupportedResult.supported()) { return checkUnsupportedResult; } @@ -166,12 +189,6 @@ public class InMemoryResourceMatcher { switch (theParamName) { case IAnyResource.SP_RES_ID: return InMemoryMatchResult.fromBoolean(matchIdsAndOr(theAndOrParams, theResource)); - - case Constants.PARAM_HAS: - case Constants.PARAM_TAG: - case Constants.PARAM_PROFILE: - case Constants.PARAM_SECURITY: - return InMemoryMatchResult.unsupportedFromParameterAndReason(theParamName, InMemoryMatchResult.PARAM); case Constants.PARAM_SOURCE: return InMemoryMatchResult.fromBoolean(matchSourcesAndOr(theAndOrParams, theResource)); default: @@ -179,6 +196,45 @@ public class InMemoryResourceMatcher { } } + private InMemoryMatchResult checkForUnsupportedParameters(String theParamName, RuntimeSearchParam theParamDef, List> theAndOrParams) { + + if (UNSUPPORTED_PARAMETER_NAMES.contains(theParamName)) { + return InMemoryMatchResult.unsupportedFromParameterAndReason(theParamName, InMemoryMatchResult.PARAM); + } + + for (List orParams : theAndOrParams) { + // The list should never be empty, but better safe than sorry + if (orParams.size() > 0) { + // The params in each OR list all share the same qualifier, prefix, etc., so we only need to check the first one + InMemoryMatchResult checkUnsupportedResult = checkOneParameterForUnsupportedModifiers(theParamName, theParamDef, orParams.get(0)); + if (!checkUnsupportedResult.supported()) { + return checkUnsupportedResult; + } + } + } + + return InMemoryMatchResult.successfulMatch(); + } + + private InMemoryMatchResult checkOneParameterForUnsupportedModifiers(String theParamName, RuntimeSearchParam theParamDef, IQueryParameterType theParam) { + // Assume we're ok until we find evidence we aren't + InMemoryMatchResult checkUnsupportedResult = InMemoryMatchResult.successfulMatch(); + + if (hasChain(theParam)) { + checkUnsupportedResult = InMemoryMatchResult.unsupportedFromParameterAndReason(theParamName + "." + ((ReferenceParam)theParam).getChain(), InMemoryMatchResult.CHAIN); + } + + if (checkUnsupportedResult.supported()) { + checkUnsupportedResult = checkUnsupportedQualifiers(theParamName, theParamDef, theParam); + } + + if (checkUnsupportedResult.supported()) { + checkUnsupportedResult = checkUnsupportedPrefixes(theParamName, theParamDef, theParam); + } + + return checkUnsupportedResult; + } + private boolean matchSourcesAndOr(List> theAndOrParams, IBaseResource theResource) { if (theResource == null) { return true; @@ -249,29 +305,77 @@ public class InMemoryResourceMatcher { } private boolean matchParams(ModelConfig theModelConfig, String theResourceName, String theParamName, RuntimeSearchParam paramDef, List theNextAnd, ResourceIndexedSearchParams theSearchParams) { - return theNextAnd.stream().anyMatch(token -> theSearchParams.matchParam(theModelConfig, theResourceName, theParamName, paramDef, token)); + return theNextAnd.stream().anyMatch(token -> matchParam(theModelConfig, theResourceName, theParamName, paramDef, theSearchParams, token)); } - private boolean hasChain(List> theAndOrParams) { - return theAndOrParams.stream().flatMap(List::stream).anyMatch(param -> param instanceof ReferenceParam && ((ReferenceParam) param).getChain() != null); + private boolean matchParam(ModelConfig theModelConfig, String theResourceName, String theParamName, RuntimeSearchParam theParamDef, ResourceIndexedSearchParams theSearchParams, IQueryParameterType theToken) { + if (theParamDef.getParamType().equals(RestSearchParameterTypeEnum.TOKEN)) { + return matchTokenParam(theModelConfig, theResourceName, theParamName, theParamDef, theSearchParams, (TokenParam) theToken); + } else { + return theSearchParams.matchParam(theModelConfig, theResourceName, theParamName, theParamDef, theToken); + } } - private boolean hasQualifiers(List> theAndOrParams) { - return theAndOrParams.stream().flatMap(List::stream).anyMatch(param -> param.getQueryParameterQualifier() != null); + /** + * Checks whether a query parameter of type token matches one of the search parameters of an in-memory resource. + * The :in and :not-in qualifiers are supported only if a bean implementing IValidationSupport is available. + * Any other qualifier will be ignored and the match will be treated as unqualified. + * @param theModelConfig a model configuration + * @param theResourceName the name of the resource type being matched + * @param theParamName the name of the parameter + * @param theParamDef the definition of the search parameter + * @param theSearchParams the search parameters derived from the target resource + * @param theQueryParam the query parameter to compare with theSearchParams + * @return true if theQueryParam matches the collection of theSearchParams, otherwise false + */ + private boolean matchTokenParam(ModelConfig theModelConfig, String theResourceName, String theParamName, RuntimeSearchParam theParamDef, ResourceIndexedSearchParams theSearchParams, TokenParam theQueryParam) { + if (theQueryParam.getModifier() != null) { + switch (theQueryParam.getModifier()) { + case IN: + return theSearchParams.myTokenParams.stream() + .filter(t -> t.getParamName().equals(theParamName)) + .anyMatch(t -> systemContainsCode(theQueryParam, t)); + case NOT_IN: + return theSearchParams.myTokenParams.stream() + .filter(t -> t.getParamName().equals(theParamName)) + .noneMatch(t -> systemContainsCode(theQueryParam, t)); + default: + return theSearchParams.matchParam(theModelConfig, theResourceName, theParamName, theParamDef, theQueryParam); + } + } else { + return theSearchParams.matchParam(theModelConfig, theResourceName, theParamName, theParamDef, theQueryParam); + } } - private InMemoryMatchResult checkUnsupportedPrefixes(String theParamName, RuntimeSearchParam theParamDef, List> theAndOrParams) { - if (theParamDef != null) { - for (List theAndOrParam : theAndOrParams) { - for (IQueryParameterType param : theAndOrParam) { - if (param instanceof BaseParamWithPrefix) { - ParamPrefixEnum prefix = ((BaseParamWithPrefix) param).getPrefix(); - RestSearchParameterTypeEnum paramType = theParamDef.getParamType(); - if (!supportedPrefix(prefix, paramType)) { - return InMemoryMatchResult.unsupportedFromParameterAndReason(theParamName, String.format("The prefix %s is not supported for param type %s", prefix, paramType)); - } - } - } + private boolean systemContainsCode(TokenParam theQueryParam, ResourceIndexedSearchParamToken theSearchParamToken) { + IValidationSupport validationSupport = getValidationSupportOrNull(); + if (validationSupport == null) { + ourLog.error(Msg.code(2096) + "Attempting to evaluate an unsupported qualifier. This should not happen."); + return false; + } + + IValidationSupport.CodeValidationResult codeValidationResult = validationSupport.validateCode(new ValidationSupportContext(validationSupport), new ConceptValidationOptions(), theSearchParamToken.getSystem(), theSearchParamToken.getValue(), null, theQueryParam.getValue()); + if (codeValidationResult != null) { + return codeValidationResult.isOk(); + } else { + return false; + } + } + + private boolean hasChain(IQueryParameterType theParam) { + return theParam instanceof ReferenceParam && ((ReferenceParam) theParam).getChain() != null; + } + + private boolean hasQualifiers(IQueryParameterType theParam) { + return theParam.getQueryParameterQualifier() != null; + } + + private InMemoryMatchResult checkUnsupportedPrefixes(String theParamName, RuntimeSearchParam theParamDef, IQueryParameterType theParam) { + if (theParamDef != null && theParam instanceof BaseParamWithPrefix) { + ParamPrefixEnum prefix = ((BaseParamWithPrefix) theParam).getPrefix(); + RestSearchParameterTypeEnum paramType = theParamDef.getParamType(); + if (!supportedPrefix(prefix, paramType)) { + return InMemoryMatchResult.unsupportedFromParameterAndReason(theParamName, String.format("The prefix %s is not supported for param type %s", prefix, paramType)); } } return InMemoryMatchResult.successfulMatch(); @@ -298,4 +402,32 @@ public class InMemoryResourceMatcher { } return false; } + + private InMemoryMatchResult checkUnsupportedQualifiers(String theParamName, RuntimeSearchParam theParamDef, IQueryParameterType theParam) { + if (hasQualifiers(theParam) && !supportedQualifier(theParamDef, theParam)) { + return InMemoryMatchResult.unsupportedFromParameterAndReason(theParamName + theParam.getQueryParameterQualifier(), InMemoryMatchResult.QUALIFIER); + } + return InMemoryMatchResult.successfulMatch(); + } + + private boolean supportedQualifier(RuntimeSearchParam theParamDef, IQueryParameterType theParam) { + if (theParamDef == null || theParam == null) { + return true; + } + switch (theParamDef.getParamType()) { + case TOKEN: + TokenParam tokenParam = (TokenParam) theParam; + switch (tokenParam.getModifier()) { + case IN: + case NOT_IN: + // Support for these qualifiers is dependent on an implementation of IValidationSupport being available to delegate the check to + return getValidationSupportOrNull() != null; + default: + return false; + } + default: + return false; + } + } + } diff --git a/hapi-fhir-jpaserver-searchparam/src/test/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryResourceMatcherConfigurationR5Test.java b/hapi-fhir-jpaserver-searchparam/src/test/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryResourceMatcherConfigurationR5Test.java new file mode 100644 index 00000000000..95ce82fd6da --- /dev/null +++ b/hapi-fhir-jpaserver-searchparam/src/test/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryResourceMatcherConfigurationR5Test.java @@ -0,0 +1,133 @@ +package ca.uhn.fhir.jpa.searchparam.matcher; + +import ca.uhn.fhir.context.ConfigurationException; +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.RuntimeSearchParam; +import ca.uhn.fhir.context.support.IValidationSupport; +import ca.uhn.fhir.jpa.model.config.PartitionSettings; +import ca.uhn.fhir.jpa.model.entity.ModelConfig; +import ca.uhn.fhir.jpa.model.entity.ResourceIndexedSearchParamToken; +import ca.uhn.fhir.jpa.searchparam.MatchUrlService; +import ca.uhn.fhir.jpa.searchparam.extractor.ResourceIndexedSearchParams; +import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; +import ca.uhn.fhir.rest.param.TokenParamModifier; +import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; +import org.hl7.fhir.r5.model.CodeableConcept; +import org.hl7.fhir.r5.model.Coding; +import org.hl7.fhir.r5.model.Observation; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(SpringExtension.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class InMemoryResourceMatcherConfigurationR5Test { + public static final String OBSERVATION_CODE = "MATCH"; + public static final String OBSERVATION_CODE_SYSTEM = "http://hl7.org/some-cs"; + public static final String OBSERVATION_CODE_DISPLAY = "Match"; + public static final String OBSERVATION_CODE_VALUE_SET_URI = "http://hl7.org/some-vs"; + + @MockBean + ISearchParamRegistry mySearchParamRegistry; + @Autowired + private InMemoryResourceMatcher myInMemoryResourceMatcher; + private Observation myObservation; + private ResourceIndexedSearchParams mySearchParams; + + @BeforeEach + public void before() { + RuntimeSearchParam codeSearchParam = new RuntimeSearchParam(null, null, null, null, "Observation.code", RestSearchParameterTypeEnum.TOKEN, null, null, RuntimeSearchParam.RuntimeSearchParamStatusEnum.ACTIVE, null, null, null); + when(mySearchParamRegistry.getActiveSearchParam("Observation", "code")).thenReturn(codeSearchParam); + + myObservation = new Observation(); + CodeableConcept codeableConcept = new CodeableConcept(); + codeableConcept.addCoding().setCode(OBSERVATION_CODE) + .setSystem(OBSERVATION_CODE_SYSTEM).setDisplay(OBSERVATION_CODE_DISPLAY); + myObservation.setCode(codeableConcept); + mySearchParams = extractSearchParams(myObservation); + } + + @Test + @Order(1) // We have to do this one first, because the InMemoryResourceMatcher is stateful regarding whether it has been initialized yet + public void testValidationSupportInitializedOnlyOnce() { + ApplicationContext applicationContext = mock(ApplicationContext.class); + when(applicationContext.getBean(IValidationSupport.class)).thenThrow(new ConfigurationException()); + myInMemoryResourceMatcher.myApplicationContext = applicationContext; + + for (int i = 0; i < 10; i++) { + myInMemoryResourceMatcher.match("code" + TokenParamModifier.IN.getValue() + "=" + OBSERVATION_CODE_VALUE_SET_URI, myObservation, mySearchParams); + } + + verify(applicationContext, times(1)).getBean(IValidationSupport.class); + } + + @Test + @Order(2) + /* + Tests the case where the :in qualifier can not be supported because no bean implementing IValidationSupport was registered + */ + public void testUnsupportedIn() { + InMemoryMatchResult result = myInMemoryResourceMatcher.match("code" + TokenParamModifier.IN.getValue() + "=" + OBSERVATION_CODE_VALUE_SET_URI, myObservation, mySearchParams); + assertFalse(result.supported()); + assertEquals("Parameter: Reason: Qualified parameter not supported", result.getUnsupportedReason()); + } + + @Test + @Order(3) + public void testUnsupportedNotIn() { + InMemoryMatchResult result = myInMemoryResourceMatcher.match("code" + TokenParamModifier.NOT_IN.getValue() + "=" + OBSERVATION_CODE_VALUE_SET_URI, myObservation, mySearchParams); + assertFalse(result.supported()); + assertEquals("Parameter: Reason: Qualified parameter not supported", result.getUnsupportedReason()); + } + + private ResourceIndexedSearchParams extractSearchParams(Observation theObservation) { + ResourceIndexedSearchParams retval = new ResourceIndexedSearchParams(); + retval.myTokenParams.add(extractCodeTokenParam(theObservation)); + return retval; + } + + private ResourceIndexedSearchParamToken extractCodeTokenParam(Observation theObservation) { + Coding coding = theObservation.getCode().getCodingFirstRep(); + return new ResourceIndexedSearchParamToken(new PartitionSettings(), "Observation", "code", coding.getSystem(), coding.getCode()); + } + + @Configuration + public static class SpringConfig { + @Bean + InMemoryResourceMatcher inMemoryResourceMatcher() { + return new InMemoryResourceMatcher(); + } + + @Bean + MatchUrlService matchUrlService() { + return new MatchUrlService(); + } + + @Bean + FhirContext fhirContext() { + return FhirContext.forR5(); + } + + @Bean + ModelConfig modelConfig() { + return new ModelConfig(); + } + } + +} diff --git a/hapi-fhir-jpaserver-searchparam/src/test/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryResourceMatcherR5Test.java b/hapi-fhir-jpaserver-searchparam/src/test/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryResourceMatcherR5Test.java index 056c512bbbc..277cf083f1c 100644 --- a/hapi-fhir-jpaserver-searchparam/src/test/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryResourceMatcherR5Test.java +++ b/hapi-fhir-jpaserver-searchparam/src/test/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryResourceMatcherR5Test.java @@ -2,9 +2,11 @@ package ca.uhn.fhir.jpa.searchparam.matcher; import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.context.RuntimeSearchParam; +import ca.uhn.fhir.context.support.IValidationSupport; import ca.uhn.fhir.jpa.model.config.PartitionSettings; import ca.uhn.fhir.jpa.model.entity.ModelConfig; import ca.uhn.fhir.jpa.model.entity.ResourceIndexedSearchParamDate; +import ca.uhn.fhir.jpa.model.entity.ResourceIndexedSearchParamToken; import ca.uhn.fhir.jpa.searchparam.MatchUrlService; import ca.uhn.fhir.jpa.searchparam.extractor.ResourceIndexedSearchParams; import ca.uhn.fhir.model.primitive.BaseDateTimeDt; @@ -15,6 +17,7 @@ import ca.uhn.fhir.rest.param.TokenParamModifier; import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; import org.hl7.fhir.r5.model.BaseDateTimeType; import org.hl7.fhir.r5.model.CodeableConcept; +import org.hl7.fhir.r5.model.Coding; import org.hl7.fhir.r5.model.DateTimeType; import org.hl7.fhir.r5.model.Observation; import org.junit.jupiter.api.BeforeEach; @@ -27,6 +30,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.test.context.junit.jupiter.SpringExtension; +import javax.annotation.Nonnull; import java.time.Duration; import java.time.Instant; import java.time.ZonedDateTime; @@ -35,6 +39,10 @@ import java.util.Date; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(SpringExtension.class) @@ -42,6 +50,9 @@ public class InMemoryResourceMatcherR5Test { public static final String OBSERVATION_DATE = "1970-10-17"; public static final String OBSERVATION_DATETIME = OBSERVATION_DATE + "T01:00:00-08:30"; public static final String OBSERVATION_CODE = "MATCH"; + public static final String OBSERVATION_CODE_SYSTEM = "http://hl7.org/some-cs"; + public static final String OBSERVATION_CODE_DISPLAY = "Match"; + public static final String OBSERVATION_CODE_VALUE_SET_URI = "http://hl7.org/some-vs"; private static final String EARLY_DATE = "1965-08-09"; private static final String LATE_DATE = "2000-06-29"; private static final String EARLY_DATETIME = EARLY_DATE + "T12:00:00Z"; @@ -52,6 +63,8 @@ public class InMemoryResourceMatcherR5Test { @MockBean ISearchParamRegistry mySearchParamRegistry; + @MockBean + IValidationSupport myValidationSupport; @Autowired private InMemoryResourceMatcher myInMemoryResourceMatcher; private Observation myObservation; @@ -72,9 +85,10 @@ public class InMemoryResourceMatcherR5Test { myObservation.getMeta().setSource(TEST_SOURCE); myObservation.setEffective(new DateTimeType(OBSERVATION_DATETIME)); CodeableConcept codeableConcept = new CodeableConcept(); - codeableConcept.addCoding().setCode(OBSERVATION_CODE); + codeableConcept.addCoding().setCode(OBSERVATION_CODE) + .setSystem(OBSERVATION_CODE_SYSTEM).setDisplay(OBSERVATION_CODE_DISPLAY); myObservation.setCode(codeableConcept); - mySearchParams = extractDateSearchParam(myObservation); + mySearchParams = extractSearchParams(myObservation); } @Test @@ -132,6 +146,54 @@ public class InMemoryResourceMatcherR5Test { assertEquals("Parameter: Reason: Qualified parameter not supported", result.getUnsupportedReason()); } + @Test + public void testSupportedIn() { + IValidationSupport.CodeValidationResult codeValidationResult = new IValidationSupport.CodeValidationResult().setCode(OBSERVATION_CODE); + when(myValidationSupport.validateCode(any(), any(), any(), any(), any(), any())).thenReturn(codeValidationResult); + + InMemoryMatchResult result = myInMemoryResourceMatcher.match("code" + TokenParamModifier.IN.getValue() + "=" + OBSERVATION_CODE_VALUE_SET_URI, myObservation, mySearchParams); + assertTrue(result.supported()); + assertTrue(result.matched()); + + verify(myValidationSupport).validateCode(any(), any(), eq(OBSERVATION_CODE_SYSTEM), eq(OBSERVATION_CODE), isNull(), eq(OBSERVATION_CODE_VALUE_SET_URI)); + } + + @Test + public void testSupportedIn_NoMatch() { + IValidationSupport.CodeValidationResult codeValidationResult = new IValidationSupport.CodeValidationResult(); + when(myValidationSupport.validateCode(any(), any(), any(), any(), any(), any())).thenReturn(codeValidationResult); + + InMemoryMatchResult result = myInMemoryResourceMatcher.match("code" + TokenParamModifier.IN.getValue() + "=" + OBSERVATION_CODE_VALUE_SET_URI, myObservation, mySearchParams); + assertTrue(result.supported()); + assertFalse(result.matched()); + + verify(myValidationSupport).validateCode(any(), any(), eq(OBSERVATION_CODE_SYSTEM), eq(OBSERVATION_CODE), isNull(), eq(OBSERVATION_CODE_VALUE_SET_URI)); + } + + @Test + public void testSupportedNotIn() { + IValidationSupport.CodeValidationResult codeValidationResult = new IValidationSupport.CodeValidationResult(); + when(myValidationSupport.validateCode(any(), any(), any(), any(), any(), any())).thenReturn(codeValidationResult); + + InMemoryMatchResult result = myInMemoryResourceMatcher.match("code" + TokenParamModifier.NOT_IN.getValue() + "=" + OBSERVATION_CODE_VALUE_SET_URI, myObservation, mySearchParams); + assertTrue(result.supported()); + assertTrue(result.matched()); + + verify(myValidationSupport).validateCode(any(), any(), eq(OBSERVATION_CODE_SYSTEM), eq(OBSERVATION_CODE), isNull(), eq(OBSERVATION_CODE_VALUE_SET_URI)); + } + + @Test + public void testSupportedNotIn_NoMatch() { + IValidationSupport.CodeValidationResult codeValidationResult = new IValidationSupport.CodeValidationResult().setCode(OBSERVATION_CODE); + when(myValidationSupport.validateCode(any(), any(), any(), any(), any(), any())).thenReturn(codeValidationResult); + + InMemoryMatchResult result = myInMemoryResourceMatcher.match("code" + TokenParamModifier.NOT_IN.getValue() + "=" + OBSERVATION_CODE_VALUE_SET_URI, myObservation, mySearchParams); + assertTrue(result.supported()); + assertFalse(result.matched()); + + verify(myValidationSupport).validateCode(any(), any(), eq(OBSERVATION_CODE_SYSTEM), eq(OBSERVATION_CODE), isNull(), eq(OBSERVATION_CODE_VALUE_SET_URI)); + } + @Test public void testDateUnsupportedDateOps() { testDateUnsupportedDateOp(ParamPrefixEnum.APPROXIMATE); @@ -204,7 +266,7 @@ public class InMemoryResourceMatcherR5Test { Observation futureObservation = new Observation(); Instant nextWeek = Instant.now().plus(Duration.ofDays(7)); futureObservation.setEffective(new DateTimeType(Date.from(nextWeek))); - ResourceIndexedSearchParams searchParams = extractDateSearchParam(futureObservation); + ResourceIndexedSearchParams searchParams = extractSearchParams(futureObservation); InMemoryMatchResult result = myInMemoryResourceMatcher.match("date=gt" + BaseDateTimeDt.NOW_DATE_CONSTANT, futureObservation, searchParams); assertTrue(result.supported(), result.getUnsupportedReason()); @@ -218,7 +280,7 @@ public class InMemoryResourceMatcherR5Test { Observation futureObservation = new Observation(); Instant nextMinute = Instant.now().plus(Duration.ofMinutes(1)); futureObservation.setEffective(new DateTimeType(Date.from(nextMinute))); - ResourceIndexedSearchParams searchParams = extractDateSearchParam(futureObservation); + ResourceIndexedSearchParams searchParams = extractSearchParams(futureObservation); InMemoryMatchResult result = myInMemoryResourceMatcher.match("date=gt" + BaseDateTimeDt.NOW_DATE_CONSTANT, futureObservation, searchParams); assertTrue(result.supported(), result.getUnsupportedReason()); @@ -237,7 +299,7 @@ public class InMemoryResourceMatcherR5Test { Observation futureObservation = new Observation(); Instant nextWeek = Instant.now().plus(Duration.ofDays(7)); futureObservation.setEffective(new DateTimeType(Date.from(nextWeek))); - ResourceIndexedSearchParams searchParams = extractDateSearchParam(futureObservation); + ResourceIndexedSearchParams searchParams = extractSearchParams(futureObservation); InMemoryMatchResult result = myInMemoryResourceMatcher.match("date=gt" + BaseDateTimeDt.TODAY_DATE_CONSTANT, futureObservation, searchParams); assertTrue(result.supported(), result.getUnsupportedReason()); @@ -249,7 +311,7 @@ public class InMemoryResourceMatcherR5Test { Observation futureObservation = new Observation(); Instant nextWeek = Instant.now().minus(Duration.ofDays(1)); futureObservation.setEffective(new DateTimeType(Date.from(nextWeek))); - ResourceIndexedSearchParams searchParams = extractDateSearchParam(futureObservation); + ResourceIndexedSearchParams searchParams = extractSearchParams(futureObservation); InMemoryMatchResult result = myInMemoryResourceMatcher.match("date=gt" + BaseDateTimeDt.TODAY_DATE_CONSTANT, futureObservation, searchParams); assertTrue(result.supported(), result.getUnsupportedReason()); @@ -269,7 +331,7 @@ public class InMemoryResourceMatcherR5Test { } Instant nextMinute = now.toInstant().plus(Duration.ofMinutes(1)); futureObservation.setEffective(new DateTimeType(Date.from(nextMinute))); - ResourceIndexedSearchParams searchParams = extractDateSearchParam(futureObservation); + ResourceIndexedSearchParams searchParams = extractSearchParams(futureObservation); InMemoryMatchResult result = myInMemoryResourceMatcher.match("date=gt" + BaseDateTimeDt.TODAY_DATE_CONSTANT, futureObservation, searchParams); assertTrue(result.supported(), result.getUnsupportedReason()); @@ -280,11 +342,11 @@ public class InMemoryResourceMatcherR5Test { public void testInPeriod() { Observation insidePeriodObservation = new Observation(); insidePeriodObservation.setEffective(new DateTimeType("1985-01-01T00:00:00Z")); - ResourceIndexedSearchParams insidePeriodSearchParams = extractDateSearchParam(insidePeriodObservation); + ResourceIndexedSearchParams insidePeriodSearchParams = extractSearchParams(insidePeriodObservation); Observation outsidePeriodObservation = new Observation(); outsidePeriodObservation.setEffective(new DateTimeType("2010-01-01T00:00:00Z")); - ResourceIndexedSearchParams outsidePeriodSearchParams = extractDateSearchParam(outsidePeriodObservation); + ResourceIndexedSearchParams outsidePeriodSearchParams = extractSearchParams(outsidePeriodObservation); String search = "date=gt" + EARLY_DATE + "&date=le" + LATE_DATE; @@ -298,14 +360,24 @@ public class InMemoryResourceMatcherR5Test { } - private ResourceIndexedSearchParams extractDateSearchParam(Observation theObservation) { + private ResourceIndexedSearchParams extractSearchParams(Observation theObservation) { ResourceIndexedSearchParams retval = new ResourceIndexedSearchParams(); - BaseDateTimeType dateValue = (BaseDateTimeType) theObservation.getEffective(); - ResourceIndexedSearchParamDate dateParam = new ResourceIndexedSearchParamDate(new PartitionSettings(), "Patient", "date", dateValue.getValue(), dateValue.getValueAsString(), dateValue.getValue(), dateValue.getValueAsString(), dateValue.getValueAsString()); - retval.myDateParams.add(dateParam); + retval.myDateParams.add(extractEffectiveDateParam(theObservation)); + retval.myTokenParams.add(extractCodeTokenParam(theObservation)); return retval; } + @Nonnull + private ResourceIndexedSearchParamDate extractEffectiveDateParam(Observation theObservation) { + BaseDateTimeType dateValue = (BaseDateTimeType) theObservation.getEffective(); + return new ResourceIndexedSearchParamDate(new PartitionSettings(), "Patient", "date", dateValue.getValue(), dateValue.getValueAsString(), dateValue.getValue(), dateValue.getValueAsString(), dateValue.getValueAsString()); + } + + private ResourceIndexedSearchParamToken extractCodeTokenParam(Observation theObservation) { + Coding coding = theObservation.getCode().getCodingFirstRep(); + return new ResourceIndexedSearchParamToken(new PartitionSettings(), "Observation", "code", coding.getSystem(), coding.getCode()); + } + @Configuration public static class SpringConfig { @Bean diff --git a/hapi-fhir-jpaserver-searchparam/src/test/java/ca/uhn/fhir/jpa/searchparam/registry/SearchParamRegistryImplTest.java b/hapi-fhir-jpaserver-searchparam/src/test/java/ca/uhn/fhir/jpa/searchparam/registry/SearchParamRegistryImplTest.java index d77a8873c71..fe42ddeec06 100644 --- a/hapi-fhir-jpaserver-searchparam/src/test/java/ca/uhn/fhir/jpa/searchparam/registry/SearchParamRegistryImplTest.java +++ b/hapi-fhir-jpaserver-searchparam/src/test/java/ca/uhn/fhir/jpa/searchparam/registry/SearchParamRegistryImplTest.java @@ -2,6 +2,7 @@ package ca.uhn.fhir.jpa.searchparam.registry; import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.context.RuntimeSearchParam; +import ca.uhn.fhir.context.support.IValidationSupport; import ca.uhn.fhir.interceptor.api.IInterceptorService; import ca.uhn.fhir.jpa.cache.IResourceChangeListenerRegistry; import ca.uhn.fhir.jpa.cache.IResourceVersionSvc; @@ -143,6 +144,11 @@ public class SearchParamRegistryImplTest { return retval; } + @Bean + IValidationSupport validationSupport() { + return mock(IValidationSupport.class); + } + } @Nonnull