-
Notifications
You must be signed in to change notification settings - Fork 1.9k
fix(database): fix DatabasePagingSource pagination when using orderByChild #2336
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| package com.firebase.ui.database.paging; | ||
|
|
||
| import android.content.Context; | ||
|
|
||
| import androidx.paging.PagingSource; | ||
| import androidx.test.core.app.ApplicationProvider; | ||
| import androidx.test.ext.junit.runners.AndroidJUnit4; | ||
|
|
||
| import com.firebase.ui.database.Bean; | ||
| import com.google.firebase.FirebaseApp; | ||
| import com.google.firebase.FirebaseOptions; | ||
| import com.google.firebase.database.DataSnapshot; | ||
| import com.google.firebase.database.DatabaseError; | ||
| import com.google.firebase.database.DatabaseReference; | ||
| import com.google.firebase.database.FirebaseDatabase; | ||
| import com.google.firebase.database.ValueEventListener; | ||
|
|
||
| import org.junit.After; | ||
| import org.junit.Before; | ||
| import org.junit.Test; | ||
| import org.junit.runner.RunWith; | ||
|
|
||
| import java.util.HashSet; | ||
| import java.util.Set; | ||
| import java.util.concurrent.CountDownLatch; | ||
| import java.util.concurrent.TimeUnit; | ||
|
|
||
| import androidx.annotation.NonNull; | ||
|
|
||
| import static org.junit.Assert.assertEquals; | ||
| import static org.junit.Assert.assertTrue; | ||
|
|
||
| @RunWith(AndroidJUnit4.class) | ||
| public class DatabasePagingSourceTest { | ||
| private static final String APP_NAME = "firebaseui-paging-tests"; | ||
| private static final int PAGE_SIZE = 3; | ||
| private static final int TOTAL_ITEMS = 6; | ||
|
|
||
| private DatabaseReference mRef; | ||
|
|
||
| @Before | ||
| public void setUp() throws InterruptedException { | ||
| FirebaseApp app; | ||
| try { | ||
| app = FirebaseApp.getInstance(APP_NAME); | ||
| } catch (IllegalStateException e) { | ||
| Context context = ApplicationProvider.getApplicationContext(); | ||
| app = FirebaseApp.initializeApp(context, new FirebaseOptions.Builder() | ||
| .setApplicationId("fir-ui-tests") | ||
| .setDatabaseUrl("https://fir-ui-tests.firebaseio.com/") | ||
| .build(), APP_NAME); | ||
| } | ||
|
|
||
| mRef = FirebaseDatabase.getInstance(app).getReference().child("paging_test"); | ||
|
|
||
| mRef.removeValue(); | ||
| for (int i = 1; i <= TOTAL_ITEMS; i++) { | ||
| mRef.push().setValue(new Bean(i)); | ||
| } | ||
|
|
||
| CountDownLatch latch = new CountDownLatch(1); | ||
| mRef.addValueEventListener(new ValueEventListener() { | ||
| @Override | ||
| public void onDataChange(@NonNull DataSnapshot snapshot) { | ||
| if (snapshot.getChildrenCount() >= TOTAL_ITEMS) { | ||
| mRef.removeEventListener(this); | ||
| latch.countDown(); | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public void onCancelled(@NonNull DatabaseError error) {} | ||
| }); | ||
| assertTrue("Timed out seeding test data", latch.await(30, TimeUnit.SECONDS)); | ||
| } | ||
|
|
||
| @After | ||
| public void tearDown() { | ||
| mRef.removeValue(); | ||
| } | ||
|
|
||
| @Test | ||
| public void testOrderByChild_noDuplicatesAcrossPages() { | ||
| DatabasePagingSource source = new DatabasePagingSource(mRef.orderByChild("number")); | ||
|
|
||
| PagingSource.LoadResult<DatabasePagingKey, DataSnapshot> result1 = | ||
| source.loadSingle(new PagingSource.LoadParams.Refresh<>(null, PAGE_SIZE, false)) | ||
| .timeout(30, TimeUnit.SECONDS) | ||
| .blockingGet(); | ||
|
|
||
| assertTrue(result1 instanceof PagingSource.LoadResult.Page); | ||
| PagingSource.LoadResult.Page<DatabasePagingKey, DataSnapshot> page1 = | ||
| (PagingSource.LoadResult.Page<DatabasePagingKey, DataSnapshot>) result1; | ||
| assertEquals(PAGE_SIZE, page1.getData().size()); | ||
|
|
||
| DatabasePagingKey nextKey = page1.getNextKey(); | ||
|
|
||
| PagingSource.LoadResult<DatabasePagingKey, DataSnapshot> result2 = | ||
| source.loadSingle(new PagingSource.LoadParams.Append<>(nextKey, PAGE_SIZE, false)) | ||
| .timeout(30, TimeUnit.SECONDS) | ||
| .blockingGet(); | ||
|
|
||
| assertTrue(result2 instanceof PagingSource.LoadResult.Page); | ||
| PagingSource.LoadResult.Page<DatabasePagingKey, DataSnapshot> page2 = | ||
| (PagingSource.LoadResult.Page<DatabasePagingKey, DataSnapshot>) result2; | ||
|
|
||
| Set<String> allKeys = new HashSet<>(); | ||
| for (DataSnapshot snapshot : page1.getData()) { | ||
| allKeys.add(snapshot.getKey()); | ||
| } | ||
| for (DataSnapshot snapshot : page2.getData()) { | ||
| assertTrue("Duplicate key across pages: " + snapshot.getKey(), | ||
| allKeys.add(snapshot.getKey())); | ||
| } | ||
| assertEquals(TOTAL_ITEMS, allKeys.size()); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package com.firebase.ui.database.paging; | ||
|
|
||
| import java.util.Objects; | ||
|
|
||
| public class DatabasePagingKey { | ||
| private final Object mChildValue; | ||
| private final String mNodeKey; | ||
|
|
||
| public DatabasePagingKey(Object childValue, String nodeKey) { | ||
| mChildValue = childValue; | ||
| mNodeKey = nodeKey; | ||
| } | ||
|
|
||
| public Object getChildValue() { | ||
| return mChildValue; | ||
| } | ||
|
|
||
| public String getNodeKey() { | ||
| return mNodeKey; | ||
| } | ||
|
|
||
| @Override | ||
| public boolean equals(Object o) { | ||
| if (this == o) return true; | ||
| if (o == null || getClass() != o.getClass()) return false; | ||
| DatabasePagingKey that = (DatabasePagingKey) o; | ||
| return Objects.equals(mChildValue, that.mChildValue) && | ||
| Objects.equals(mNodeKey, that.mNodeKey); | ||
| } | ||
|
|
||
| @Override | ||
| public int hashCode() { | ||
| return Objects.hash(mChildValue, mNodeKey); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,8 @@ | |
| import com.google.firebase.database.DataSnapshot; | ||
| import com.google.firebase.database.DatabaseError; | ||
| import com.google.firebase.database.Query; | ||
| import com.google.firebase.database.snapshot.Index; | ||
| import com.google.firebase.database.snapshot.PathIndex; | ||
|
|
||
| import org.jetbrains.annotations.Nullable; | ||
|
|
||
|
|
@@ -21,7 +23,7 @@ | |
| import io.reactivex.rxjava3.core.Single; | ||
| import io.reactivex.rxjava3.schedulers.Schedulers; | ||
|
|
||
| public class DatabasePagingSource extends RxPagingSource<String, DataSnapshot> { | ||
| public class DatabasePagingSource extends RxPagingSource<DatabasePagingKey, DataSnapshot> { | ||
| private final Query mQuery; | ||
|
|
||
| private static final String STATUS_DATABASE_NOT_FOUND = "DATA_NOT_FOUND"; | ||
|
|
@@ -33,28 +35,36 @@ public DatabasePagingSource(Query query) { | |
| } | ||
|
|
||
| /** | ||
| * DatabaseError.fromStatus() is not meant to be public. | ||
| * DatabaseError.fromStatus() and PathIndex are not meant to be public. | ||
| */ | ||
| @SuppressLint("RestrictedApi") | ||
| @NonNull | ||
| @Override | ||
| public Single<LoadResult<String, DataSnapshot>> loadSingle(@NonNull LoadParams<String> params) { | ||
| public Single<LoadResult<DatabasePagingKey, DataSnapshot>> loadSingle( | ||
| @NonNull LoadParams<DatabasePagingKey> params) { | ||
| PathIndex pathIndex = getPathIndex(); | ||
| Task<DataSnapshot> task; | ||
|
|
||
| if (params.getKey() == null) { | ||
| task = mQuery.limitToFirst(params.getLoadSize()).get(); | ||
| } else { | ||
| task = mQuery.startAt(null, params.getKey()).limitToFirst(params.getLoadSize() + 1).get(); | ||
| DatabasePagingKey key = params.getKey(); | ||
| if (pathIndex != null) { | ||
| task = startAtChildValue(key.getChildValue(), key.getNodeKey()) | ||
| .limitToFirst(params.getLoadSize() + 1).get(); | ||
| } else { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For non- |
||
| task = mQuery.startAt(null, key.getNodeKey()) | ||
| .limitToFirst(params.getLoadSize() + 1).get(); | ||
| } | ||
| } | ||
|
|
||
| return Single.fromCallable(() -> { | ||
| try { | ||
| Tasks.await(task); | ||
| DataSnapshot dataSnapshot = task.getResult(); | ||
| if (dataSnapshot.exists()) { | ||
|
|
||
| //Make List of DataSnapshot | ||
| List<DataSnapshot> data = new ArrayList<>(); | ||
| String lastKey = null; | ||
| DatabasePagingKey lastKey = null; | ||
|
|
||
| if (params.getKey() == null) { | ||
| for (DataSnapshot snapshot : dataSnapshot.getChildren()) { | ||
|
|
@@ -69,15 +79,16 @@ public Single<LoadResult<String, DataSnapshot>> loadSingle(@NonNull LoadParams<S | |
| } | ||
|
|
||
| while (iterator.hasNext()) { | ||
| DataSnapshot snapshot = iterator.next(); | ||
| data.add(snapshot); | ||
| data.add(iterator.next()); | ||
| } | ||
| } | ||
|
|
||
| //Detect End of Data | ||
| if (!data.isEmpty()) { | ||
| //Get Last Key | ||
| lastKey = getLastPageKey(data); | ||
| DataSnapshot last = data.get(data.size() - 1); | ||
| Object childValue = pathIndex != null | ||
| ? getChildValue(last, pathIndex) : null; | ||
| lastKey = new DatabasePagingKey(childValue, last.getKey()); | ||
| } | ||
| return toLoadResult(data, lastKey); | ||
| } else { | ||
|
|
@@ -89,19 +100,39 @@ public Single<LoadResult<String, DataSnapshot>> loadSingle(@NonNull LoadParams<S | |
| } | ||
| } catch (ExecutionException e) { | ||
| if (e.getCause() instanceof Exception) { | ||
| // throw the original Exception | ||
| throw (Exception) e.getCause(); | ||
| } | ||
| // Only throw a new Exception when the original | ||
| // Throwable cannot be cast to Exception | ||
| throw new Exception(e); | ||
| } | ||
| }).subscribeOn(Schedulers.io()).onErrorReturn(LoadResult.Error::new); | ||
| } | ||
|
|
||
| private LoadResult<String, DataSnapshot> toLoadResult( | ||
| @SuppressLint("RestrictedApi") | ||
| private PathIndex getPathIndex() { | ||
| Index index = mQuery.getSpec().getIndex(); | ||
| return index instanceof PathIndex ? (PathIndex) index : null; | ||
| } | ||
|
|
||
| @SuppressLint("RestrictedApi") | ||
| private Object getChildValue(DataSnapshot snapshot, PathIndex pathIndex) { | ||
| return snapshot.child(pathIndex.getQueryDefinition()).getValue(); | ||
| } | ||
|
|
||
| @SuppressLint("RestrictedApi") | ||
| private Query startAtChildValue(Object childValue, String nodeKey) { | ||
| if (childValue instanceof String) { | ||
| return mQuery.startAt((String) childValue, nodeKey); | ||
| } else if (childValue instanceof Boolean) { | ||
| return mQuery.startAt((Boolean) childValue, nodeKey); | ||
| } else if (childValue instanceof Number) { | ||
| return mQuery.startAt(((Number) childValue).doubleValue(), nodeKey); | ||
| } | ||
| return mQuery.startAt(null, nodeKey); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hmm - looks like this fallback to |
||
| } | ||
|
demolaf marked this conversation as resolved.
|
||
|
|
||
| private LoadResult<DatabasePagingKey, DataSnapshot> toLoadResult( | ||
| @NonNull List<DataSnapshot> snapshots, | ||
| String nextPage | ||
| DatabasePagingKey nextPage | ||
| ) { | ||
| return new LoadResult.Page<>( | ||
| snapshots, | ||
|
|
@@ -111,18 +142,10 @@ private LoadResult<String, DataSnapshot> toLoadResult( | |
| LoadResult.Page.COUNT_UNDEFINED); | ||
| } | ||
|
|
||
| @Nullable | ||
| private String getLastPageKey(@NonNull List<DataSnapshot> data) { | ||
| if (data.isEmpty()) { | ||
| return null; | ||
| } else { | ||
| return data.get(data.size() - 1).getKey(); | ||
| } | ||
| } | ||
|
|
||
| @Nullable | ||
| @Override | ||
| public String getRefreshKey(@NonNull PagingState<String, DataSnapshot> state) { | ||
| public DatabasePagingKey getRefreshKey( | ||
| @NonNull PagingState<DatabasePagingKey, DataSnapshot> state) { | ||
| return null; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
curious if using s separate app is a deliberate choice, if not TestUtils has app init code in
TestUtils.getAppInstance()