Add forced offset search JPA interceptor (#2756)

* Add support for offset searches

* Tests working

* Add changelog

* Test fixes

* Test fix

* More test fixes

* Add commit counter
This commit is contained in:
James Agnew 2021-06-25 10:40:39 -04:00 committed by GitHub
parent c64269054b
commit 7185089c9d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 515 additions and 188 deletions

View File

@ -0,0 +1,7 @@
---
type: add
issue: 2756
title: "A new interceptor has been addeed to the JPA server called `ForceOffsetSearchModeInterceptor`. This
interceptor forces all searches to be offset searches, instead of relying on the query cache. This means
that FHIR search operations will never result in any database write, which can be good for highly
concurrent servers."

View File

@ -145,11 +145,17 @@ public class Search implements ICachedSearchDetails, Serializable {
private byte[] mySearchParameterMap; private byte[] mySearchParameterMap;
/** /**
* This isn't currently persisted in the DB as it's only used for history. We could * This isn't currently persisted in the DB as it's only used for offset mode. We could
* change this if needed in the future. * change this if needed in the future.
*/ */
@Transient @Transient
private Integer myOffset; private Integer myOffset;
/**
* This isn't currently persisted in the DB as it's only used for offset mode. We could
* change this if needed in the future.
*/
@Transient
private Integer mySizeModeSize;
/** /**
* Constructor * Constructor
@ -158,6 +164,10 @@ public class Search implements ICachedSearchDetails, Serializable {
super(); super();
} }
public Integer getSizeModeSize() {
return mySizeModeSize;
}
@Override @Override
public String toString() { public String toString() {
return new ToStringBuilder(this) return new ToStringBuilder(this)
@ -396,14 +406,14 @@ public class Search implements ICachedSearchDetails, Serializable {
mySearchQueryStringHash = null; mySearchQueryStringHash = null;
} }
public void setOffset(Integer theOffset) {
myOffset = theOffset;
}
public Integer getOffset() { public Integer getOffset() {
return myOffset; return myOffset;
} }
public void setOffset(Integer theOffset) {
myOffset = theOffset;
}
@Nonnull @Nonnull
public static String createSearchQueryStringForStorage(@Nonnull String theSearchQueryString, @Nonnull RequestPartitionId theRequestPartitionId) { public static String createSearchQueryStringForStorage(@Nonnull String theSearchQueryString, @Nonnull RequestPartitionId theRequestPartitionId) {
String searchQueryString = theSearchQueryString; String searchQueryString = theSearchQueryString;

View File

@ -0,0 +1,55 @@
package ca.uhn.fhir.jpa.interceptor;
/*-
* #%L
* HAPI FHIR JPA Server
* %%
* Copyright (C) 2014 - 2021 Smile CDR, Inc.
* %%
* 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.
* #L%
*/
import ca.uhn.fhir.interceptor.api.Hook;
import ca.uhn.fhir.interceptor.api.Interceptor;
import ca.uhn.fhir.interceptor.api.Pointcut;
import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
import org.apache.commons.lang3.Validate;
/**
* This interceptor for the HAPI FHIR JPA server forces all queries to
* be performed as offset queries. This means that the query cache will
* not be used and searches will never result in any writes to the
* database.
*/
@Interceptor
public class ForceOffsetSearchModeInterceptor {
private Integer myDefaultCount = 100;
public void setDefaultCount(Integer theDefaultCount) {
Validate.notNull(theDefaultCount, "theDefaultCount must not be null");
myDefaultCount = theDefaultCount;
}
@Hook(Pointcut.STORAGE_PRESEARCH_REGISTERED)
public void storagePreSearchRegistered(SearchParameterMap theMap) {
if (theMap.getOffset() == null) {
theMap.setOffset(0);
}
if (theMap.getCount() == null) {
theMap.setCount(myDefaultCount);
}
}
}

View File

@ -307,6 +307,7 @@ public class SearchCoordinatorSvcImpl implements ISearchCoordinatorSvc {
final String queryString = theParams.toNormalizedQueryString(myContext); final String queryString = theParams.toNormalizedQueryString(myContext);
ourLog.debug("Registering new search {}", searchUuid); ourLog.debug("Registering new search {}", searchUuid);
Search search = new Search(); Search search = new Search();
populateSearchEntity(theParams, theResourceType, searchUuid, queryString, search, theRequestPartitionId); populateSearchEntity(theParams, theResourceType, searchUuid, queryString, search, theRequestPartitionId);
@ -317,13 +318,15 @@ public class SearchCoordinatorSvcImpl implements ISearchCoordinatorSvc {
.addIfMatchesType(ServletRequestDetails.class, theRequestDetails) .addIfMatchesType(ServletRequestDetails.class, theRequestDetails)
.add(SearchParameterMap.class, theParams); .add(SearchParameterMap.class, theParams);
CompositeInterceptorBroadcaster.doCallHooks(myInterceptorBroadcaster, theRequestDetails, Pointcut.STORAGE_PRESEARCH_REGISTERED, params); CompositeInterceptorBroadcaster.doCallHooks(myInterceptorBroadcaster, theRequestDetails, Pointcut.STORAGE_PRESEARCH_REGISTERED, params);
Class<? extends IBaseResource> resourceTypeClass = myContext.getResourceDefinition(theResourceType).getImplementingClass(); Class<? extends IBaseResource> resourceTypeClass = myContext.getResourceDefinition(theResourceType).getImplementingClass();
final ISearchBuilder sb = mySearchBuilderFactory.newSearchBuilder(theCallingDao, theResourceType, resourceTypeClass); final ISearchBuilder sb = mySearchBuilderFactory.newSearchBuilder(theCallingDao, theResourceType, resourceTypeClass);
sb.setFetchSize(mySyncSize); sb.setFetchSize(mySyncSize);
final Integer loadSynchronousUpTo = getLoadSynchronousUpToOrNull(theCacheControlDirective); final Integer loadSynchronousUpTo = getLoadSynchronousUpToOrNull(theCacheControlDirective);
boolean isOffsetQuery = theParams.isOffsetQuery();
if (theParams.isLoadSynchronous() || loadSynchronousUpTo != null) { if (theParams.isLoadSynchronous() || loadSynchronousUpTo != null || isOffsetQuery) {
ourLog.debug("Search {} is loading in synchronous mode", searchUuid); ourLog.debug("Search {} is loading in synchronous mode", searchUuid);
return executeQuery(theResourceType, theParams, theRequestDetails, searchUuid, sb, loadSynchronousUpTo, theRequestPartitionId); return executeQuery(theResourceType, theParams, theRequestDetails, searchUuid, sb, loadSynchronousUpTo, theRequestPartitionId);
} }
@ -466,6 +469,7 @@ public class SearchCoordinatorSvcImpl implements ISearchCoordinatorSvc {
// Execute the query and make sure we return distinct results // Execute the query and make sure we return distinct results
TransactionTemplate txTemplate = new TransactionTemplate(myManagedTxManager); TransactionTemplate txTemplate = new TransactionTemplate(myManagedTxManager);
txTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); txTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
txTemplate.setReadOnly(theParams.isLoadSynchronous() || theParams.isOffsetQuery());
return txTemplate.execute(t -> { return txTemplate.execute(t -> {
// Load the results synchronously // Load the results synchronously
@ -554,6 +558,10 @@ public class SearchCoordinatorSvcImpl implements ISearchCoordinatorSvc {
resources = InterceptorUtil.fireStoragePreshowResource(resources, theRequestDetails, myInterceptorBroadcaster); resources = InterceptorUtil.fireStoragePreshowResource(resources, theRequestDetails, myInterceptorBroadcaster);
SimpleBundleProvider bundleProvider = new SimpleBundleProvider(resources); SimpleBundleProvider bundleProvider = new SimpleBundleProvider(resources);
if (theParams.isOffsetQuery()) {
bundleProvider.setCurrentPageOffset(theParams.getOffset());
bundleProvider.setCurrentPageSize(theParams.getCount());
}
if (wantCount) { if (wantCount) {
bundleProvider.setSize(count.intValue()); bundleProvider.setSize(count.intValue());

View File

@ -22,17 +22,20 @@ package ca.uhn.fhir.jpa.util;
import net.ttddyy.dsproxy.ExecutionInfo; import net.ttddyy.dsproxy.ExecutionInfo;
import net.ttddyy.dsproxy.QueryInfo; import net.ttddyy.dsproxy.QueryInfo;
import net.ttddyy.dsproxy.listener.MethodExecutionContext;
import net.ttddyy.dsproxy.proxy.ParameterSetOperation; import net.ttddyy.dsproxy.proxy.ParameterSetOperation;
import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder; import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder;
import javax.annotation.Nullable;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Queue; import java.util.Queue;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.apache.commons.lang3.StringUtils.trim; import static org.apache.commons.lang3.StringUtils.trim;
public abstract class BaseCaptureQueriesListener implements ProxyDataSourceBuilder.SingleQueryExecution { public abstract class BaseCaptureQueriesListener implements ProxyDataSourceBuilder.SingleQueryExecution, ProxyDataSourceBuilder.SingleMethodExecution {
private boolean myCaptureQueryStackTrace = false; private boolean myCaptureQueryStackTrace = false;
@ -88,4 +91,34 @@ public abstract class BaseCaptureQueriesListener implements ProxyDataSourceBuild
protected abstract Queue<SqlQuery> provideQueryList(); protected abstract Queue<SqlQuery> provideQueryList();
@Nullable
protected abstract AtomicInteger provideCommitCounter();
@Nullable
protected abstract AtomicInteger provideRollbackCounter();
@Override
public void execute(MethodExecutionContext executionContext) {
AtomicInteger counter = null;
switch (executionContext.getMethod().getName()) {
case "commit":
counter = provideCommitCounter();
break;
case "rollback":
counter = provideRollbackCounter();
break;
}
if (counter != null) {
counter.incrementAndGet();
}
}
public int countCommits() {
return provideCommitCounter().get();
}
public int countRollbacks() {
return provideRollbackCounter().get();
}
} }

View File

@ -35,6 +35,7 @@ import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Queue; import java.util.Queue;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -52,6 +53,8 @@ public class CircularQueueCaptureQueriesListener extends BaseCaptureQueriesListe
private static final int CAPACITY = 1000; private static final int CAPACITY = 1000;
private static final Logger ourLog = LoggerFactory.getLogger(CircularQueueCaptureQueriesListener.class); private static final Logger ourLog = LoggerFactory.getLogger(CircularQueueCaptureQueriesListener.class);
private Queue<SqlQuery> myQueries; private Queue<SqlQuery> myQueries;
private AtomicInteger myCommitCounter;
private AtomicInteger myRollbackCounter;
/** /**
* Constructor * Constructor
@ -65,11 +68,23 @@ public class CircularQueueCaptureQueriesListener extends BaseCaptureQueriesListe
return myQueries; return myQueries;
} }
@Override
protected AtomicInteger provideCommitCounter() {
return myCommitCounter;
}
@Override
protected AtomicInteger provideRollbackCounter() {
return myRollbackCounter;
}
/** /**
* Clear all stored queries * Clear all stored queries
*/ */
public void clear() { public void clear() {
myQueries.clear(); myQueries.clear();
myCommitCounter.set(0);
myRollbackCounter.set(0);
} }
/** /**
@ -77,6 +92,8 @@ public class CircularQueueCaptureQueriesListener extends BaseCaptureQueriesListe
*/ */
public void startCollecting() { public void startCollecting() {
myQueries = Queues.synchronizedQueue(new CircularFifoQueue<>(CAPACITY)); myQueries = Queues.synchronizedQueue(new CircularFifoQueue<>(CAPACITY));
myCommitCounter = new AtomicInteger(0);
myRollbackCounter = new AtomicInteger(0);
} }
/** /**
@ -84,6 +101,8 @@ public class CircularQueueCaptureQueriesListener extends BaseCaptureQueriesListe
*/ */
public void stopCollecting() { public void stopCollecting() {
myQueries = null; myQueries = null;
myCommitCounter = null;
myRollbackCounter = null;
} }
/** /**
@ -115,6 +134,14 @@ public class CircularQueueCaptureQueriesListener extends BaseCaptureQueriesListe
return getQueriesStartingWith(theStart, null); return getQueriesStartingWith(theStart, null);
} }
public int getCommitCount() {
return myCommitCounter.get();
}
public int getRollbackCount() {
return myRollbackCounter.get();
}
/** /**
* Returns all SELECT queries executed on the current thread - Index 0 is oldest * Returns all SELECT queries executed on the current thread - Index 0 is oldest
*/ */

View File

@ -27,11 +27,14 @@ import java.util.ArrayDeque;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Queue; import java.util.Queue;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public class CurrentThreadCaptureQueriesListener extends BaseCaptureQueriesListener { public class CurrentThreadCaptureQueriesListener extends BaseCaptureQueriesListener {
private static final ThreadLocal<Queue<SqlQuery>> ourQueues = new ThreadLocal<>(); private static final ThreadLocal<Queue<SqlQuery>> ourQueues = new ThreadLocal<>();
private static final ThreadLocal<AtomicInteger> ourCommits = new ThreadLocal<>();
private static final ThreadLocal<AtomicInteger> ourRollbacks = new ThreadLocal<>();
private static final Logger ourLog = LoggerFactory.getLogger(CurrentThreadCaptureQueriesListener.class); private static final Logger ourLog = LoggerFactory.getLogger(CurrentThreadCaptureQueriesListener.class);
@Override @Override
@ -39,18 +42,31 @@ public class CurrentThreadCaptureQueriesListener extends BaseCaptureQueriesListe
return ourQueues.get(); return ourQueues.get();
} }
@Override
protected AtomicInteger provideCommitCounter() {
return ourCommits.get();
}
@Override
protected AtomicInteger provideRollbackCounter() {
return ourRollbacks.get();
}
/** /**
* Get the current queue of items and stop collecting * Get the current queue of items and stop collecting
*/ */
public static SqlQueryList getCurrentQueueAndStopCapturing() { public static SqlQueryList getCurrentQueueAndStopCapturing() {
Queue<SqlQuery> retVal = ourQueues.get(); Queue<SqlQuery> retVal = ourQueues.get();
ourQueues.remove(); ourQueues.remove();
ourCommits.remove();
ourRollbacks.remove();
if (retVal == null) { if (retVal == null) {
return new SqlQueryList(); return new SqlQueryList();
} }
return new SqlQueryList(retVal); return new SqlQueryList(retVal);
} }
/** /**
* Starts capturing queries for the current thread. * Starts capturing queries for the current thread.
* <p> * <p>
@ -62,6 +78,8 @@ public class CurrentThreadCaptureQueriesListener extends BaseCaptureQueriesListe
*/ */
public static void startCapturing() { public static void startCapturing() {
ourQueues.set(new ArrayDeque<>()); ourQueues.set(new ArrayDeque<>());
ourCommits.set(new AtomicInteger(0));
ourRollbacks.set(new AtomicInteger(0));
} }
/** /**

View File

@ -127,6 +127,7 @@ public class TestR4Config extends BaseJavaConfigR4 {
.afterQuery(captureQueriesListener()) .afterQuery(captureQueriesListener())
.afterQuery(new CurrentThreadCaptureQueriesListener()) .afterQuery(new CurrentThreadCaptureQueriesListener())
.countQuery(singleQueryCountHolder()) .countQuery(singleQueryCountHolder())
.afterMethod(captureQueriesListener())
.build(); .build();
return dataSource; return dataSource;

View File

@ -5,11 +5,13 @@ import ca.uhn.fhir.jpa.api.model.HistoryCountModeEnum;
import ca.uhn.fhir.jpa.model.entity.ModelConfig; import ca.uhn.fhir.jpa.model.entity.ModelConfig;
import ca.uhn.fhir.jpa.model.util.JpaConstants; import ca.uhn.fhir.jpa.model.util.JpaConstants;
import ca.uhn.fhir.jpa.partition.SystemRequestDetails; import ca.uhn.fhir.jpa.partition.SystemRequestDetails;
import ca.uhn.fhir.jpa.provider.r4.BaseResourceProviderR4Test;
import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
import ca.uhn.fhir.jpa.util.SqlQuery; import ca.uhn.fhir.jpa.util.SqlQuery;
import ca.uhn.fhir.rest.api.SortSpec; import ca.uhn.fhir.rest.api.SortSpec;
import ca.uhn.fhir.rest.api.server.IBundleProvider; import ca.uhn.fhir.rest.api.server.IBundleProvider;
import ca.uhn.fhir.rest.param.ReferenceParam; import ca.uhn.fhir.rest.param.ReferenceParam;
import ca.uhn.fhir.rest.param.TokenParam;
import ca.uhn.fhir.util.BundleBuilder; import ca.uhn.fhir.util.BundleBuilder;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.hl7.fhir.instance.model.api.IIdType; import org.hl7.fhir.instance.model.api.IIdType;
@ -46,10 +48,11 @@ import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.empty;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
public class FhirResourceDaoR4QueryCountTest extends BaseJpaR4Test { public class FhirResourceDaoR4QueryCountTest extends BaseResourceProviderR4Test {
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(FhirResourceDaoR4QueryCountTest.class); private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(FhirResourceDaoR4QueryCountTest.class);
@AfterEach @AfterEach
@ -66,8 +69,10 @@ public class FhirResourceDaoR4QueryCountTest extends BaseJpaR4Test {
myDaoConfig.setTagStorageMode(new DaoConfig().getTagStorageMode()); myDaoConfig.setTagStorageMode(new DaoConfig().getTagStorageMode());
} }
@Override
@BeforeEach @BeforeEach
public void before() { public void before() throws Exception {
super.before();
myInterceptorRegistry.registerInterceptor(myInterceptor); myInterceptorRegistry.registerInterceptor(myInterceptor);
} }
@ -534,6 +539,92 @@ public class FhirResourceDaoR4QueryCountTest extends BaseJpaR4Test {
} }
@Test
public void testSearchUsingOffsetMode_Explicit() {
for (int i = 0; i < 10; i++) {
createPatient(withId("A" + i), withActiveTrue());
}
SearchParameterMap map = new SearchParameterMap();
map.setLoadSynchronousUpTo(5);
map.setOffset(0);
map.add("active", new TokenParam("true"));
// First page
myCaptureQueriesListener.clear();
Bundle outcome = myClient
.search()
.forResource("Patient")
.where(Patient.ACTIVE.exactly().code("true"))
.offset(0)
.count(5)
.returnBundle(Bundle.class)
.execute();
assertThat(toUnqualifiedVersionlessIdValues(outcome).toString(), toUnqualifiedVersionlessIdValues(outcome), containsInAnyOrder(
"Patient/A0", "Patient/A1", "Patient/A2", "Patient/A3", "Patient/A4"
));
myCaptureQueriesListener.logSelectQueries();
assertEquals(2, myCaptureQueriesListener.countSelectQueries());
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("SELECT t0.RES_ID FROM HFJ_SPIDX_TOKEN t0"));
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("limit '5'"));
assertEquals(0, myCaptureQueriesListener.countInsertQueries());
assertEquals(0, myCaptureQueriesListener.countUpdateQueries());
assertEquals(0, myCaptureQueriesListener.countDeleteQueries());
assertEquals(1, myCaptureQueriesListener.countCommits());
assertEquals(0, myCaptureQueriesListener.countRollbacks());
assertThat(outcome.getLink("next").getUrl(), containsString("Patient?_count=5&_offset=5&active=true"));
// Second page
myCaptureQueriesListener.clear();
outcome = myClient
.search()
.forResource("Patient")
.where(Patient.ACTIVE.exactly().code("true"))
.offset(5)
.count(5)
.returnBundle(Bundle.class)
.execute();
assertThat(toUnqualifiedVersionlessIdValues(outcome).toString(), toUnqualifiedVersionlessIdValues(outcome), containsInAnyOrder(
"Patient/A5", "Patient/A6", "Patient/A7", "Patient/A8", "Patient/A9"
));
myCaptureQueriesListener.logSelectQueries();
assertEquals(2, myCaptureQueriesListener.countSelectQueries());
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("SELECT t0.RES_ID FROM HFJ_SPIDX_TOKEN t0"));
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("limit '5'"));
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("offset '5'"));
assertEquals(0, myCaptureQueriesListener.countInsertQueries());
assertEquals(0, myCaptureQueriesListener.countUpdateQueries());
assertEquals(0, myCaptureQueriesListener.countDeleteQueries());
assertEquals(1, myCaptureQueriesListener.countCommits());
assertEquals(0, myCaptureQueriesListener.countRollbacks());
assertThat(outcome.getLink("next").getUrl(), containsString("Patient?_count=5&_offset=10&active=true"));
// Third page (no results)
myCaptureQueriesListener.clear();
outcome = myClient
.search()
.forResource("Patient")
.where(Patient.ACTIVE.exactly().code("true"))
.offset(10)
.count(5)
.returnBundle(Bundle.class)
.execute();
assertThat(toUnqualifiedVersionlessIdValues(outcome).toString(), toUnqualifiedVersionlessIdValues(outcome), empty());
myCaptureQueriesListener.logSelectQueries();
assertEquals(1, myCaptureQueriesListener.countSelectQueries());
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("SELECT t0.RES_ID FROM HFJ_SPIDX_TOKEN t0"));
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("limit '5'"));
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("offset '10'"));
assertEquals(0, myCaptureQueriesListener.countInsertQueries());
assertEquals(0, myCaptureQueriesListener.countUpdateQueries());
assertEquals(0, myCaptureQueriesListener.countDeleteQueries());
}
@Test @Test
public void testSearchUsingForcedIdReference() { public void testSearchUsingForcedIdReference() {

View File

@ -0,0 +1,158 @@
package ca.uhn.fhir.jpa.interceptor;
import ca.uhn.fhir.jpa.provider.r4.BaseResourceProviderR4Test;
import org.hl7.fhir.r4.model.Bundle;
import org.hl7.fhir.r4.model.Patient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.empty;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
public class ForceOffsetSearchModeInterceptorTest extends BaseResourceProviderR4Test {
private ForceOffsetSearchModeInterceptor mySvc;
private Integer myInitialDefaultPageSize;
@Override
@BeforeEach
public void before() throws Exception {
super.before();
mySvc = new ForceOffsetSearchModeInterceptor();
ourRestServer.registerInterceptor(mySvc);
myInitialDefaultPageSize = ourRestServer.getDefaultPageSize();
}
@Override
@AfterEach
public void after() throws Exception {
super.after();
ourRestServer.unregisterInterceptor(mySvc);
ourRestServer.setDefaultPageSize(myInitialDefaultPageSize);
}
@Test
public void testSearch_NoExplcitCount() {
ourRestServer.setDefaultPageSize(5);
for (int i = 0; i < 10; i++) {
createPatient(withId("A" + i), withActiveTrue());
}
// First page
myCaptureQueriesListener.clear();
Bundle outcome = myClient
.search()
.forResource("Patient")
.where(Patient.ACTIVE.exactly().code("true"))
.returnBundle(Bundle.class)
.execute();
assertThat(toUnqualifiedVersionlessIdValues(outcome).toString(), toUnqualifiedVersionlessIdValues(outcome), containsInAnyOrder(
"Patient/A0", "Patient/A1", "Patient/A2", "Patient/A3", "Patient/A4"
));
myCaptureQueriesListener.logSelectQueries();
assertEquals(2, myCaptureQueriesListener.countSelectQueries());
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("SELECT t0.RES_ID FROM HFJ_SPIDX_TOKEN t0"));
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("limit '5'"));
assertEquals(0, myCaptureQueriesListener.countInsertQueries());
assertEquals(0, myCaptureQueriesListener.countUpdateQueries());
assertEquals(0, myCaptureQueriesListener.countDeleteQueries());
assertEquals(1, myCaptureQueriesListener.countCommits());
assertEquals(0, myCaptureQueriesListener.countRollbacks());
assertThat(outcome.getLink("next").getUrl(), containsString("Patient?_count=5&_offset=5&active=true"));
// Second page
myCaptureQueriesListener.clear();
outcome = myClient
.search()
.forResource("Patient")
.where(Patient.ACTIVE.exactly().code("true"))
.offset(5)
.count(5)
.returnBundle(Bundle.class)
.execute();
assertThat(toUnqualifiedVersionlessIdValues(outcome).toString(), toUnqualifiedVersionlessIdValues(outcome), containsInAnyOrder(
"Patient/A5", "Patient/A6", "Patient/A7", "Patient/A8", "Patient/A9"
));
myCaptureQueriesListener.logSelectQueries();
assertEquals(2, myCaptureQueriesListener.countSelectQueries());
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("SELECT t0.RES_ID FROM HFJ_SPIDX_TOKEN t0"));
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("limit '5'"));
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("offset '5'"));
assertEquals(0, myCaptureQueriesListener.countInsertQueries());
assertEquals(0, myCaptureQueriesListener.countUpdateQueries());
assertEquals(0, myCaptureQueriesListener.countDeleteQueries());
assertEquals(1, myCaptureQueriesListener.countCommits());
assertEquals(0, myCaptureQueriesListener.countRollbacks());
assertThat(outcome.getLink("next").getUrl(), containsString("Patient?_count=5&_offset=10&active=true"));
// Third page (no results)
myCaptureQueriesListener.clear();
Bundle outcome3 = myClient
.search()
.forResource("Patient")
.where(Patient.ACTIVE.exactly().code("true"))
.offset(10)
.count(5)
.returnBundle(Bundle.class)
.execute();
assertThat(toUnqualifiedVersionlessIdValues(outcome3).toString(), toUnqualifiedVersionlessIdValues(outcome3), empty());
myCaptureQueriesListener.logSelectQueries();
assertEquals(1, myCaptureQueriesListener.countSelectQueries());
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("SELECT t0.RES_ID FROM HFJ_SPIDX_TOKEN t0"));
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("limit '5'"));
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("offset '10'"));
assertEquals(0, myCaptureQueriesListener.countInsertQueries());
assertEquals(0, myCaptureQueriesListener.countUpdateQueries());
assertEquals(0, myCaptureQueriesListener.countDeleteQueries());
assertNull(outcome3.getLink("next"), () -> outcome3.getLink("next").getUrl());
}
@Test
public void testSearch_WithExplicitCount() {
ourRestServer.setDefaultPageSize(5);
for (int i = 0; i < 10; i++) {
createPatient(withId("A" + i), withActiveTrue());
}
// First page
myCaptureQueriesListener.clear();
Bundle outcome = myClient
.search()
.forResource("Patient")
.where(Patient.ACTIVE.exactly().code("true"))
.count(7)
.returnBundle(Bundle.class)
.execute();
assertThat(toUnqualifiedVersionlessIdValues(outcome).toString(), toUnqualifiedVersionlessIdValues(outcome), containsInAnyOrder(
"Patient/A0", "Patient/A1", "Patient/A2", "Patient/A3", "Patient/A4", "Patient/A5", "Patient/A6"
));
myCaptureQueriesListener.logSelectQueries();
assertEquals(2, myCaptureQueriesListener.countSelectQueries());
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("SELECT t0.RES_ID FROM HFJ_SPIDX_TOKEN t0"));
assertThat(myCaptureQueriesListener.getSelectQueries().get(0).getSql(true, false), containsString("limit '7'"));
assertEquals(0, myCaptureQueriesListener.countInsertQueries());
assertEquals(0, myCaptureQueriesListener.countUpdateQueries());
assertEquals(0, myCaptureQueriesListener.countDeleteQueries());
assertEquals(1, myCaptureQueriesListener.countCommits());
assertEquals(0, myCaptureQueriesListener.countRollbacks());
assertThat(outcome.getLink("next").getUrl(), containsString("Patient?_count=7&_offset=7&active=true"));
}
}

View File

@ -631,6 +631,15 @@ public class SearchParameterMap implements Serializable {
} }
} }
/**
* Returns true if {@link #getOffset()} and {@link #getCount()} both return a non null response
*
* @since 5.5.0
*/
public boolean isOffsetQuery() {
return getOffset() != null && getCount() != null;
}
public enum EverythingModeEnum { public enum EverythingModeEnum {
/* /*
* Don't reorder! We rely on the ordinals * Don't reorder! We rely on the ordinals

View File

@ -88,6 +88,25 @@ public interface IBundleProvider {
return null; return null;
} }
/**
* If the results in this bundle were produced using an offset query (as opposed to a query using
* continuation pointers, page IDs, etc.) the page offset can be returned here. The server
* should then attempt to form paging links that use <code>_offset</code> instead of
* opaque page IDs.
*/
default Integer getCurrentPageOffset() {
return null;
}
/**
* If {@link #getCurrentPageOffset()} returns a non-null value, this method must also return
* the actual page size used
*/
default Integer getCurrentPageSize() {
return null;
}
/** /**
* Returns the instant as of which this result was created. The * Returns the instant as of which this result was created. The
* result of this value is used to populate the <code>lastUpdated</code> * result of this value is used to populate the <code>lastUpdated</code>
@ -194,4 +213,5 @@ public interface IBundleProvider {
Validate.notNull(retVal, "size() returned null"); Validate.notNull(retVal, "size() returned null");
return retVal; return retVal;
} }
} }

View File

@ -37,6 +37,8 @@ public class SimpleBundleProvider implements IBundleProvider {
private Integer myPreferredPageSize; private Integer myPreferredPageSize;
private Integer mySize; private Integer mySize;
private IPrimitiveType<Date> myPublished = InstantDt.withCurrentTime(); private IPrimitiveType<Date> myPublished = InstantDt.withCurrentTime();
private Integer myCurrentPageOffset;
private Integer myCurrentPageSize;
/** /**
* Constructor * Constructor
@ -74,6 +76,36 @@ public class SimpleBundleProvider implements IBundleProvider {
setSize(theSize); setSize(theSize);
} }
/**
* @since 5.5.0
*/
@Override
public Integer getCurrentPageOffset() {
return myCurrentPageOffset;
}
/**
* @since 5.5.0
*/
public void setCurrentPageOffset(Integer theCurrentPageOffset) {
myCurrentPageOffset = theCurrentPageOffset;
}
/**
* @since 5.5.0
*/
@Override
public Integer getCurrentPageSize() {
return myCurrentPageSize;
}
/**
* @since 5.5.0
*/
public void setCurrentPageSize(Integer theCurrentPageSize) {
myCurrentPageSize = theCurrentPageSize;
}
/** /**
* Returns the results stored in this provider * Returns the results stored in this provider
*/ */

View File

@ -47,6 +47,7 @@ import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import static org.apache.commons.lang3.StringUtils.isBlank; import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNotBlank; import static org.apache.commons.lang3.StringUtils.isNotBlank;
@ -141,7 +142,16 @@ public abstract class BaseResourceReturningMethodBinding extends BaseMethodBindi
IBaseResource createBundleFromBundleProvider(IRestfulServer<?> theServer, RequestDetails theRequest, Integer theLimit, String theLinkSelf, Set<Include> theIncludes, IBaseResource createBundleFromBundleProvider(IRestfulServer<?> theServer, RequestDetails theRequest, Integer theLimit, String theLinkSelf, Set<Include> theIncludes,
IBundleProvider theResult, int theOffset, BundleTypeEnum theBundleType, EncodingEnum theLinkEncoding, String theSearchId) { IBundleProvider theResult, int theOffset, BundleTypeEnum theBundleType, EncodingEnum theLinkEncoding, String theSearchId) {
IVersionSpecificBundleFactory bundleFactory = theServer.getFhirContext().newBundleFactory(); IVersionSpecificBundleFactory bundleFactory = theServer.getFhirContext().newBundleFactory();
final Integer requestOffset = RestfulServerUtils.tryToExtractNamedParameter(theRequest, Constants.PARAM_OFFSET); final Integer offset;
Integer limit = theLimit;
if (theResult.getCurrentPageOffset() != null) {
offset = theResult.getCurrentPageOffset();
limit = theResult.getCurrentPageSize();
Validate.notNull(limit, "IBundleProvider returned a non-null offset, but did not return a non-null page size");
} else {
offset = RestfulServerUtils.tryToExtractNamedParameter(theRequest, Constants.PARAM_OFFSET);
}
int numToReturn; int numToReturn;
String searchId = null; String searchId = null;
@ -149,9 +159,9 @@ public abstract class BaseResourceReturningMethodBinding extends BaseMethodBindi
Integer numTotalResults = theResult.size(); Integer numTotalResults = theResult.size();
int pageSize; int pageSize;
if (requestOffset != null || !theServer.canStoreSearchResults()) { if (offset != null || !theServer.canStoreSearchResults()) {
if (theLimit != null) { if (limit != null) {
pageSize = theLimit; pageSize = limit;
} else { } else {
if (theServer.getDefaultPageSize() != null) { if (theServer.getDefaultPageSize() != null) {
pageSize = theServer.getDefaultPageSize(); pageSize = theServer.getDefaultPageSize();
@ -161,7 +171,7 @@ public abstract class BaseResourceReturningMethodBinding extends BaseMethodBindi
} }
numToReturn = pageSize; numToReturn = pageSize;
if (requestOffset != null && !isOffsetModeHistory()) { if ((offset != null && !isOffsetModeHistory()) || theResult.getCurrentPageOffset() != null) {
// When offset query is done theResult already contains correct amount (+ their includes etc.) so return everything // When offset query is done theResult already contains correct amount (+ their includes etc.) so return everything
resourceList = theResult.getResources(0, Integer.MAX_VALUE); resourceList = theResult.getResources(0, Integer.MAX_VALUE);
} else if (numToReturn > 0) { } else if (numToReturn > 0) {
@ -173,10 +183,10 @@ public abstract class BaseResourceReturningMethodBinding extends BaseMethodBindi
} else { } else {
IPagingProvider pagingProvider = theServer.getPagingProvider(); IPagingProvider pagingProvider = theServer.getPagingProvider();
if (theLimit == null || theLimit.equals(0)) { if (limit == null || ((Integer) limit).equals(0)) {
pageSize = pagingProvider.getDefaultPageSize(); pageSize = pagingProvider.getDefaultPageSize();
} else { } else {
pageSize = Math.min(pagingProvider.getMaximumPageSize(), theLimit); pageSize = Math.min(pagingProvider.getMaximumPageSize(), limit);
} }
numToReturn = pageSize; numToReturn = pageSize;
@ -238,19 +248,31 @@ public abstract class BaseResourceReturningMethodBinding extends BaseMethodBindi
BundleLinks links = new BundleLinks(theRequest.getFhirServerBase(), theIncludes, RestfulServerUtils.prettyPrintResponse(theServer, theRequest), theBundleType); BundleLinks links = new BundleLinks(theRequest.getFhirServerBase(), theIncludes, RestfulServerUtils.prettyPrintResponse(theServer, theRequest), theBundleType);
links.setSelf(theLinkSelf); links.setSelf(theLinkSelf);
if (requestOffset != null || (!theServer.canStoreSearchResults() && !isEverythingOperation(theRequest)) || isOffsetModeHistory()) { if (theResult.getCurrentPageOffset() != null) {
int offset = requestOffset != null ? requestOffset : 0;
if (isNotBlank(theResult.getNextPageId())) {
links.setNext(RestfulServerUtils.createOffsetPagingLink(links, theRequest.getRequestPath(), theRequest.getTenantId(), offset + limit, limit, theRequest.getParameters()));
}
if (isNotBlank(theResult.getPreviousPageId())) {
links.setNext(RestfulServerUtils.createOffsetPagingLink(links, theRequest.getRequestPath(), theRequest.getTenantId(), Math.max(offset - limit, 0), limit, theRequest.getParameters()));
}
}
if (offset != null || (!theServer.canStoreSearchResults() && !isEverythingOperation(theRequest)) || isOffsetModeHistory()) {
// Paging without caching // Paging without caching
// We're doing requestOffset pages // We're doing offset pages
int requestedToReturn = numToReturn; int requestedToReturn = numToReturn;
if (theServer.getPagingProvider() == null) { if (theServer.getPagingProvider() == null && offset != null) {
// There is no paging provider at all, so assume we're querying up to all the results we need every time // There is no paging provider at all, so assume we're querying up to all the results we need every time
requestedToReturn += offset; requestedToReturn += offset;
} }
if (numTotalResults == null || requestedToReturn < numTotalResults) { if (numTotalResults == null || requestedToReturn < numTotalResults) {
links.setNext(RestfulServerUtils.createOffsetPagingLink(links, theRequest.getRequestPath(), theRequest.getTenantId(), offset + numToReturn, numToReturn, theRequest.getParameters())); if (!resourceList.isEmpty()) {
links.setNext(RestfulServerUtils.createOffsetPagingLink(links, theRequest.getRequestPath(), theRequest.getTenantId(), defaultIfNull(offset, 0) + numToReturn, numToReturn, theRequest.getParameters()));
} }
if (offset > 0) { }
if (offset != null && offset > 0) {
int start = Math.max(0, theOffset - pageSize); int start = Math.max(0, theOffset - pageSize);
links.setPrev(RestfulServerUtils.createOffsetPagingLink(links, theRequest.getRequestPath(), theRequest.getTenantId(), start, pageSize, theRequest.getParameters())); links.setPrev(RestfulServerUtils.createOffsetPagingLink(links, theRequest.getRequestPath(), theRequest.getTenantId(), start, pageSize, theRequest.getParameters()));
} }

View File

@ -57,88 +57,6 @@ public class SearchBundleProviderWithNoSizeDstu2Test {
ourIdentifiers = null; ourIdentifiers = null;
} }
@Test
public void testBundleProviderReturnsNoSize() throws Exception {
Bundle respBundle;
ourLastBundleProvider = mock(IBundleProvider.class);
when(ourLastBundleProvider.size()).thenReturn(null);
when(ourLastBundleProvider.getResources(any(int.class), any(int.class))).then(new Answer<List<IBaseResource>>() {
@Override
public List<IBaseResource> answer(InvocationOnMock theInvocation) throws Throwable {
int from =(Integer)theInvocation.getArguments()[0];
int to =(Integer)theInvocation.getArguments()[1];
ArrayList<IBaseResource> retVal = Lists.newArrayList();
for (int i = from; i < to; i++) {
Patient p = new Patient();
p.setId(Integer.toString(i));
retVal.add(p);
}
return retVal;
}});
HttpGet httpGet;
CloseableHttpResponse status = null;
Link linkNext;
try {
httpGet = new HttpGet("http://localhost:" + ourPort + "/Patient?_format=json");
status = ourClient.execute(httpGet);
String responseContent = IOUtils.toString(status.getEntity().getContent(), StandardCharsets.UTF_8);
ourLog.info(responseContent);
assertEquals(200, status.getStatusLine().getStatusCode());
assertEquals("searchAll", ourLastMethod);
respBundle = ourCtx.newJsonParser().parseResource(Bundle.class, responseContent);
assertEquals(10, respBundle.getEntry().size());
assertEquals("Patient/0", respBundle.getEntry().get(0).getResource().getIdElement().toUnqualifiedVersionless().getValue());
linkNext = respBundle.getLink("next");
assertNotNull(linkNext);
} finally {
IOUtils.closeQuietly(status.getEntity().getContent());
}
when(ourLastBundleProvider.size()).thenReturn(25);
try {
httpGet = new HttpGet(linkNext.getUrl());
status = ourClient.execute(httpGet);
String responseContent = IOUtils.toString(status.getEntity().getContent(), StandardCharsets.UTF_8);
ourLog.info(responseContent);
assertEquals(200, status.getStatusLine().getStatusCode());
assertEquals("searchAll", ourLastMethod);
respBundle = ourCtx.newJsonParser().parseResource(Bundle.class, responseContent);
assertEquals(10, respBundle.getEntry().size());
assertEquals("Patient/10", respBundle.getEntry().get(0).getResource().getIdElement().toUnqualifiedVersionless().getValue());
linkNext = respBundle.getLink("next");
assertNotNull(linkNext);
} finally {
IOUtils.closeQuietly(status.getEntity().getContent());
}
try {
httpGet = new HttpGet(linkNext.getUrl());
status = ourClient.execute(httpGet);
String responseContent = IOUtils.toString(status.getEntity().getContent(), StandardCharsets.UTF_8);
ourLog.info(responseContent);
assertEquals(200, status.getStatusLine().getStatusCode());
assertEquals("searchAll", ourLastMethod);
respBundle = ourCtx.newJsonParser().parseResource(Bundle.class, responseContent);
assertEquals(5, respBundle.getEntry().size());
assertEquals("Patient/20", respBundle.getEntry().get(0).getResource().getIdElement().toUnqualifiedVersionless().getValue());
linkNext = respBundle.getLink("next");
assertNull(linkNext);
} finally {
IOUtils.closeQuietly(status.getEntity().getContent());
}
}
@AfterAll @AfterAll
public static void afterClassClearContext() throws Exception { public static void afterClassClearContext() throws Exception {

View File

@ -57,89 +57,6 @@ public class SearchBundleProviderWithNoSizeDstu3Test {
ourIdentifiers = null; ourIdentifiers = null;
} }
@Test
public void testBundleProviderReturnsNoSize() throws Exception {
Bundle respBundle;
ourLastBundleProvider = mock(IBundleProvider.class);
when(ourLastBundleProvider.size()).thenReturn(null);
when(ourLastBundleProvider.getResources(any(int.class), any(int.class))).then(new Answer<List<IBaseResource>>() {
@Override
public List<IBaseResource> answer(InvocationOnMock theInvocation) throws Throwable {
int from =(Integer)theInvocation.getArguments()[0];
int to =(Integer)theInvocation.getArguments()[1];
ArrayList<IBaseResource> retVal = Lists.newArrayList();
for (int i = from; i < to; i++) {
Patient p = new Patient();
p.setId(Integer.toString(i));
retVal.add(p);
}
return retVal;
}});
HttpGet httpGet;
CloseableHttpResponse status = null;
BundleLinkComponent linkNext;
try {
httpGet = new HttpGet("http://localhost:" + ourPort + "/Patient?_format=json");
status = ourClient.execute(httpGet);
String responseContent = IOUtils.toString(status.getEntity().getContent(), StandardCharsets.UTF_8);
ourLog.info(responseContent);
assertEquals(200, status.getStatusLine().getStatusCode());
assertEquals("searchAll", ourLastMethod);
respBundle = ourCtx.newJsonParser().parseResource(Bundle.class, responseContent);
assertEquals(10, respBundle.getEntry().size());
assertEquals("Patient/0", respBundle.getEntry().get(0).getResource().getIdElement().toUnqualifiedVersionless().getValue());
linkNext = respBundle.getLink("next");
assertNotNull(linkNext);
} finally {
IOUtils.closeQuietly(status.getEntity().getContent());
}
when(ourLastBundleProvider.size()).thenReturn(25);
try {
httpGet = new HttpGet(linkNext.getUrl());
status = ourClient.execute(httpGet);
String responseContent = IOUtils.toString(status.getEntity().getContent(), StandardCharsets.UTF_8);
ourLog.info(responseContent);
assertEquals(200, status.getStatusLine().getStatusCode());
assertEquals("searchAll", ourLastMethod);
respBundle = ourCtx.newJsonParser().parseResource(Bundle.class, responseContent);
assertEquals(10, respBundle.getEntry().size());
assertEquals("Patient/10", respBundle.getEntry().get(0).getResource().getIdElement().toUnqualifiedVersionless().getValue());
linkNext = respBundle.getLink("next");
assertNotNull(linkNext);
} finally {
IOUtils.closeQuietly(status.getEntity().getContent());
}
try {
httpGet = new HttpGet(linkNext.getUrl());
status = ourClient.execute(httpGet);
String responseContent = IOUtils.toString(status.getEntity().getContent(), StandardCharsets.UTF_8);
ourLog.info(responseContent);
assertEquals(200, status.getStatusLine().getStatusCode());
assertEquals("searchAll", ourLastMethod);
respBundle = ourCtx.newJsonParser().parseResource(Bundle.class, responseContent);
assertEquals(5, respBundle.getEntry().size());
assertEquals("Patient/20", respBundle.getEntry().get(0).getResource().getIdElement().toUnqualifiedVersionless().getValue());
linkNext = respBundle.getLink("next");
assertNull(linkNext);
} finally {
IOUtils.closeQuietly(status.getEntity().getContent());
}
}
@AfterAll @AfterAll
public static void afterClassClearContext() throws Exception { public static void afterClassClearContext() throws Exception {
JettyUtil.closeServer(ourServer); JettyUtil.closeServer(ourServer);

View File

@ -62,6 +62,7 @@ public class SearchBundleProviderWithNoSizeR4Test {
Bundle respBundle; Bundle respBundle;
ourLastBundleProvider = mock(IBundleProvider.class); ourLastBundleProvider = mock(IBundleProvider.class);
when(ourLastBundleProvider.getCurrentPageOffset()).thenReturn(null);
when(ourLastBundleProvider.size()).thenReturn(null); when(ourLastBundleProvider.size()).thenReturn(null);
when(ourLastBundleProvider.getResources(any(int.class), any(int.class))).then(new Answer<List<IBaseResource>>() { when(ourLastBundleProvider.getResources(any(int.class), any(int.class))).then(new Answer<List<IBaseResource>>() {
@Override @Override