From 181e4a0142379929a79912d3cea4a266ccb95f75 Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Thu, 28 Jul 2022 19:54:00 +0200 Subject: [PATCH] Fix Paging 3 (#3396) * Copy-paste Room LimitOffsetPagingSource.kt and associated files https://github.com/androidx/androidx/tree/androidx-main/room/room-paging/src/main/java/androidx/room/paging * Don't limit to library scope * Remove unnecessary constructor * Remove unused argument * Remove usages of getQueryDispatcher * Remove usages of androidx.room.withTransaction * Remove usage of app.cash.sqldelight.paging3.util.queryItemCount * Rename Value to RowType to align with SQLDelight * Pass in a QueryProvider and then remove all fluff * Put queryDatabase within LimitOffsetPagingSource * Use QueryPagingSource as the invalidater * Simplify * Copy-paste tests from AndroidX * Fix test compilation * Spotless * Update extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt Co-authored-by: Niklas Baudy * Update extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt Co-authored-by: Niklas Baudy * Apply suggestions from code review Co-authored-by: Niklas Baudy * Make the artifact a JVM one again Co-authored-by: Niklas Baudy --- .../android-paging3/android-test/build.gradle | 26 + .../paging3/OffsetQueryPagingSourceTest.kt | 722 ++++++++++++++++++ .../paging3/WithPagingDataDiffer.kt | 52 ++ .../paging3/OffsetQueryPagingSource.kt | 50 +- .../paging3/OffsetQueryPagingSourceTest.kt | 287 ------- gradle/libs.versions.toml | 1 + settings.gradle | 1 + 7 files changed, 827 insertions(+), 312 deletions(-) create mode 100644 extensions/android-paging3/android-test/build.gradle create mode 100644 extensions/android-paging3/android-test/src/test/java/app/cash/sqldelight/paging3/OffsetQueryPagingSourceTest.kt create mode 100644 extensions/android-paging3/android-test/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt delete mode 100644 extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/OffsetQueryPagingSourceTest.kt diff --git a/extensions/android-paging3/android-test/build.gradle b/extensions/android-paging3/android-test/build.gradle new file mode 100644 index 00000000000..de12cac69e9 --- /dev/null +++ b/extensions/android-paging3/android-test/build.gradle @@ -0,0 +1,26 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) +} + +android { + namespace 'app.cash.sqldelight.paging3' + compileSdk deps.versions.compileSdk.get() as int +} + +dependencies { + testImplementation project(':drivers:sqlite-driver') + testImplementation project(':extensions:android-paging3') + testImplementation deps.androidx.paging3.runtime + testImplementation deps.androidx.recyclerView + testImplementation deps.truth + testImplementation deps.kotlin.test.junit + testImplementation deps.kotlin.coroutines.test +} + +// workaround for https://youtrack.jetbrains.com/issue/KT-27059 +configurations.all { + resolutionStrategy.dependencySubstitution { + substitute module("${project.property("GROUP")}:runtime-jvm:${project.property("VERSION_NAME")}") with project(':runtime') + } +} diff --git a/extensions/android-paging3/android-test/src/test/java/app/cash/sqldelight/paging3/OffsetQueryPagingSourceTest.kt b/extensions/android-paging3/android-test/src/test/java/app/cash/sqldelight/paging3/OffsetQueryPagingSourceTest.kt new file mode 100644 index 00000000000..f72725eb3a5 --- /dev/null +++ b/extensions/android-paging3/android-test/src/test/java/app/cash/sqldelight/paging3/OffsetQueryPagingSourceTest.kt @@ -0,0 +1,722 @@ +/* + * Copyright (C) 2016 Square, 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. + */ +package app.cash.sqldelight.paging3 + +import androidx.paging.LoadType +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingSource.LoadResult +import androidx.paging.PagingState +import androidx.recyclerview.widget.DiffUtil +import app.cash.sqldelight.Query +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.TransacterImpl +import app.cash.sqldelight.db.SqlCursor +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +@ExperimentalCoroutinesApi +class OffsetQueryPagingSourceTest { + + private lateinit var driver: SqlDriver + private lateinit var transacter: Transacter + + @Before + fun init() { + Dispatchers.setMain(StandardTestDispatcher()) + driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + driver.execute(null, "CREATE TABLE TestItem(id INTEGER NOT NULL PRIMARY KEY);", 0) + transacter = object : TransacterImpl(driver) {} + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun test_itemCount() = runTest { + insertItems(ITEMS_LIST) + + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + pagingSource.refresh() + + Pager(CONFIG, pagingSourceFactory = { pagingSource }) + .flow + .first() + .withPagingDataDiffer(this, testItemDiffCallback) { + assertThat(itemCount).isEqualTo(100) + } + } + + @Test + fun invalidDbQuery_pagingSourceDoesNotInvalidate() = runTest { + insertItems(ITEMS_LIST) + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + // load once to register db observers + pagingSource.refresh() + assertThat(pagingSource.invalid).isFalse() + + val result = deleteItem(TestItem(1000)) + + // invalid delete. Should have 0 items deleted and paging source remains valid + assertThat(result).isEqualTo(0) + assertThat(pagingSource.invalid).isFalse() + } + + @Test + fun load_initialLoad() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + insertItems(ITEMS_LIST) + val result = pagingSource.refresh() as LoadResult.Page + + assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(0, 15)) + } + + @Test + fun load_initialEmptyLoad() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + val result = pagingSource.refresh() as LoadResult.Page + + assertTrue(result.data.isEmpty()) + + // now add items + insertItems(ITEMS_LIST) + + // invalidate pagingSource to imitate invalidation from running refreshVersionSync + pagingSource.invalidate() + assertTrue(pagingSource.invalid) + + // this refresh should check pagingSource's invalid status, realize it is invalid, and + // return a LoadResult.Invalid + assertThat(pagingSource.refresh()).isInstanceOf(LoadResult.Invalid::class.java) + } + + @Test + fun load_initialLoadWithInitialKey() = runTest { + insertItems(ITEMS_LIST) + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + // refresh with initial key = 20 + val result = pagingSource.refresh(key = 20) as LoadResult.Page + + // item in pos 21-35 (TestItemId 20-34) loaded + assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(20, 35)) + } + + @Test + fun invalidInitialKey_dbEmpty_returnsEmpty() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + val result = pagingSource.refresh(key = 101) as LoadResult.Page + + assertThat(result.data).isEmpty() + } + + @Test + fun invalidInitialKey_keyTooLarge_returnsLastPage() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + insertItems(ITEMS_LIST) + val result = pagingSource.refresh(key = 101) as LoadResult.Page + + // should load the last page + assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(85, 100)) + } + + @Test + fun invalidInitialKey_negativeKey() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + insertItems(ITEMS_LIST) + // should throw error when initial key is negative + val expectedException = assertFailsWith { + pagingSource.refresh(key = -1) + } + // default message from Paging 3 for negative initial key + assertThat(expectedException.message).isEqualTo("itemsBefore cannot be negative") + } + + @Test + fun append_middleOfList() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + insertItems(ITEMS_LIST) + val result = pagingSource.append(key = 20) as LoadResult.Page + + // item in pos 21-25 (TestItemId 20-24) loaded + assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(20, 25)) + assertThat(result.nextKey).isEqualTo(25) + assertThat(result.prevKey).isEqualTo(20) + } + + @Test + fun append_availableItemsLessThanLoadSize() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + insertItems(ITEMS_LIST) + val result = pagingSource.append(key = 97) as LoadResult.Page + + // item in pos 98-100 (TestItemId 97-99) loaded + assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(97, 100)) + assertThat(result.nextKey).isNull() + assertThat(result.prevKey).isEqualTo(97) + } + + @Test + fun load_consecutiveAppend() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + insertItems(ITEMS_LIST) + // first append + val result = pagingSource.append(key = 30) as LoadResult.Page + + // TestItemId 30-34 loaded + assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(30, 35)) + // second append using nextKey from previous load + val result2 = pagingSource.append(key = result.nextKey) as LoadResult.Page + + // TestItemId 35 - 39 loaded + assertThat(result2.data).containsExactlyElementsIn(ITEMS_LIST.subList(35, 40)) + } + + @Test + fun append_invalidResult() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + insertItems(ITEMS_LIST) + // first append + val result = pagingSource.append(key = 30) as LoadResult.Page + + // TestItemId 30-34 loaded + assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(30, 35)) + + // invalidate pagingSource to imitate invalidation from running refreshVersionSync + pagingSource.invalidate() + + // this append should check pagingSource's invalid status, realize it is invalid, and + // return a LoadResult.Invalid + val result2 = pagingSource.append(key = result.nextKey) + + assertThat(result2).isInstanceOf(LoadResult.Invalid::class.java) + } + + @Test + fun prepend_middleOfList() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + insertItems(ITEMS_LIST) + val result = pagingSource.prepend(key = 30) as LoadResult.Page + + assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(25, 30)) + assertThat(result.nextKey).isEqualTo(30) + assertThat(result.prevKey).isEqualTo(25) + } + + @Test + fun prepend_availableItemsLessThanLoadSize() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + insertItems(ITEMS_LIST) + val result = pagingSource.prepend(key = 3) as LoadResult.Page + + // items in pos 0 - 2 (TestItemId 0 - 2) loaded + assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(0, 3)) + assertThat(result.nextKey).isEqualTo(3) + assertThat(result.prevKey).isNull() + } + + @Test + fun load_consecutivePrepend() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + insertItems(ITEMS_LIST) + // first prepend + val result = pagingSource.prepend(key = 20) as LoadResult.Page + + // items pos 16-20 (TestItemId 15-19) loaded + assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(15, 20)) + // second prepend using prevKey from previous load + val result2 = pagingSource.prepend(key = result.prevKey) as LoadResult.Page + + // items pos 11-15 (TestItemId 10 - 14) loaded + assertThat(result2.data).containsExactlyElementsIn(ITEMS_LIST.subList(10, 15)) + } + + @Test + fun prepend_invalidResult() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + insertItems(ITEMS_LIST) + // first prepend + val result = pagingSource.prepend(key = 20) as LoadResult.Page + + // items pos 16-20 (TestItemId 15-19) loaded + assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(15, 20)) + + // invalidate pagingSource to imitate invalidation from running refreshVersionSync + pagingSource.invalidate() + + // this prepend should check pagingSource's invalid status, realize it is invalid, and + // return LoadResult.Invalid + val result2 = pagingSource.prepend(key = result.prevKey) + + assertThat(result2).isInstanceOf(LoadResult.Invalid::class.java) + } + + @Test + fun test_itemsBefore() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + insertItems(ITEMS_LIST) + // for initial load + val result = pagingSource.refresh(key = 50) as LoadResult.Page + + // initial loads items in pos 51 - 65, should have 50 items before + assertThat(result.itemsBefore).isEqualTo(50) + + // prepend from initial load + val result2 = pagingSource.prepend(key = result.prevKey) as LoadResult.Page + + // prepend loads items in pos 46 - 50, should have 45 item before + assertThat(result2.itemsBefore).isEqualTo(45) + + // append from initial load + val result3 = pagingSource.append(key = result.nextKey) as LoadResult.Page + + // append loads items in position 66 - 70 , should have 65 item before + assertThat(result3.itemsBefore).isEqualTo(65) + } + + @Test + fun test_itemsAfter() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + insertItems(ITEMS_LIST) + // for initial load + val result = pagingSource.refresh(key = 30) as LoadResult.Page + + // initial loads items in position 31 - 45, should have 55 items after + assertThat(result.itemsAfter).isEqualTo(55) + + // prepend from initial load + val result2 = pagingSource.prepend(key = result.prevKey) as LoadResult.Page + + // prepend loads items in position 26 - 30, should have 70 item after + assertThat(result2.itemsAfter).isEqualTo(70) + + // append from initial load + val result3 = pagingSource.append(result.nextKey) as LoadResult.Page + + // append loads items in position 46 - 50 , should have 50 item after + assertThat(result3.itemsAfter).isEqualTo(50) + } + + @Test + fun test_getRefreshKey() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + insertItems(ITEMS_LIST) + // initial load + val result = pagingSource.refresh() as LoadResult.Page + // 15 items loaded, assuming anchorPosition = 14 as the last item loaded + var refreshKey = pagingSource.getRefreshKey( + PagingState( + pages = listOf(result), + anchorPosition = 14, + config = CONFIG, + leadingPlaceholderCount = 0, + ), + ) + // should load around anchor position + // Initial load size = 15, refresh key should be (15/2 = 7) items + // before anchorPosition (14 - 7 = 7) + assertThat(refreshKey).isEqualTo(7) + + // append after refresh + val result2 = pagingSource.append(key = result.nextKey) as LoadResult.Page + + assertThat(result2.data).isEqualTo(ITEMS_LIST.subList(15, 20)) + refreshKey = pagingSource.getRefreshKey( + PagingState( + pages = listOf(result, result2), + // 20 items loaded, assume anchorPosition = 19 as the last item loaded + anchorPosition = 19, + config = CONFIG, + leadingPlaceholderCount = 0, + ), + ) + // initial load size 15. Refresh key should be (15/2 = 7) items before anchorPosition + // (19 - 7 = 12) + assertThat(refreshKey).isEqualTo(12) + } + + @Test + fun load_refreshKeyGreaterThanItemCount_lastPage() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + insertItems(ITEMS_LIST) + pagingSource.refresh(key = 70) + + deleteItems(40..100) + + // assume user was viewing last item of the refresh load with anchorPosition = 85, + // initialLoadSize = 15. This mimics how getRefreshKey() calculates refresh key. + val refreshKey = 85 - (15 / 2) + assertThat(refreshKey).isEqualTo(78) + + val pagingSource2 = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + val result2 = pagingSource2.refresh(key = refreshKey) as LoadResult.Page + + // database should only have 40 items left. Refresh key is invalid at this point + // (greater than item count after deletion) + Pager(CONFIG, pagingSourceFactory = { pagingSource2 }) + .flow + .first() + .withPagingDataDiffer(this, testItemDiffCallback) { + assertThat(itemCount).isEqualTo(40) + } + // ensure that paging source can handle invalid refresh key properly + // should load last page with items 25 - 40 + assertThat(result2.data).containsExactlyElementsIn(ITEMS_LIST.subList(25, 40)) + + // should account for updated item count to return correct itemsBefore, itemsAfter, + // prevKey, nextKey + assertThat(result2.itemsBefore).isEqualTo(25) + assertThat(result2.itemsAfter).isEqualTo(0) + // no append can be triggered + assertThat(result2.prevKey).isEqualTo(25) + assertThat(result2.nextKey).isNull() + } + + /** + * Tests the behavior if user was viewing items in the top of the database and those items + * were deleted. + * + * Currently, if anchorPosition is small enough (within bounds of 0 to loadSize/2), then on + * invalidation from dropped items at the top, refresh will load with offset = 0. If + * anchorPosition is larger than loadsize/2, then the refresh load's offset will + * be 0 to (anchorPosition - loadSize/2). + * + * Ideally, in the future Paging will be able to handle this case better. + */ + @Test + fun load_refreshKeyGreaterThanItemCount_firstPage() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + insertItems(ITEMS_LIST) + pagingSource.refresh() + + Pager(CONFIG, pagingSourceFactory = { pagingSource }) + .flow + .first() + .withPagingDataDiffer(this, testItemDiffCallback) { + assertThat(itemCount).isEqualTo(100) + } + + // items id 0 - 29 deleted (30 items removed) + deleteItems(0..29) + + val pagingSource2 = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + // assume user was viewing first few items with anchorPosition = 0 and refresh key + // clips to 0 + val refreshKey = 0 + + val result2 = pagingSource2.refresh(key = refreshKey) as LoadResult.Page + + // database should only have 70 items left + Pager(CONFIG, pagingSourceFactory = { pagingSource2 }) + .flow + .first() + .withPagingDataDiffer(this, testItemDiffCallback) { + assertThat(itemCount).isEqualTo(70) + } + // first 30 items deleted, refresh should load starting from pos 31 (item id 30 - 45) + assertThat(result2.data).containsExactlyElementsIn(ITEMS_LIST.subList(30, 45)) + + // should account for updated item count to return correct itemsBefore, itemsAfter, + // prevKey, nextKey + assertThat(result2.itemsBefore).isEqualTo(0) + assertThat(result2.itemsAfter).isEqualTo(55) + // no prepend can be triggered + assertThat(result2.prevKey).isNull() + assertThat(result2.nextKey).isEqualTo(15) + } + + @Test + fun load_loadSizeAndRefreshKeyGreaterThanItemCount() = runTest { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + insertItems(ITEMS_LIST) + pagingSource.refresh(key = 30) + + Pager(CONFIG, pagingSourceFactory = { pagingSource }) + .flow + .first() + .withPagingDataDiffer(this, testItemDiffCallback) { + assertThat(itemCount).isEqualTo(100) + } + // items id 0 - 94 deleted (95 items removed) + deleteItems(0..94) + + val pagingSource2 = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + // assume user was viewing first few items with anchorPosition = 0 and refresh key + // clips to 0 + val refreshKey = 0 + + val result2 = pagingSource2.refresh(key = refreshKey) as LoadResult.Page + + // database should only have 5 items left + Pager(CONFIG, pagingSourceFactory = { pagingSource2 }) + .flow + .first() + .withPagingDataDiffer(this, testItemDiffCallback) { + assertThat(itemCount).isEqualTo(5) + } + // only 5 items should be loaded with offset = 0 + assertThat(result2.data).containsExactlyElementsIn(ITEMS_LIST.subList(95, 100)) + + // should recognize that this is a terminal load + assertThat(result2.itemsBefore).isEqualTo(0) + assertThat(result2.itemsAfter).isEqualTo(0) + assertThat(result2.prevKey).isNull() + assertThat(result2.nextKey).isNull() + } + + @Test + fun test_jumpSupport() { + val pagingSource = QueryPagingSource( + countQuery(), + transacter, + EmptyCoroutineContext, + ::query, + ) + assertTrue(pagingSource.jumpingSupported) + } + + private fun query(limit: Int, offset: Int) = object : Query( + { cursor -> + TestItem(cursor.getLong(0)!!) + }, + ) { + override fun execute(mapper: (SqlCursor) -> R) = driver.executeQuery(1, "SELECT id FROM TestItem LIMIT ? OFFSET ?", mapper, 2) { + bindLong(0, limit.toLong()) + bindLong(1, offset.toLong()) + } + + override fun addListener(listener: Listener) = driver.addListener(listener, arrayOf("TestItem")) + override fun removeListener(listener: Listener) = driver.removeListener(listener, arrayOf("TestItem")) + } + + private fun countQuery() = Query( + 2, + arrayOf("TestItem"), + driver, + "Test.sq", + "count", + "SELECT count(*) FROM TestItem", + { it.getLong(0)!!.toInt() }, + ) + + private fun insertItems(items: List) { + items.forEach { + driver.execute(0, "INSERT INTO TestItem (id) VALUES (?)", 1) { + bindLong(0, it.id) + } + } + } + + private fun deleteItem(item: TestItem): Long = + driver + .execute(0, "DELETE FROM TestItem WHERE id = ?;", 1) { + bindLong(0, item.id) + } + .value + + private fun deleteItems(range: IntRange): Long = + driver + .execute(0, "DELETE FROM TestItem WHERE id >= ? AND id <= ?", 2) { + bindLong(0, range.first.toLong()) + bindLong(1, range.last.toLong()) + } + .value +} + +private val CONFIG = PagingConfig( + pageSize = 5, + enablePlaceholders = true, + initialLoadSize = 15, +) + +private val ITEMS_LIST = List(100) { TestItem(id = it.toLong()) } + +private val testItemDiffCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: TestItem, newItem: TestItem): Boolean = oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: TestItem, newItem: TestItem): Boolean = oldItem == newItem +} + +data class TestItem(val id: Long) + +private fun createLoadParam(loadType: LoadType, key: Int?): PagingSource.LoadParams = when (loadType) { + LoadType.REFRESH -> PagingSource.LoadParams.Refresh( + key = key, + loadSize = CONFIG.initialLoadSize, + placeholdersEnabled = CONFIG.enablePlaceholders, + ) + + LoadType.APPEND -> PagingSource.LoadParams.Append( + key = key ?: -1, + loadSize = CONFIG.pageSize, + placeholdersEnabled = CONFIG.enablePlaceholders, + ) + + LoadType.PREPEND -> PagingSource.LoadParams.Prepend( + key = key ?: -1, + loadSize = CONFIG.pageSize, + placeholdersEnabled = CONFIG.enablePlaceholders, + ) +} + +private suspend fun PagingSource.refresh(key: Int? = null): LoadResult = + load(createLoadParam(LoadType.REFRESH, key)) + +private suspend fun PagingSource.append(key: Int?): LoadResult = + load(createLoadParam(LoadType.APPEND, key)) + +private suspend fun PagingSource.prepend(key: Int?): LoadResult = + load(createLoadParam(LoadType.PREPEND, key)) diff --git a/extensions/android-paging3/android-test/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt b/extensions/android-paging3/android-test/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt new file mode 100644 index 00000000000..53c5cc5bd83 --- /dev/null +++ b/extensions/android-paging3/android-test/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2016 Square, 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. + */ +package app.cash.sqldelight.paging3 + +import androidx.paging.AsyncPagingDataDiffer +import androidx.paging.PagingData +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle + +private object NoopListCallback : ListUpdateCallback { + override fun onChanged(position: Int, count: Int, payload: Any?) = Unit + override fun onMoved(fromPosition: Int, toPosition: Int) = Unit + override fun onInserted(position: Int, count: Int) = Unit + override fun onRemoved(position: Int, count: Int) = Unit +} + +@ExperimentalCoroutinesApi +fun PagingData.withPagingDataDiffer( + testScope: TestScope, + diffCallback: DiffUtil.ItemCallback, + block: AsyncPagingDataDiffer.() -> Unit, +) { + val pagingDataDiffer = AsyncPagingDataDiffer( + diffCallback, + NoopListCallback, + workerDispatcher = Dispatchers.Main, + ) + val job = testScope.launch { + pagingDataDiffer.submitData(this@withPagingDataDiffer) + } + testScope.advanceUntilIdle() + block(pagingDataDiffer) + job.cancel() +} diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/OffsetQueryPagingSource.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/OffsetQueryPagingSource.kt index 1495e713296..8d575c081da 100644 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/OffsetQueryPagingSource.kt +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/OffsetQueryPagingSource.kt @@ -33,33 +33,33 @@ internal class OffsetQueryPagingSource( override suspend fun load( params: LoadParams, ): LoadResult = withContext(context) { - try { - val key = params.key ?: 0 - transacter.transactionWithResult { - val count = countQuery.executeAsOne() - if (count != 0 && key >= count) throw IndexOutOfBoundsException() - - val loadSize = if (key < 0) params.loadSize + key else params.loadSize - - val data = queryProvider(loadSize, maxOf(0, key)) - .also { currentQuery = it } - .executeAsList() - - LoadResult.Page( - data = data, - // allow one, and only one negative prevKey in a paging set. This is done for - // misaligned prepend queries to avoid duplicates. - prevKey = if (key <= 0L) null else key - params.loadSize, - nextKey = if (key + params.loadSize >= count) null else key + params.loadSize, - itemsBefore = maxOf(0, key), - itemsAfter = maxOf(0, (count - (key + params.loadSize))), - ) + val key = params.key ?: 0 + val limit = when (params) { + is LoadParams.Prepend -> minOf(key, params.loadSize) + else -> params.loadSize + } + val loadResult = transacter.transactionWithResult { + val count = countQuery.executeAsOne() + val offset = when (params) { + is LoadParams.Prepend -> maxOf(0, key - params.loadSize) + is LoadParams.Append -> key + is LoadParams.Refresh -> if (key >= count) maxOf(0, count - params.loadSize) else key } - } catch (e: Exception) { - if (e is IndexOutOfBoundsException) throw e - LoadResult.Error(e) + val data = queryProvider(limit, offset) + .also { currentQuery = it } + .executeAsList() + val nextPosToLoad = offset + data.size + LoadResult.Page( + data = data, + prevKey = offset.takeIf { it > 0 && data.isNotEmpty() }, + nextKey = nextPosToLoad.takeIf { data.isNotEmpty() && data.size >= limit && it < count }, + itemsBefore = offset, + itemsAfter = maxOf(0, count - nextPosToLoad), + ) } + if (invalid) LoadResult.Invalid() else loadResult } - override fun getRefreshKey(state: PagingState) = state.anchorPosition + override fun getRefreshKey(state: PagingState) = + state.anchorPosition?.let { maxOf(0, it - (state.config.initialLoadSize / 2)) } } diff --git a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/OffsetQueryPagingSourceTest.kt b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/OffsetQueryPagingSourceTest.kt deleted file mode 100644 index d83a457d6e1..00000000000 --- a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/OffsetQueryPagingSourceTest.kt +++ /dev/null @@ -1,287 +0,0 @@ -/* - * Copyright (C) 2016 Square, 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. - */ -package app.cash.sqldelight.paging3 - -import androidx.paging.PagingSource.LoadParams.Refresh -import androidx.paging.PagingSource.LoadResult -import app.cash.sqldelight.Query -import app.cash.sqldelight.Transacter -import app.cash.sqldelight.TransacterImpl -import app.cash.sqldelight.db.SqlCursor -import app.cash.sqldelight.db.SqlDriver -import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import kotlin.coroutines.EmptyCoroutineContext -import kotlin.test.assertFailsWith - -@ExperimentalCoroutinesApi -class OffsetQueryPagingSourceTest { - - private lateinit var driver: SqlDriver - private lateinit var transacter: Transacter - - @Before fun before() { - driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) - driver.execute(null, "CREATE TABLE testTable(value INTEGER PRIMARY KEY)", 0) - (0L until 10L).forEach { this.insert(it) } - transacter = object : TransacterImpl(driver) {} - } - - @Test fun `empty page gives correct prevKey and nextKey`() { - driver.execute(null, "DELETE FROM testTable", 0) - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) - - val results = runBlocking { source.load(Refresh(null, 2, false)) } - - assertNull((results as LoadResult.Page).prevKey) - assertNull(results.nextKey) - } - - @Test fun `aligned first page gives correct prevKey and nextKey`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) - - val results = runBlocking { source.load(Refresh(null, 2, false)) } - - assertNull((results as LoadResult.Page).prevKey) - assertEquals(2, results.nextKey) - } - - @Test fun `aligned last page gives correct prevKey and nextKey`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) - - val results = runBlocking { source.load(Refresh(8, 2, false)) } - - assertEquals(6, (results as LoadResult.Page).prevKey) - assertNull(results.nextKey) - } - - @Test fun `simple sequential page exhaustion gives correct results`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) - - runBlocking { - val expected = (0 until 10).chunked(2).iterator() - var nextKey: Int? = null - do { - val results = source.load(Refresh(nextKey, 2, false)) - assertEquals(expected.next(), (results as LoadResult.Page).data) - nextKey = results.nextKey - 1L.toInt() - } while (nextKey != null) - } - } - - @Test fun `misaligned refresh at end page boundary gives null nextKey`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) - - val results = runBlocking { source.load(Refresh(9, 2, false)) } - - assertEquals(7, (results as LoadResult.Page).prevKey) - assertNull(results.nextKey) - } - - @Test fun `misaligned refresh at first page boundary gives proper prevKey`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) - - val results = runBlocking { source.load(Refresh(1, 2, false)) } - - assertEquals(-1, (results as LoadResult.Page).prevKey) - assertEquals(3, results.nextKey) - } - - @Test fun `initial page has correct itemsBefore and itemsAfter`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) - - val results = runBlocking { source.load(Refresh(null, 2, false)) } - - assertEquals(0, (results as LoadResult.Page).itemsBefore) - assertEquals(8, results.itemsAfter) - } - - @Test fun `middle page has correct itemsBefore and itemsAfter`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) - - val results = runBlocking { source.load(Refresh(4, 2, false)) } - - assertEquals(4, (results as LoadResult.Page).itemsBefore) - assertEquals(4, results.itemsAfter) - } - - @Test fun `end page has correct itemsBefore and itemsAfter`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) - - val results = runBlocking { source.load(Refresh(8, 2, false)) } - - assertEquals(8, (results as LoadResult.Page).itemsBefore) - assertEquals(0, results.itemsAfter) - } - - @Test fun `misaligned end page has correct itemsBefore and itemsAfter`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) - - val results = runBlocking { source.load(Refresh(9, 2, false)) } - - assertEquals(9, (results as LoadResult.Page).itemsBefore) - assertEquals(0, results.itemsAfter) - } - - @Test fun `misaligned start page has correct itemsBefore and itemsAfter`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) - - val results = runBlocking { source.load(Refresh(1, 2, false)) } - - assertEquals(1, (results as LoadResult.Page).itemsBefore) - assertEquals(7, results.itemsAfter) - } - - @Test fun `prepend paging misaligned start page produces correct values`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) - - runBlocking { - val expected = listOf(listOf(1, 2), listOf(0)).iterator() - var prevKey: Int? = 1 - do { - val results = source.load(Refresh(prevKey, 2, false)) - assertEquals(expected.next(), (results as LoadResult.Page).data) - prevKey = results.prevKey - } while (prevKey != null) - } - } - - @Test fun `key too big throws IndexOutOfBoundsException`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) - - runBlocking { - assertFailsWith { - source.load(Refresh(10, 2, false)) - } - } - } - - @Test fun `query invalidation invalidates paging source`() { - val query = query(2, 0) - val source = OffsetQueryPagingSource( - { _, _ -> query }, - countQuery(), - transacter, - EmptyCoroutineContext, - ) - - runBlocking { source.load(Refresh(null, 0, false)) } - - driver.notifyListeners(arrayOf("testTable")) - - assertTrue(source.invalid) - } - - private fun query(limit: Int, offset: Int) = object : Query( - { cursor -> cursor.getLong(0)!!.toInt() }, - ) { - override fun execute(mapper: (SqlCursor) -> R) = driver.executeQuery(1, "SELECT value FROM testTable LIMIT ? OFFSET ?", mapper, 2) { - bindLong(0, limit.toLong()) - bindLong(1, offset.toLong()) - } - - override fun addListener(listener: Listener) = driver.addListener(listener, arrayOf("testTable")) - override fun removeListener(listener: Listener) = driver.removeListener(listener, arrayOf("testTable")) - } - - private fun countQuery() = Query( - 2, - arrayOf("testTable"), - driver, - "Test.sq", - "count", - "SELECT count(*) FROM testTable", - { it.getLong(0)!!.toInt() }, - ) - - private fun insert(value: Long, db: SqlDriver = driver) { - db.execute(0, "INSERT INTO testTable (value) VALUES (?)", 1) { - bindLong(0, value) - } - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d4b0b0f2660..8919eebe896 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ androidx-test-runner = { module = "androidx.test:runner", version.ref = "test" } androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "androidxSqlite" } androidx-sqliteFramework = { module = "androidx.sqlite:sqlite-framework", version.ref = "androidxSqlite" } androidx-paging3-common = { module = "androidx.paging:paging-common", version.ref = "paging3" } +androidx-paging3-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging3" } androidx-paging3-rx3 = { module = "androidx.paging:paging-rxjava3", version.ref = "paging3" } androidx-recyclerView = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" } android-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } diff --git a/settings.gradle b/settings.gradle index c285ddb15ed..f7f88fb388c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -33,6 +33,7 @@ include ':drivers:sqlite-driver' include ':drivers:sqljs-driver' include ':drivers:driver-test' include ':extensions:android-paging3' +include ':extensions:android-paging3:android-test' include ':extensions:async-extensions' include ':extensions:coroutines-extensions' include ':extensions:rxjava2-extensions'