Skip to content

Commit ac4aa1e

Browse files
Luke deGruchyLong Ma
authored andcommitted
Fix queries with chained sort with Lucene by checking supported SortSpecs (#5958)
* First commit with very rough solution. * Solidify solutions for both requirements. Add new tests. Enhance others. * Spotless. * Add new chained sort spec algorithm. Add new Msg.codes. Finalize tests. Update docs. Add changelog.
1 parent 26c5d80 commit ac4aa1e

File tree

10 files changed

+263
-11
lines changed

10 files changed

+263
-11
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
type: fix
3+
issue: 5960
4+
title: "Previously, queries with chained would fail to sort correctly with lucene and full text searches enabled.
5+
This has been fixed."

hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/docs/server_jpa/elastic.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,10 @@ This can cause issues, particularly in unit tests where data is being examined s
115115

116116
You can force synchronous writing to them in HAPI FHIR JPA by setting the Hibernate Search [synchronization strategy](https://docs.jboss.org/hibernate/stable/search/reference/en-US/html_single/#mapper-orm-indexing-automatic-synchronization). This setting is internally setting the ElasticSearch [refresh=wait_for](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-refresh.html) option. Be warned that this will have a negative impact on overall performance. THE HAPI FHIR TEAM has not tried to quantify this impact but the ElasticSearch docs seem to make a fairly big deal about it.
117117

118+
# Sorting
119+
120+
It is possible to sort with Lucene indexing and full text searching enabled. For example, this will work: `Practitioner?_sort=family`.
121+
122+
Also, chained sorts will work: `PractitionerRole?_sort=practitioner.family`.
123+
124+
However, chained sorting _combined_ with full text searches will fail. For example, this query will fail with an error: `PractitionerRole?_text=blah&_sort=practitioner.family`

hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/FulltextSearchSvcImpl.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,11 @@ public boolean supportsAllOf(SearchParameterMap theParams) {
497497
return myAdvancedIndexQueryBuilder.isSupportsAllOf(theParams);
498498
}
499499

500+
@Override
501+
public boolean supportsAllSortTerms(String theResourceType, SearchParameterMap theParams) {
502+
return myExtendedFulltextSortHelper.supportsAllSortTerms(theResourceType, theParams);
503+
}
504+
500505
private void dispatchEvent(IHSearchEventListener.HSearchEventType theEventType) {
501506
if (myHSearchEventListener != null) {
502507
myHSearchEventListener.hsearchEvent(theEventType);

hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/IFulltextSearchSvc.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,13 @@ List<IBaseResource> searchForResources(
120120
* @param theGivenIds The list of IDs for the given document type. Note that while this is a List<Object>, the type must match the type of the `@Id` field on the given class.
121121
*/
122122
void deleteIndexedDocumentsByTypeAndId(Class theClazz, List<Object> theGivenIds);
123+
124+
/**
125+
* Given a resource type and a {@link SearchParameterMap}, return true only if all sort terms are supported.
126+
*
127+
* @param theResourceName The resource type for the query.
128+
* @param theParams The {@link SearchParameterMap} being searched with.
129+
* @return true if all sort terms are supported, false otherwise.
130+
*/
131+
boolean supportsAllSortTerms(String theResourceName, SearchParameterMap theParams);
123132
}

hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/HSearchSortHelperImpl.java

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
package ca.uhn.fhir.jpa.dao.search;
2121

2222
import ca.uhn.fhir.context.RuntimeSearchParam;
23+
import ca.uhn.fhir.i18n.Msg;
24+
import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
2325
import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum;
2426
import ca.uhn.fhir.rest.api.SortOrderEnum;
2527
import ca.uhn.fhir.rest.api.SortSpec;
@@ -94,6 +96,19 @@ public SortFinalStep getSortClauses(
9496
return sortStep;
9597
}
9698

99+
@Override
100+
public boolean supportsAllSortTerms(String theResourceType, SearchParameterMap theParams) {
101+
for (SortSpec sortSpec : theParams.getAllChainsInOrder()) {
102+
final Optional<RestSearchParameterTypeEnum> paramTypeOpt =
103+
getParamType(theResourceType, sortSpec.getParamName());
104+
if (paramTypeOpt.isEmpty()) {
105+
return false;
106+
}
107+
}
108+
109+
return true;
110+
}
111+
97112
/**
98113
* Builds sort clauses for the received SortSpec by
99114
* _ finding out the corresponding RestSearchParameterTypeEnum for the parameter
@@ -104,13 +119,12 @@ public SortFinalStep getSortClauses(
104119
Optional<SortFinalStep> getSortClause(SearchSortFactory theF, SortSpec theSortSpec, String theResourceType) {
105120
Optional<RestSearchParameterTypeEnum> paramTypeOpt = getParamType(theResourceType, theSortSpec.getParamName());
106121
if (paramTypeOpt.isEmpty()) {
107-
ourLog.warn("Sprt parameter type couldn't be determined for parameter: " + theSortSpec.getParamName()
108-
+ ". Result will not be properly sorted");
109-
return Optional.empty();
122+
throw new IllegalArgumentException(
123+
Msg.code(2523) + "Invalid sort specification: " + theSortSpec.getParamName());
110124
}
111125
List<String> paramFieldNameList = getSortPropertyList(paramTypeOpt.get(), theSortSpec.getParamName());
112126
if (paramFieldNameList.isEmpty()) {
113-
ourLog.warn("Unable to sort by parameter '" + theSortSpec.getParamName() + "'. Sort parameter ignored.");
127+
ourLog.warn("Unable to sort by parameter '{}' . Sort parameter ignored.", theSortSpec.getParamName());
114128
return Optional.empty();
115129
}
116130

@@ -128,6 +142,7 @@ Optional<SortFinalStep> getSortClause(SearchSortFactory theF, SortSpec theSortSp
128142
sortFinalStep.add(sortStep.missing().last());
129143
}
130144

145+
// regular sorting is supported
131146
return Optional.of(sortFinalStep);
132147
}
133148

hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/search/IHSearchSortHelper.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
*/
2020
package ca.uhn.fhir.jpa.dao.search;
2121

22+
import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
2223
import ca.uhn.fhir.rest.api.SortSpec;
2324
import org.hibernate.search.engine.search.sort.dsl.SearchSortFactory;
2425
import org.hibernate.search.engine.search.sort.dsl.SortFinalStep;
@@ -29,4 +30,13 @@
2930
public interface IHSearchSortHelper {
3031

3132
SortFinalStep getSortClauses(SearchSortFactory theSortFactory, SortSpec theSort, String theResourceType);
33+
34+
/**
35+
* Given a resource type and a {@link SearchParameterMap}, return true only if all sort terms are supported.
36+
*
37+
* @param theResourceType The resource type for the query.
38+
* @param theParams The {@link SearchParameterMap} being searched with.
39+
* @return true if all sort terms are supported, false otherwise.
40+
*/
41+
boolean supportsAllSortTerms(String theResourceType, SearchParameterMap theParams);
3242
}

hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/builder/SearchBuilder.java

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,14 +466,23 @@ private boolean checkUseHibernateSearch() {
466466
if (!fulltextEnabled) {
467467
failIfUsed(Constants.PARAM_TEXT);
468468
failIfUsed(Constants.PARAM_CONTENT);
469+
} else {
470+
for (SortSpec sortSpec : myParams.getAllChainsInOrder()) {
471+
final String paramName = sortSpec.getParamName();
472+
if (paramName.contains(".")) {
473+
failIfUsedWithChainedSort(Constants.PARAM_TEXT);
474+
failIfUsedWithChainedSort(Constants.PARAM_CONTENT);
475+
}
476+
}
469477
}
470478

471479
// someday we'll want a query planner to figure out if we _should_ or _must_ use the ft index, not just if we
472480
// can.
473481
return fulltextEnabled
474482
&& myParams != null
475483
&& myParams.getSearchContainedMode() == SearchContainedModeEnum.FALSE
476-
&& myFulltextSearchSvc.supportsSomeOf(myParams);
484+
&& myFulltextSearchSvc.supportsSomeOf(myParams)
485+
&& myFulltextSearchSvc.supportsAllSortTerms(myResourceName, myParams);
477486
}
478487

479488
private void failIfUsed(String theParamName) {
@@ -483,6 +492,14 @@ private void failIfUsed(String theParamName) {
483492
}
484493
}
485494

495+
private void failIfUsedWithChainedSort(String theParamName) {
496+
if (myParams.containsKey(theParamName)) {
497+
throw new InvalidRequestException(Msg.code(2524)
498+
+ "Fulltext search combined with chained sorts are not supported, can not process parameter: "
499+
+ theParamName);
500+
}
501+
}
502+
486503
private List<JpaPid> executeLastNAgainstIndex(Integer theMaximumResults) {
487504
// Can we use our hibernate search generated index on resource to support lastN?:
488505
if (myStorageSettings.isAdvancedHSearchIndexing()) {

hapi-fhir-jpaserver-searchparam/src/main/java/ca/uhn/fhir/jpa/searchparam/SearchParameterMap.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -894,4 +894,13 @@ public int compare(IQueryParameterType theO1, IQueryParameterType theO2) {
894894
return SearchParameterMap.compare(myCtx, theO1, theO2);
895895
}
896896
}
897+
898+
public List<SortSpec> getAllChainsInOrder() {
899+
final List<SortSpec> allChainsInOrder = new ArrayList<>();
900+
for (SortSpec sortSpec = getSort(); sortSpec != null; sortSpec = sortSpec.getChain()) {
901+
allChainsInOrder.add(sortSpec);
902+
}
903+
904+
return Collections.unmodifiableList(allChainsInOrder);
905+
}
897906
}

hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/provider/r4/ResourceProviderR4SearchVariousScenariosTest.java

Lines changed: 107 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
import ca.uhn.fhir.jpa.provider.BaseResourceProviderR4Test;
44
import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
55
import ca.uhn.fhir.rest.api.server.IBundleProvider;
6+
import ca.uhn.fhir.rest.api.server.SystemRequestDetails;
7+
import ca.uhn.fhir.rest.client.api.IGenericClient;
68
import ca.uhn.fhir.rest.param.HasParam;
9+
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
10+
import org.hamcrest.MatcherAssert;
711
import org.hl7.fhir.instance.model.api.IIdType;
812
import org.hl7.fhir.r4.model.Bundle;
913
import org.hl7.fhir.r4.model.CarePlan;
@@ -12,20 +16,28 @@
1216
import org.hl7.fhir.r4.model.Enumerations;
1317
import org.hl7.fhir.r4.model.ExplanationOfBenefit;
1418
import org.hl7.fhir.r4.model.Group;
19+
import org.hl7.fhir.r4.model.HumanName;
20+
import org.hl7.fhir.r4.model.IdType;
1521
import org.hl7.fhir.r4.model.ListResource;
1622
import org.hl7.fhir.r4.model.Observation;
1723
import org.hl7.fhir.r4.model.Organization;
1824
import org.hl7.fhir.r4.model.Patient;
1925
import org.hl7.fhir.r4.model.Practitioner;
26+
import org.hl7.fhir.r4.model.PractitionerRole;
2027
import org.hl7.fhir.r4.model.Reference;
28+
import org.hl7.fhir.r4.model.Resource;
2129
import org.hl7.fhir.r4.model.SearchParameter;
2230
import org.junit.jupiter.api.BeforeEach;
2331
import org.junit.jupiter.api.Nested;
2432
import org.junit.jupiter.api.Test;
2533
import org.junit.jupiter.params.ParameterizedTest;
2634
import org.junit.jupiter.params.provider.ValueSource;
2735

36+
import java.util.List;
37+
38+
import static org.hamcrest.Matchers.contains;
2839
import static org.junit.jupiter.api.Assertions.assertFalse;
40+
import static org.junit.jupiter.api.Assertions.assertThrows;
2941

3042

3143
public class ResourceProviderR4SearchVariousScenariosTest extends BaseResourceProviderR4Test {
@@ -352,15 +364,104 @@ void complexQueryFromPractitioner(String theQueryString) {
352364
}
353365
}
354366

367+
@Nested
368+
class Sorting {
369+
private IdType myPraId1;
370+
private IdType myPraId2;
371+
private IdType myPraId3;
372+
private IdType myPraRoleId1;
373+
private IdType myPraRoleId2;
374+
private IdType myPraRoleId3;
375+
376+
@BeforeEach
377+
void beforeEach() {
378+
myPraId1 = createPractitioner("pra1", "C_Family");
379+
myPraId2 = createPractitioner("pra2", "A_Family");
380+
myPraId3 = createPractitioner("pra3", "B_Family");
381+
382+
myPraRoleId1 = createPractitionerRole("praRole1", myPraId1);
383+
myPraRoleId2 = createPractitionerRole("praRole2", myPraId2);
384+
myPraRoleId3 = createPractitionerRole("praRole3", myPraId3);
385+
}
386+
387+
@Test
388+
void testRegularSortAscendingWorks() {
389+
runAndAssert("regular sort ascending works", "Practitioner?_sort=family", myPraId2.getIdPart(), myPraId3.getIdPart(), myPraId1.getIdPart());
390+
}
391+
392+
@Test
393+
void testRegularSortDescendingWorks() {
394+
runAndAssert("regular sort descending works", "Practitioner?_sort=-family", myPraId1.getIdPart(), myPraId3.getIdPart(), myPraId2.getIdPart());
395+
}
396+
397+
@Test
398+
void testChainedSortWorks() {
399+
runAndAssert("chain sort works", "PractitionerRole?_sort=practitioner.family", myPraRoleId2.getIdPart(), myPraRoleId3.getIdPart(), myPraRoleId1.getIdPart());
400+
}
401+
402+
@ParameterizedTest
403+
@ValueSource(strings = {
404+
"PractitionerRole?_text=blahblah&_sort=practitioner.family",
405+
"PractitionerRole?_content=blahblah&_sort=practitioner.family"
406+
})
407+
void unsupportedSearchesWithChainedSorts(String theQueryString) {
408+
runAndAssertThrows(InvalidRequestException.class, theQueryString);
409+
}
410+
411+
private IdType createPractitioner(String theId, String theFamilyName) {
412+
final Practitioner practitioner = (Practitioner) new Practitioner()
413+
.setActive(true)
414+
.setName(List.of(new HumanName().setFamily(theFamilyName)))
415+
.setId(theId);
416+
417+
myPractitionerDao.update(practitioner, new SystemRequestDetails());
418+
419+
return practitioner.getIdElement().toUnqualifiedVersionless();
420+
}
421+
422+
private IdType createPractitionerRole(String theId, IdType thePractitionerId) {
423+
final PractitionerRole practitionerRole = (PractitionerRole) new PractitionerRole()
424+
.setActive(true)
425+
.setPractitioner(new Reference(thePractitionerId.asStringValue()))
426+
.setId(theId);
427+
428+
myPractitionerRoleDao.update(practitionerRole, new SystemRequestDetails());
429+
430+
return practitionerRole.getIdElement().toUnqualifiedVersionless();
431+
}
432+
}
433+
434+
private void runAndAssert(String theReason, String theQueryString, String... theExpectedIdsInOrder) {
435+
final Bundle outcome = runQueryAndGetBundle(theQueryString, myClient);
436+
437+
assertFalse(outcome.getEntry().isEmpty());
438+
439+
final List<String> actualIdsInOrder = outcome.getEntry()
440+
.stream()
441+
.map(Bundle.BundleEntryComponent::getResource)
442+
.map(Resource::getIdPart)
443+
.toList();
444+
445+
MatcherAssert.assertThat(theReason, actualIdsInOrder, contains(theExpectedIdsInOrder));
446+
}
447+
355448
private void runAndAssert(String theQueryString) {
356-
ourLog.info("queryString:\n{}", theQueryString);
449+
ourLog.debug("queryString:\n{}", theQueryString);
357450

358-
final Bundle outcome = myClient.search()
359-
.byUrl(theQueryString)
360-
.returnBundle(Bundle.class)
361-
.execute();
451+
final Bundle outcome = runQueryAndGetBundle(theQueryString, myClient);
362452

363453
assertFalse(outcome.getEntry().isEmpty());
364-
ourLog.info("result:\n{}", theQueryString);
454+
ourLog.debug("result:\n{}", theQueryString);
455+
}
456+
457+
private void runAndAssertThrows(Class<? extends Exception> theExceptedException, String theQueryString) {
458+
assertThrows(theExceptedException, () -> runQueryAndGetBundle(theQueryString, myClient));
459+
}
460+
461+
private static Bundle runQueryAndGetBundle(String theTheQueryString, IGenericClient theClient) {
462+
return theClient.search()
463+
.byUrl(theTheQueryString)
464+
.returnBundle(Bundle.class)
465+
.execute();
365466
}
366467
}

0 commit comments

Comments
 (0)