Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -153,6 +154,13 @@ public SearchQuery<R> build()
private final Map<String, SearchQueryRevIncludeParameterFactory> revIncludeParameterFactoriesByValue = new HashMap<>();

private final List<SearchQueryParameter<R>> 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<List<SearchQueryParameter<R>>> searchParameterOrGroups = new ArrayList<>();

private final List<SearchQuerySortParameterConfiguration> sortParameters = new ArrayList<>();
private final List<SearchQueryIncludeParameterConfiguration> includeParameters = new ArrayList<>();
private final List<SearchQueryIncludeParameterConfiguration> revIncludeParameters = new ArrayList<>();
Expand Down Expand Up @@ -267,9 +275,20 @@ private String createFilterQuery(Map<String, List<String>> 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<SearchQueryParameter<R>> 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
{
Expand All @@ -278,15 +297,62 @@ private String createFilterQuery(Map<String, List<String>> queryParameters)
}
});

Stream<String> elements = searchParameters.stream().filter(SearchQueryParameter::isDefined)
.map(SearchQueryParameter::getFilterQuery);
Stream<String> elements = searchParameterOrGroups.stream().map(this::toOrFilterQuery).filter(s -> !s.isEmpty());

if (identityFilter != null && !identityFilter.getFilterQuery().isEmpty())
elements = Stream.concat(Stream.of(identityFilter.getFilterQuery()), elements);

return elements.collect(Collectors.joining(" AND "));
}

private String toOrFilterQuery(List<SearchQueryParameter<R>> orGroup)
{
List<String> 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 <code>\,</code>
* are treated as literal characters (and unescaped); other escape sequences are left untouched for the parameter
* type specific parsing.
*
* @param value
* not <code>null</code>
* @return the OR-parts, never empty
*/
static List<String> splitValuesForOr(String value)
{
List<String> 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<SearchQueryParameterError> getUnsupportedQueryParameters()
{
return errors;
Expand Down Expand Up @@ -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<String> 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<String, List<String>> valuesByName = new LinkedHashMap<>();
for (List<SearchQueryParameter<R>> group : searchParameterOrGroups)
{
List<SearchQueryParameter<R>> 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())
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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>,<b> -> 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=<a>&identifier=<b> -> 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);
}
}
Loading
Loading