From 2b751ed312583574beea570f29ca1ed4059eccfa Mon Sep 17 00:00:00 2001 From: Simon Schweizer Date: Sun, 26 Jul 2026 09:34:37 +0200 Subject: [PATCH] add logical OR (comma separated values) for FHIR search parameters Implements the FHIR search OR join: comma separated values within a single search parameter are combined with a logical OR, while separately repeated parameters keep the existing logical AND semantics, per https://www.hl7.org/fhir/search.html#combining - SearchQuery groups the parameter instances created from one comma separated value; the SQL filter combines the members of a group with OR and the groups with AND, the in-memory (subscription) matcher does the same, and the search self link keeps OR values comma joined within one parameter - a comma escaped as \, is treated as a literal character (and unescaped); other escape sequences are left for the parameter type specific parsing - no change to the individual search parameter type implementations Co-Authored-By: Claude Opus 4.8 --- .../java/dev/dsf/fhir/search/SearchQuery.java | 109 +++++++++++-- .../integration/SearchOrIntegrationTest.java | 149 ++++++++++++++++++ .../fhir/search/SearchQueryOrValueTest.java | 93 +++++++++++ 3 files changed, 335 insertions(+), 16 deletions(-) create mode 100644 dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/integration/SearchOrIntegrationTest.java create mode 100644 dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/search/SearchQueryOrValueTest.java diff --git a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/search/SearchQuery.java b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/search/SearchQuery.java index 56a0f274e..c75699b7d 100755 --- a/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/search/SearchQuery.java +++ b/dsf-fhir/dsf-fhir-server/src/main/java/dev/dsf/fhir/search/SearchQuery.java @@ -24,6 +24,7 @@ import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -153,6 +154,13 @@ public SearchQuery build() private final Map revIncludeParameterFactoriesByValue = new HashMap<>(); private final List> searchParameters = new ArrayList<>(); + + // FHIR search: comma separated values of a single parameter are combined with a logical OR, values of separately + // repeated parameters with a logical AND. Each element of this list is one such OR-group (in the same order as the + // flat searchParameters list above); the SQL filter and the in-memory matcher combine the members of a group with + // OR and the groups with AND. See https://www.hl7.org/fhir/search.html#combining + private final List>> searchParameterOrGroups = new ArrayList<>(); + private final List sortParameters = new ArrayList<>(); private final List includeParameters = new ArrayList<>(); private final List revIncludeParameters = new ArrayList<>(); @@ -267,9 +275,20 @@ private String createFilterQuery(Map> queryParameters) .get(e.getKey()); if (queryParameterFactory != null) { - e.getValue().stream().filter(v -> v != null && !v.isBlank()) - .forEach(value -> searchParameters.add(queryParameterFactory.createQueryParameter() - .configure(errors, e.getKey(), value))); + e.getValue().stream().filter(v -> v != null && !v.isBlank()).forEach(value -> + { + // comma separated values within a single parameter are combined with a logical OR + List> orGroup = splitValuesForOr(value).stream() + .filter(orValue -> !orValue.isBlank()).map(orValue -> queryParameterFactory + .createQueryParameter().configure(errors, e.getKey(), orValue)) + .collect(Collectors.toList()); + + if (!orGroup.isEmpty()) + { + searchParameters.addAll(orGroup); + searchParameterOrGroups.add(orGroup); + } + }); } else { @@ -278,8 +297,7 @@ private String createFilterQuery(Map> queryParameters) } }); - Stream elements = searchParameters.stream().filter(SearchQueryParameter::isDefined) - .map(SearchQueryParameter::getFilterQuery); + Stream elements = searchParameterOrGroups.stream().map(this::toOrFilterQuery).filter(s -> !s.isEmpty()); if (identityFilter != null && !identityFilter.getFilterQuery().isEmpty()) elements = Stream.concat(Stream.of(identityFilter.getFilterQuery()), elements); @@ -287,6 +305,54 @@ private String createFilterQuery(Map> queryParameters) return elements.collect(Collectors.joining(" AND ")); } + private String toOrFilterQuery(List> orGroup) + { + List filters = orGroup.stream().filter(SearchQueryParameter::isDefined) + .map(SearchQueryParameter::getFilterQuery).collect(Collectors.toList()); + + if (filters.isEmpty()) + return ""; + else if (filters.size() == 1) + return filters.get(0); + else + return filters.stream().collect(Collectors.joining(" OR ", "(", ")")); + } + + /** + * Splits a single FHIR search parameter value into its comma separated OR-parts. Commas escaped as \, + * are treated as literal characters (and unescaped); other escape sequences are left untouched for the parameter + * type specific parsing. + * + * @param value + * not null + * @return the OR-parts, never empty + */ + static List splitValuesForOr(String value) + { + List values = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + + for (int i = 0; i < value.length(); i++) + { + char c = value.charAt(i); + if (c == '\\' && i + 1 < value.length() && value.charAt(i + 1) == ',') + { + current.append(','); + i++; + } + else if (c == ',') + { + values.add(current.toString()); + current.setLength(0); + } + else + current.append(c); + } + values.add(current.toString()); + + return values; + } + public List getUnsupportedQueryParameters() { return errors; @@ -471,15 +537,23 @@ public UriBuilder configureBundleUri(UriBuilder bundleUri) { Objects.requireNonNull(bundleUri, "bundleUri"); - searchParameters.stream().filter(SearchQueryParameter::isDefined) - .collect(Collectors.toMap(SearchQueryParameter::getBundleUriQueryParameterName, - p -> List.of(p.getBundleUriQueryParameterValue()), (v1, v2) -> - { - List list = new ArrayList<>(v1); - list.addAll(v2); - return list; - })) - .entrySet().stream().sorted(Comparator.comparing(Entry::getKey)) + // within an OR-group the values belong to the same parameter and are re-joined with a comma (literal commas + // re-escaped); separately repeated (AND) parameters with the same name stay as separate query parameter values + Map> valuesByName = new LinkedHashMap<>(); + for (List> group : searchParameterOrGroups) + { + List> defined = group.stream().filter(SearchQueryParameter::isDefined) + .collect(Collectors.toList()); + if (defined.isEmpty()) + continue; + + String name = defined.get(0).getBundleUriQueryParameterName(); + String value = defined.stream().map(SearchQueryParameter::getBundleUriQueryParameterValue) + .map(v -> v.replace(",", "\\,")).collect(Collectors.joining(",")); + valuesByName.computeIfAbsent(name, k -> new ArrayList<>()).add(value); + } + + valuesByName.entrySet().stream().sorted(Comparator.comparing(Entry::getKey)) .forEach(e -> bundleUri.replaceQueryParam(e.getKey(), e.getValue().toArray())); if (!sortParameters.isEmpty()) @@ -544,8 +618,11 @@ public boolean matches(Resource resource) if (resource == null || !getResourceType().isInstance(resource)) return false; - // returns true if no search parameters configured - return searchParameters.stream().filter(SearchQueryParameter::isDefined).allMatch(p -> p.matches(resource)); + // returns true if no search parameters configured; within an OR-group any member matching is enough (OR), + // across groups all groups must match (AND) + return searchParameterOrGroups.stream() + .map(group -> group.stream().filter(SearchQueryParameter::isDefined).collect(Collectors.toList())) + .filter(group -> !group.isEmpty()).allMatch(group -> group.stream().anyMatch(p -> p.matches(resource))); } @Override diff --git a/dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/integration/SearchOrIntegrationTest.java b/dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/integration/SearchOrIntegrationTest.java new file mode 100644 index 000000000..b92d47e89 --- /dev/null +++ b/dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/integration/SearchOrIntegrationTest.java @@ -0,0 +1,149 @@ +/* + * Copyright 2018-2025 Heilbronn University of Applied Sciences + * + * 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. + */ +package dev.dsf.fhir.integration; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.Endpoint; +import org.hl7.fhir.r4.model.Endpoint.EndpointStatus; +import org.hl7.fhir.r4.model.Organization; +import org.junit.Test; + +import dev.dsf.fhir.authentication.OrganizationProvider; + +public class SearchOrIntegrationTest extends AbstractIntegrationTest +{ + private static final String IDENTIFIER_SYSTEM = "http://dsf.dev/sid/endpoint-identifier"; + + private Endpoint createEndpoint(String identifierValue) + { + OrganizationProvider organizationProvider = getSpringWebApplicationContext() + .getBean(OrganizationProvider.class); + Organization localOrganization = organizationProvider.getLocalOrganization().get(); + + Endpoint endpoint = new Endpoint(); + endpoint.addIdentifier().setSystem(IDENTIFIER_SYSTEM).setValue(identifierValue); + endpoint.setStatus(EndpointStatus.ACTIVE); + endpoint.getConnectionType().setSystem("http://terminology.hl7.org/CodeSystem/endpoint-connection-type") + .setCode("hl7-fhir-rest"); + endpoint.setName("Endpoint " + identifierValue); + endpoint.getPayloadTypeFirstRep().getCodingFirstRep().setSystem("http://hl7.org/fhir/resource-types") + .setCode("Task"); + endpoint.addPayloadMimeType("application/fhir+json"); + endpoint.addPayloadMimeType("application/fhir+xml"); + endpoint.setAddress("https://foo-bar-baz.test.bla-bla.de/fhir/" + identifierValue); + endpoint.getManagingOrganization() + .setReferenceElement(localOrganization.getIdElement().toUnqualifiedVersionless()); + + getReadAccessHelper().addLocal(endpoint); + return endpoint; + } + + private Set identifierValues(Bundle bundle) + { + return bundle.getEntry().stream().map(Bundle.BundleEntryComponent::getResource) + .filter(r -> r instanceof Endpoint).map(r -> (Endpoint) r) + .map(e -> e.getIdentifierFirstRep().getValue()).collect(Collectors.toSet()); + } + + private String token(String value) + { + return IDENTIFIER_SYSTEM + "|" + value; + } + + @Test + public void testSearchWithOrOnTokenReturnsUnionOfMatches() throws Exception + { + getWebserviceClient().create(createEndpoint("ora")); + getWebserviceClient().create(createEndpoint("orb")); + getWebserviceClient().create(createEndpoint("orc")); + + // identifier=, -> a OR b + Bundle result = getWebserviceClient().search(Endpoint.class, + Map.of("identifier", List.of(token("ora") + "," + token("orb")))); + + assertNotNull(result); + assertEquals(2, result.getTotal()); + assertEquals(Set.of("ora", "orb"), identifierValues(result)); + } + + @Test + public void testSearchWithOrOnThreeValues() throws Exception + { + getWebserviceClient().create(createEndpoint("ora")); + getWebserviceClient().create(createEndpoint("orb")); + getWebserviceClient().create(createEndpoint("orc")); + getWebserviceClient().create(createEndpoint("ord")); + + Bundle result = getWebserviceClient().search(Endpoint.class, + Map.of("identifier", List.of(token("ora") + "," + token("orb") + "," + token("orc")))); + + assertEquals(3, result.getTotal()); + assertEquals(Set.of("ora", "orb", "orc"), identifierValues(result)); + } + + @Test + public void testSeparatelyRepeatedParameterStaysAnd() throws Exception + { + getWebserviceClient().create(createEndpoint("ora")); + getWebserviceClient().create(createEndpoint("orb")); + + // identifier=&identifier= -> a AND b; no endpoint carries both identifiers -> no match + Bundle result = getWebserviceClient().search(Endpoint.class, + Map.of("identifier", List.of(token("ora"), token("orb")))); + + assertEquals(0, result.getTotal()); + } + + @Test + public void testOrValueMatchingNoResource() throws Exception + { + getWebserviceClient().create(createEndpoint("ora")); + + // a OR (non existing) -> only a + Bundle result = getWebserviceClient().search(Endpoint.class, + Map.of("identifier", List.of(token("ora") + "," + token("doesnotexist")))); + + assertEquals(1, result.getTotal()); + assertEquals(Set.of("ora"), identifierValues(result)); + } + + @Test + public void testSelfLinkKeepsOrValuesCommaJoined() throws Exception + { + getWebserviceClient().create(createEndpoint("ora")); + getWebserviceClient().create(createEndpoint("orb")); + + Bundle result = getWebserviceClient().search(Endpoint.class, + Map.of("identifier", List.of(token("ora") + "," + token("orb")))); + + String selfLink = result.getLink(Bundle.LINK_SELF).getUrl(); + assertNotNull(selfLink); + // both OR values are kept within a single identifier parameter (comma separated), not two AND parameters + assertTrue("self link should contain both or values: " + selfLink, + selfLink.contains("ora") && selfLink.contains("orb")); + assertEquals("self link should contain exactly one identifier parameter: " + selfLink, 1, + selfLink.split("identifier=", -1).length - 1); + } +} diff --git a/dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/search/SearchQueryOrValueTest.java b/dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/search/SearchQueryOrValueTest.java new file mode 100644 index 000000000..ea5014f0f --- /dev/null +++ b/dsf-fhir/dsf-fhir-server/src/test/java/dev/dsf/fhir/search/SearchQueryOrValueTest.java @@ -0,0 +1,93 @@ +/* + * Copyright 2018-2025 Heilbronn University of Applied Sciences + * + * 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. + */ +package dev.dsf.fhir.search; + +import static org.junit.Assert.assertEquals; + +import java.util.List; + +import org.junit.Test; + +public class SearchQueryOrValueTest +{ + @Test + public void testSingleValue() + { + assertEquals(List.of("male"), SearchQuery.splitValuesForOr("male")); + } + + @Test + public void testTwoValues() + { + assertEquals(List.of("male", "female"), SearchQuery.splitValuesForOr("male,female")); + } + + @Test + public void testThreeValues() + { + assertEquals(List.of("a", "b", "c"), SearchQuery.splitValuesForOr("a,b,c")); + } + + @Test + public void testTokenValuesWithSystem() + { + assertEquals(List.of("http://loinc.org|1234-5", "http://loinc.org|6789-0"), + SearchQuery.splitValuesForOr("http://loinc.org|1234-5,http://loinc.org|6789-0")); + } + + @Test + public void testEscapedCommaIsLiteral() + { + assertEquals(List.of("a,b"), SearchQuery.splitValuesForOr("a\\,b")); + } + + @Test + public void testEscapedAndSeparatingComma() + { + assertEquals(List.of("a,b", "c"), SearchQuery.splitValuesForOr("a\\,b,c")); + } + + @Test + public void testOtherBackslashEscapesArePreserved() + { + // only \, is unescaped here; \| is left for the parameter type specific parsing + assertEquals(List.of("a\\|b"), SearchQuery.splitValuesForOr("a\\|b")); + } + + @Test + public void testEmptyValue() + { + assertEquals(List.of(""), SearchQuery.splitValuesForOr("")); + } + + @Test + public void testTrailingComma() + { + assertEquals(List.of("a", ""), SearchQuery.splitValuesForOr("a,")); + } + + @Test + public void testLeadingComma() + { + assertEquals(List.of("", "a"), SearchQuery.splitValuesForOr(",a")); + } + + @Test + public void testTrailingBackslash() + { + assertEquals(List.of("a\\"), SearchQuery.splitValuesForOr("a\\")); + } +}