diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_4_0/6070-add-offset-support-for-everything-operation-synchronous-search.yaml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_4_0/6070-add-offset-support-for-everything-operation-synchronous-search.yaml new file mode 100644 index 00000000000..c11f238d3f3 --- /dev/null +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_4_0/6070-add-offset-support-for-everything-operation-synchronous-search.yaml @@ -0,0 +1,5 @@ +--- +type: add +issue: 6070 +jira: SMILE-8503 +title: "Added paging support for `$everything` operation in synchronous search mode." diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/JpaResourceDaoPatient.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/JpaResourceDaoPatient.java index 806674cb0d7..3ccc16fdd5a 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/JpaResourceDaoPatient.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/JpaResourceDaoPatient.java @@ -19,7 +19,6 @@ */ package ca.uhn.fhir.jpa.dao; -import ca.uhn.fhir.i18n.Msg; import ca.uhn.fhir.interceptor.model.RequestPartitionId; import ca.uhn.fhir.jpa.api.dao.IFhirResourceDaoPatient; import ca.uhn.fhir.jpa.api.dao.PatientEverythingParameters; @@ -73,8 +72,7 @@ public class JpaResourceDaoPatient extends BaseHapiFhir paramMap.setCount(theCount.getValue()); } if (theOffset != null) { - throw new IllegalArgumentException( - Msg.code(1106) + "Everything operation does not support offset searching"); + paramMap.setOffset(theOffset.getValue()); } if (theContent != null) { paramMap.add(Constants.PARAM_CONTENT, theContent); diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/builder/QueryStack.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/builder/QueryStack.java index 99ff10640b9..ea44a1fdfef 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/builder/QueryStack.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/builder/QueryStack.java @@ -1477,6 +1477,11 @@ public class QueryStack { mySqlBuilder.getSelect().addGroupings(firstPredicateBuilder.getResourceIdColumn()); } + public void addOrdering() { + BaseJoiningPredicateBuilder firstPredicateBuilder = mySqlBuilder.getOrCreateFirstPredicateBuilder(); + mySqlBuilder.getSelect().addOrderings(firstPredicateBuilder.getResourceIdColumn()); + } + public Condition createPredicateReferenceForEmbeddedChainedSearchResource( @Nullable DbColumn theSourceJoinColumn, String theResourceName, diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/builder/SearchBuilder.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/builder/SearchBuilder.java index 6f916e4dbc8..943d3f9abb9 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/builder/SearchBuilder.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/search/builder/SearchBuilder.java @@ -571,8 +571,7 @@ public class SearchBuilder implements ISearchBuilder { * * @param theTargetPids */ - private void extractTargetPidsFromIdParams( - HashSet theTargetPids, List theSearchQueryExecutors) { + private void extractTargetPidsFromIdParams(Set theTargetPids) { // get all the IQueryParameterType objects // for _id -> these should all be StringParam values HashSet ids = new HashSet<>(); @@ -601,10 +600,6 @@ public class SearchBuilder implements ISearchBuilder { for (JpaPid pid : idToPid.values()) { theTargetPids.add(pid.getId()); } - - // add the target pids to our executors as the first - // results iterator to go through - theSearchQueryExecutors.add(new ResolvedSearchQueryExecutor(new ArrayList<>(theTargetPids))); } private void createChunkedQuery( @@ -616,13 +611,29 @@ public class SearchBuilder implements ISearchBuilder { RequestDetails theRequest, List thePidList, List theSearchQueryExecutors) { - String sqlBuilderResourceName = myParams.getEverythingMode() == null ? myResourceName : null; + if (myParams.getEverythingMode() != null) { + createChunkedQueryForEverythingSearch( + theParams, theOffset, theMaximumResults, theCountOnlyFlag, thePidList, theSearchQueryExecutors); + } else { + createChunkedQueryNormalSearch( + theParams, sort, theOffset, theCountOnlyFlag, theRequest, thePidList, theSearchQueryExecutors); + } + } + + private void createChunkedQueryNormalSearch( + SearchParameterMap theParams, + SortSpec sort, + Integer theOffset, + boolean theCountOnlyFlag, + RequestDetails theRequest, + List thePidList, + List theSearchQueryExecutors) { SearchQueryBuilder sqlBuilder = new SearchQueryBuilder( myContext, myStorageSettings, myPartitionSettings, myRequestPartitionId, - sqlBuilderResourceName, + myResourceName, mySqlBuilderFactory, myDialectProvider, theCountOnlyFlag); @@ -640,67 +651,22 @@ public class SearchBuilder implements ISearchBuilder { } } - JdbcTemplate jdbcTemplate = new JdbcTemplate(myEntityManagerFactory.getDataSource()); - jdbcTemplate.setFetchSize(myFetchSize); - if (theMaximumResults != null) { - jdbcTemplate.setMaxRows(theMaximumResults); + /* + * If we're doing a filter, always use the resource table as the root - This avoids the possibility of + * specific filters with ORs as their root from working around the natural resource type / deletion + * status / partition IDs built into queries. + */ + if (theParams.containsKey(Constants.PARAM_FILTER)) { + Condition partitionIdPredicate = sqlBuilder + .getOrCreateResourceTablePredicateBuilder() + .createPartitionIdPredicate(myRequestPartitionId); + if (partitionIdPredicate != null) { + sqlBuilder.addPredicate(partitionIdPredicate); + } } - if (myParams.getEverythingMode() != null) { - HashSet targetPids = new HashSet<>(); - if (myParams.get(IAnyResource.SP_RES_ID) != null) { - // will add an initial search executor for - // _id params - extractTargetPidsFromIdParams(targetPids, theSearchQueryExecutors); - } else { - // For Everything queries, we make the query root by the ResourceLink table, since this query - // is basically a reverse-include search. For type/Everything (as opposed to instance/Everything) - // the one problem with this approach is that it doesn't catch Patients that have absolutely - // nothing linked to them. So we do one additional query to make sure we catch those too. - SearchQueryBuilder fetchPidsSqlBuilder = new SearchQueryBuilder( - myContext, - myStorageSettings, - myPartitionSettings, - myRequestPartitionId, - myResourceName, - mySqlBuilderFactory, - myDialectProvider, - theCountOnlyFlag); - GeneratedSql allTargetsSql = fetchPidsSqlBuilder.generate(theOffset, myMaxResultsToFetch); - String sql = allTargetsSql.getSql(); - Object[] args = allTargetsSql.getBindVariables().toArray(new Object[0]); - - List output = jdbcTemplate.query(sql, args, new SingleColumnRowMapper<>(Long.class)); - - // we add a search executor to fetch unlinked patients first - theSearchQueryExecutors.add(new ResolvedSearchQueryExecutor(output)); - } - - List typeSourceResources = new ArrayList<>(); - if (myParams.get(Constants.PARAM_TYPE) != null) { - typeSourceResources.addAll(extractTypeSourceResourcesFromParams()); - } - - queryStack3.addPredicateEverythingOperation( - myResourceName, typeSourceResources, targetPids.toArray(new Long[0])); - } else { - /* - * If we're doing a filter, always use the resource table as the root - This avoids the possibility of - * specific filters with ORs as their root from working around the natural resource type / deletion - * status / partition IDs built into queries. - */ - if (theParams.containsKey(Constants.PARAM_FILTER)) { - Condition partitionIdPredicate = sqlBuilder - .getOrCreateResourceTablePredicateBuilder() - .createPartitionIdPredicate(myRequestPartitionId); - if (partitionIdPredicate != null) { - sqlBuilder.addPredicate(partitionIdPredicate); - } - } - - // Normal search - searchForIdsWithAndOr(sqlBuilder, queryStack3, myParams, theRequest); - } + // Normal search + searchForIdsWithAndOr(sqlBuilder, queryStack3, myParams, theRequest); // If we haven't added any predicates yet, we're doing a search for all resources. Make sure we add the // partition ID predicate in that case. @@ -714,16 +680,10 @@ public class SearchBuilder implements ISearchBuilder { } // Add PID list predicate for full text search and/or lastn operation - if (thePidList != null && thePidList.size() > 0) { - sqlBuilder.addResourceIdsPredicate(thePidList); - } + addPidListPredicate(thePidList, sqlBuilder); // Last updated - DateRangeParam lu = myParams.getLastUpdated(); - if (lu != null && !lu.isEmpty()) { - Condition lastUpdatedPredicates = sqlBuilder.addPredicateLastUpdated(lu); - sqlBuilder.addPredicate(lastUpdatedPredicates); - } + addLastUpdatePredicate(sqlBuilder); /* * Exclude the pids already in the previous iterator. This is an optimization, as opposed @@ -770,6 +730,11 @@ public class SearchBuilder implements ISearchBuilder { /* * Now perform the search */ + executeSearch(theOffset, theSearchQueryExecutors, sqlBuilder); + } + + private void executeSearch( + Integer theOffset, List theSearchQueryExecutors, SearchQueryBuilder sqlBuilder) { GeneratedSql generatedSql = sqlBuilder.generate(theOffset, myMaxResultsToFetch); if (!generatedSql.isMatchNothing()) { SearchQueryExecutor executor = @@ -778,6 +743,111 @@ public class SearchBuilder implements ISearchBuilder { } } + private void createChunkedQueryForEverythingSearch( + SearchParameterMap theParams, + Integer theOffset, + Integer theMaximumResults, + boolean theCountOnlyFlag, + List thePidList, + List theSearchQueryExecutors) { + + SearchQueryBuilder sqlBuilder = new SearchQueryBuilder( + myContext, + myStorageSettings, + myPartitionSettings, + myRequestPartitionId, + null, + mySqlBuilderFactory, + myDialectProvider, + theCountOnlyFlag); + + QueryStack queryStack3 = new QueryStack( + theParams, myStorageSettings, myContext, sqlBuilder, mySearchParamRegistry, myPartitionSettings); + + JdbcTemplate jdbcTemplate = initializeJdbcTemplate(theMaximumResults); + + Set targetPids = new HashSet<>(); + if (myParams.get(IAnyResource.SP_RES_ID) != null) { + + extractTargetPidsFromIdParams(targetPids); + + // add the target pids to our executors as the first + // results iterator to go through + theSearchQueryExecutors.add(new ResolvedSearchQueryExecutor(new ArrayList<>(targetPids))); + } else { + // For Everything queries, we make the query root by the ResourceLink table, since this query + // is basically a reverse-include search. For type/Everything (as opposed to instance/Everything) + // the one problem with this approach is that it doesn't catch Patients that have absolutely + // nothing linked to them. So we do one additional query to make sure we catch those too. + SearchQueryBuilder fetchPidsSqlBuilder = new SearchQueryBuilder( + myContext, + myStorageSettings, + myPartitionSettings, + myRequestPartitionId, + myResourceName, + mySqlBuilderFactory, + myDialectProvider, + theCountOnlyFlag); + GeneratedSql allTargetsSql = fetchPidsSqlBuilder.generate(theOffset, myMaxResultsToFetch); + String sql = allTargetsSql.getSql(); + Object[] args = allTargetsSql.getBindVariables().toArray(new Object[0]); + + List output = jdbcTemplate.query(sql, args, new SingleColumnRowMapper<>(Long.class)); + + // we add a search executor to fetch unlinked patients first + theSearchQueryExecutors.add(new ResolvedSearchQueryExecutor(output)); + } + + List typeSourceResources = new ArrayList<>(); + if (myParams.get(Constants.PARAM_TYPE) != null) { + typeSourceResources.addAll(extractTypeSourceResourcesFromParams()); + } + + queryStack3.addPredicateEverythingOperation( + myResourceName, typeSourceResources, targetPids.toArray(new Long[0])); + + // Add PID list predicate for full text search and/or lastn operation + addPidListPredicate(thePidList, sqlBuilder); + + /* + * If offset is present, we want deduplicate the results by using GROUP BY + * ORDER BY is required to make sure we return unique results for each page + */ + if (theOffset != null) { + queryStack3.addGrouping(); + queryStack3.addOrdering(); + queryStack3.setUseAggregate(true); + } + + /* + * Now perform the search + */ + executeSearch(theOffset, theSearchQueryExecutors, sqlBuilder); + } + + private void addPidListPredicate(List thePidList, SearchQueryBuilder theSqlBuilder) { + if (thePidList != null && !thePidList.isEmpty()) { + theSqlBuilder.addResourceIdsPredicate(thePidList); + } + } + + private void addLastUpdatePredicate(SearchQueryBuilder theSqlBuilder) { + DateRangeParam lu = myParams.getLastUpdated(); + if (lu != null && !lu.isEmpty()) { + Condition lastUpdatedPredicates = theSqlBuilder.addPredicateLastUpdated(lu); + theSqlBuilder.addPredicate(lastUpdatedPredicates); + } + } + + private JdbcTemplate initializeJdbcTemplate(Integer theMaximumResults) { + JdbcTemplate jdbcTemplate = new JdbcTemplate(myEntityManagerFactory.getDataSource()); + jdbcTemplate.setFetchSize(myFetchSize); + if (theMaximumResults != null) { + jdbcTemplate.setMaxRows(theMaximumResults); + } + return jdbcTemplate; + } + private Collection extractTypeSourceResourcesFromParams() { List> listOfList = myParams.get(Constants.PARAM_TYPE); @@ -2273,13 +2343,7 @@ public class SearchBuilder implements ISearchBuilder { // If we don't have a query yet, create one if (myResultsIterator == null) { if (myMaxResultsToFetch == null) { - if (myParams.getLoadSynchronousUpTo() != null) { - myMaxResultsToFetch = myParams.getLoadSynchronousUpTo(); - } else if (myParams.getOffset() != null && myParams.getCount() != null) { - myMaxResultsToFetch = myParams.getCount(); - } else { - myMaxResultsToFetch = myStorageSettings.getFetchSizeDefaultMaximum(); - } + myMaxResultsToFetch = calculateMaxResultsToFetch(); } /* @@ -2305,19 +2369,12 @@ public class SearchBuilder implements ISearchBuilder { Long nextLong = myResultsIterator.next(); if (myHavePerfTraceFoundIdHook) { - HookParams params = new HookParams() - .add(Integer.class, System.identityHashCode(this)) - .add(Object.class, nextLong); - CompositeInterceptorBroadcaster.doCallHooks( - myInterceptorBroadcaster, - myRequest, - Pointcut.JPA_PERFTRACE_SEARCH_FOUND_ID, - params); + callPerformanceTracingHook(nextLong); } if (nextLong != null) { JpaPid next = JpaPid.fromId(nextLong); - if (myPidSet.add(next)) { + if (myPidSet.add(next) && doNotSkipNextPidForEverything()) { myNext = next; myNonSkipCount++; break; @@ -2350,11 +2407,10 @@ public class SearchBuilder implements ISearchBuilder { if (myIncludesIterator != null) { while (myIncludesIterator.hasNext()) { JpaPid next = myIncludesIterator.next(); - if (next != null) - if (myPidSet.add(next)) { - myNext = next; - break; - } + if (next != null && myPidSet.add(next) && doNotSkipNextPidForEverything()) { + myNext = next; + break; + } } if (myNext == null) { myNext = NO_MORE; @@ -2393,6 +2449,30 @@ public class SearchBuilder implements ISearchBuilder { } } + private Integer calculateMaxResultsToFetch() { + if (myParams.getLoadSynchronousUpTo() != null) { + return myParams.getLoadSynchronousUpTo(); + } else if (myParams.getOffset() != null && myParams.getCount() != null) { + return myParams.getEverythingMode() != null + ? myParams.getOffset() + myParams.getCount() + : myParams.getCount(); + } else { + return myStorageSettings.getFetchSizeDefaultMaximum(); + } + } + + private boolean doNotSkipNextPidForEverything() { + return !(myParams.getEverythingMode() != null && (myOffset != null && myOffset >= myPidSet.size())); + } + + private void callPerformanceTracingHook(Long theNextLong) { + HookParams params = new HookParams() + .add(Integer.class, System.identityHashCode(this)) + .add(Object.class, theNextLong); + CompositeInterceptorBroadcaster.doCallHooks( + myInterceptorBroadcaster, myRequest, Pointcut.JPA_PERFTRACE_SEARCH_FOUND_ID, params); + } + private void sendProcessingMsgAndFirePerformanceHook() { String msg = "Pass completed with no matching results seeking rows " + myPidSet.size() + "-" + mySkipCount @@ -2402,11 +2482,18 @@ public class SearchBuilder implements ISearchBuilder { } private void initializeIteratorQuery(Integer theOffset, Integer theMaxResultsToFetch) { + Integer offset = theOffset; if (myQueryList.isEmpty()) { // Capture times for Lucene/Elasticsearch queries as well mySearchRuntimeDetails.setQueryStopwatch(new StopWatch()); + + // setting offset to 0 to fetch all resource ids to guarantee + // correct output result for everything operation during paging + if (myParams.getEverythingMode() != null) { + offset = 0; + } myQueryList = createQuery( - myParams, mySort, theOffset, theMaxResultsToFetch, false, myRequest, mySearchRuntimeDetails); + myParams, mySort, offset, theMaxResultsToFetch, false, myRequest, mySearchRuntimeDetails); } mySearchRuntimeDetails.setQueryStopwatch(new StopWatch()); diff --git a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/provider/r4/ResourceProviderR4EverythingTest.java b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/provider/r4/ResourceProviderR4EverythingTest.java index 6968a69b08a..137c8cad7d6 100644 --- a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/provider/r4/ResourceProviderR4EverythingTest.java +++ b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/provider/r4/ResourceProviderR4EverythingTest.java @@ -42,6 +42,7 @@ import org.hl7.fhir.r4.model.UnsignedIntType; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import java.io.IOException; @@ -1240,6 +1241,234 @@ public class ResourceProviderR4EverythingTest extends BaseResourceProviderR4Test assertThat(actualResourceIds).containsExactlyInAnyOrder(desiredPid, desiredPractitionerId, groupId); } + @Nested + public class ResourceProviderR4EverythingWithOffsetTest { + + // Patient 1 resources + private IIdType o1Id; + private IIdType e1Id; + private IIdType p1Id; + private IIdType c1Id; + + // Patient 2 resources + private IIdType o2Id; + private IIdType p2Id; + private IIdType c2Id; + + // Patient 3 resources + private IIdType o3Id; + private IIdType p3Id; + private IIdType c3Id; + + // Patient 4 resources + private IIdType o4Id; + private IIdType p4Id; + private IIdType c4Id; + + // Resource id not linked to any Patient + private IIdType c5Id; + + @Test + public void testPagingOverEverything_onPatientInstance_returnsCorrectBundles() { + String methodName = "testEverythingPatientInstanceOffset"; + createPatientResources(methodName); + + // There are 4 results, lets make 4 pages of 1. + Parameters parameters = new Parameters(); + addOffsetAndCount(parameters, 0, 1); + + // first page + Parameters output = myClient.operation().onInstance(p1Id).named("everything").withParameters(parameters).execute(); + Bundle bundle = (Bundle) output.getParameter().get(0).getResource(); + List results = new ArrayList<>(validateAndGetIdListFromBundle(bundle, 1)); + + // second page + Bundle nextBundle = getNextBundle(bundle); + results.addAll(validateAndGetIdListFromBundle(nextBundle, 1)); + + // third page + nextBundle = getNextBundle(nextBundle); + results.addAll(validateAndGetIdListFromBundle(nextBundle, 1)); + + // fourth page + nextBundle = getNextBundle(nextBundle); + results.addAll(validateAndGetIdListFromBundle(nextBundle, 1)); + + // no next link + assertNull(nextBundle.getLink("next")); + assertThat(results).containsOnly(p1Id, c1Id, o1Id, e1Id); + } + + @Test + public void testPagingOverEverything_onPatientType_returnsCorrectBundles() { + String methodName = "testEverythingPatientTypeOffset"; + createPatientResources(methodName); + + // Test paging works. + // There are 13 results, lets make 3 pages of 4 and 1 page of 1. + Parameters parameters = new Parameters(); + addOffsetAndCount(parameters, 0, 4); + + Parameters output = myClient.operation().onType(Patient.class).named("everything").withParameters(parameters).execute(); + validateEverythingBundle(output); + } + + @Test + public void testPagingOverEverything_onPatientTypeWithAndIdParameter_returnsCorrectBundles() { + String methodName = "testEverythingPatientTypeWithAndIdParameter"; + createPatientResources(methodName); + + // Test 4 patients using and List + // e.g. _id=1&_id=2&_id=3&_id=4 + Parameters parameters = new Parameters(); + parameters.addParameter("_id", p1Id.getIdPart()); + parameters.addParameter("_id", p2Id.getIdPart()); + parameters.addParameter("_id", p3Id.getIdPart()); + parameters.addParameter("_id", p4Id.getIdPart()); + // Test for Patient 1-4 with _count=4 and _offset=0 with paging + addOffsetAndCount(parameters, 0, 4); + + Parameters output = myClient.operation().onType(Patient.class).named("everything").withParameters(parameters).execute(); + validateEverythingBundle(output); + } + + @Test + public void testPagingOverEverything_onPatientTypeWithOrIdParameter_returnsCorrectBundles() { + String methodName = "testEverythingPatientTypeWithOrIdParameter"; + createPatientResources(methodName); + + // Test 4 patients using or List + // e.g. _id=1,2,3,4 + Parameters parameters = new Parameters(); + parameters.addParameter("_id", p1Id.getIdPart() + "," + p2Id.getIdPart() + "," + p3Id.getIdPart() + "," + p4Id.getIdPart()); + // Test for Patient 1-4 with _count=4 and _offset=0 with paging + addOffsetAndCount(parameters, 0, 4); + + Parameters output = myClient.operation().onType(Patient.class).named("everything").withParameters(parameters).execute(); + validateEverythingBundle(output); + } + + @Test + public void testPagingOverEverything_onPatientTypeWithOrAndIdParameter_returnsCorrectBundles() { + String methodName = "testEverythingPatientTypeWithOrAndIdParameter"; + createPatientResources(methodName); + + // Test combining 2 or-listed params + // e.g. _id=1,2&_id=3,4 + Parameters parameters = new Parameters(); + parameters.addParameter("_id", p1Id.getIdPart() + "," + p2Id.getIdPart()); + parameters.addParameter("_id", p3Id.getIdPart() + "," + p4Id.getIdPart()); + // Test for Patient 1-4 with _count=4 and _offset=0 with paging + addOffsetAndCount(parameters, 0, 4); + + Parameters output = myClient.operation().onType(Patient.class).named("everything").withParameters(parameters).execute(); + validateEverythingBundle(output); + } + + @Test + public void testPagingOverEverything_onPatientTypeWithNotLinkedPatients_returnsCorrectBundles() { + String methodName = "testEverythingPatientTypeWithNotLinkedPatients"; + + // setup + p1Id = createPatient(methodName, "1"); + p2Id = createPatient(methodName, "2"); + p3Id = createPatient(methodName, "3"); + p4Id = createPatient(methodName, "4"); + + // Test patients without links + Parameters parameters = new Parameters(); + addOffsetAndCount(parameters, 0, 1); + + // first page + Parameters output = myClient.operation().onType(Patient.class).named("everything").withParameters(parameters).execute(); + Bundle bundle = (Bundle) output.getParameter().get(0).getResource(); + List results = new ArrayList<>(validateAndGetIdListFromBundle(bundle, 1)); + + // second page + Bundle nextBundle = getNextBundle(bundle); + results.addAll(validateAndGetIdListFromBundle(nextBundle, 1)); + + // third page + nextBundle = getNextBundle(nextBundle); + results.addAll(validateAndGetIdListFromBundle(nextBundle, 1)); + + // fourth page + nextBundle = getNextBundle(nextBundle); + results.addAll(validateAndGetIdListFromBundle(nextBundle, 1)); + + // no next link + assertNull(nextBundle.getLink("next")); + assertThat(results).containsOnly(p1Id, p2Id, p3Id, p4Id); + } + + private void addOffsetAndCount(Parameters theParameters, int theOffset, int theCount) { + theParameters.addParameter(new Parameters.ParametersParameterComponent() + .setName("_count").setValue(new UnsignedIntType(theCount))); + theParameters.addParameter(new Parameters.ParametersParameterComponent() + .setName("_offset").setValue(new UnsignedIntType(theOffset))); + } + + private void validateEverythingBundle(Parameters theParameters) { + // first page + Bundle bundle = (Bundle) theParameters.getParameter().get(0).getResource(); + List results = new ArrayList<>(validateAndGetIdListFromBundle(bundle, 4)); + + // second page + Bundle nextBundle = getNextBundle(bundle); + results.addAll(validateAndGetIdListFromBundle(nextBundle, 4)); + + // third page + nextBundle = getNextBundle(nextBundle); + results.addAll(validateAndGetIdListFromBundle(nextBundle, 4)); + + // fourth page + nextBundle = getNextBundle(nextBundle); + results.addAll(validateAndGetIdListFromBundle(nextBundle, 1)); + + // no next link + assertNull(nextBundle.getLink("next")); + // make sure all resources are returned, order doesn't matter + assertThat(results).containsOnly(p1Id, p2Id, p3Id, p4Id, c1Id, c2Id, c3Id, c4Id, o1Id, e1Id, o2Id, o3Id, o4Id); + } + + private Bundle getNextBundle(Bundle theBundle) { + String next = theBundle.getLink("next").getUrl(); + return myClient.loadPage().byUrl(next).andReturnBundle(Bundle.class).execute(); + } + + private List validateAndGetIdListFromBundle(Bundle theBundle, int theSize) { + assertEquals(Bundle.BundleType.SEARCHSET, theBundle.getType()); + assertThat(theBundle.getEntry()).hasSize(theSize); + return toUnqualifiedVersionlessIds(theBundle); + } + + private void createPatientResources(String theMethodName) { + // Patient 1 resources + o1Id = createOrganization(theMethodName, "1"); + e1Id = createEncounter(theMethodName, "1"); + p1Id = createPatientWithIndexAtOrganization(theMethodName, "1", o1Id); + c1Id = createConditionWithEncounterForPatient(theMethodName, "1", p1Id, e1Id); + + // Patient 2 resources + o2Id = createOrganization(theMethodName, "2"); + p2Id = createPatientWithIndexAtOrganization(theMethodName, "2", o2Id); + c2Id = createConditionForPatient(theMethodName, "2", p2Id); + + // Patient 3 resources + o3Id = createOrganization(theMethodName, "3"); + p3Id = createPatientWithIndexAtOrganization(theMethodName, "3", o3Id); + c3Id = createConditionForPatient(theMethodName, "3", p3Id); + + // Patient 4 resources + o4Id = createOrganization(theMethodName, "4"); + p4Id = createPatientWithIndexAtOrganization(theMethodName, "4", o4Id); + c4Id = createConditionForPatient(theMethodName, "4", p4Id); + + // Resource not linked to any Patient + c5Id = createConditionForPatient(theMethodName, "5", null); + } + } + private Bundle executeEverythingOperationOnInstance(IIdType theInstanceIdType) { return myClient .operation() @@ -1250,12 +1479,22 @@ public class ResourceProviderR4EverythingTest extends BaseResourceProviderR4Test .execute(); } - private IIdType createOrganization(String methodName, String s) { + private IIdType createOrganization(String methodName, String theIndex) { Organization o1 = new Organization(); - o1.setName(methodName + s); + o1.setName(methodName + theIndex); return myClient.create().resource(o1).execute().getId().toUnqualifiedVersionless(); } + private IIdType createEncounter(String methodName, String theIndex) { + Encounter e1 = new Encounter(); + e1.setLanguage(methodName + theIndex); + return myClient.create().resource(e1).execute().getId().toUnqualifiedVersionless(); + } + + public IIdType createPatient(String theMethodName, String theIndex) { + return createPatientWithIndexAtOrganization(theMethodName, theIndex, null); + } + public IIdType createPatientWithIndexAtOrganization(String theMethodName, String theIndex, IIdType theOrganizationId) { Patient p1 = new Patient(); p1.addName().setFamily(theMethodName + theIndex); @@ -1265,13 +1504,22 @@ public class ResourceProviderR4EverythingTest extends BaseResourceProviderR4Test } public IIdType createConditionForPatient(String theMethodName, String theIndex, IIdType thePatientId) { + return createConditionWithEncounterForPatient(theMethodName, theIndex, thePatientId, null); + } + + public IIdType createConditionWithEncounterForPatient(String theMethodName, + String theIndex, + IIdType thePatientId, + IIdType theEncounterId) { Condition c = new Condition(); c.addIdentifier().setValue(theMethodName + theIndex); if (thePatientId != null) { c.getSubject().setReferenceElement(thePatientId); } - IIdType cId = myClient.create().resource(c).execute().getId().toUnqualifiedVersionless(); - return cId; + if (theEncounterId != null) { + c.setEncounter(new Reference(theEncounterId)); + } + return myClient.create().resource(c).execute().getId().toUnqualifiedVersionless(); } private IIdType createMedicationRequestForPatient(IIdType thePatientId, String theIndex) {