From 7299b7475a2ac31020ed211dc955315968930a3b Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Tue, 19 Jul 2022 21:57:36 +0200 Subject: [PATCH 01/19] 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 --- .../paging3/LimitOffsetPagingSource.kt | 118 +++++++++++ .../sqldelight/paging3/util/RoomPagingUtil.kt | 194 ++++++++++++++++++ .../util/ThreadSafeInvalidationObserver.kt | 26 +++ 3 files changed, 338 insertions(+) create mode 100644 extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt create mode 100644 extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt create mode 100644 extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/ThreadSafeInvalidationObserver.kt diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt new file mode 100644 index 00000000000..b3fd73a671f --- /dev/null +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt @@ -0,0 +1,118 @@ +// Copyright Square, Inc. +package app.cash.sqldelight.paging3 + +import android.database.Cursor +import androidx.annotation.NonNull +import androidx.annotation.RestrictTo +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.room.RoomDatabase +import androidx.room.RoomSQLiteQuery +import androidx.room.getQueryDispatcher +import app.cash.sqldelight.paging3.util.INITIAL_ITEM_COUNT +import app.cash.sqldelight.paging3.util.INVALID +import app.cash.sqldelight.paging3.util.ThreadSafeInvalidationObserver +import app.cash.sqldelight.paging3.util.getClippedRefreshKey +import app.cash.sqldelight.paging3.util.queryDatabase +import app.cash.sqldelight.paging3.util.queryItemCount +import androidx.room.withTransaction +import androidx.sqlite.db.SupportSQLiteQuery +import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicInteger + +/** + * An implementation of [PagingSource] to perform a LIMIT OFFSET query + * + * This class is used for Paging3 to perform Query and RawQuery in Room to return a PagingSource + * for Pager's consumption. Registers observers on tables lazily and automatically invalidates + * itself when data changes. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +abstract class LimitOffsetPagingSource( + private val sourceQuery: RoomSQLiteQuery, + private val db: RoomDatabase, + vararg tables: String, +) : PagingSource() { + + constructor( + supportSQLiteQuery: SupportSQLiteQuery, + db: RoomDatabase, + vararg tables: String, + ) : this( + sourceQuery = RoomSQLiteQuery.copyFrom(supportSQLiteQuery), + db = db, + tables = tables, + ) + + internal val itemCount: AtomicInteger = AtomicInteger(INITIAL_ITEM_COUNT) + + private val observer = ThreadSafeInvalidationObserver( + tables = tables, + onInvalidated = ::invalidate + ) + + override suspend fun load(params: LoadParams): LoadResult { + return withContext(db.getQueryDispatcher()) { + observer.registerIfNecessary(db) + val tempCount = itemCount.get() + // if itemCount is < 0, then it is initial load + if (tempCount == INITIAL_ITEM_COUNT) { + initialLoad(params) + } else { + nonInitialLoad(params, tempCount) + } + } + } + + /** + * For the very first time that this PagingSource's [load] is called. Executes the count + * query (initializes [itemCount]) and db query within a transaction to ensure initial load's + * data integrity. + * + * For example, if the database gets updated after the count query but before the db query + * completes, the paging source may not invalidate in time, but this method will return + * data based on the original database that the count was performed on to ensure a valid + * initial load. + */ + private suspend fun initialLoad(params: LoadParams): LoadResult { + return db.withTransaction { + val tempCount = queryItemCount(sourceQuery, db) + itemCount.set(tempCount) + queryDatabase( + params = params, + sourceQuery = sourceQuery, + db = db, + itemCount = tempCount, + convertRows = ::convertRows + ) + } + } + + private suspend fun nonInitialLoad( + params: LoadParams, + tempCount: Int, + ): LoadResult { + val loadResult = queryDatabase( + params = params, + sourceQuery = sourceQuery, + db = db, + itemCount = tempCount, + convertRows = ::convertRows + ) + // manually check if database has been updated. If so, the observer's + // invalidation callback will invalidate this paging source + db.invalidationTracker.refreshVersionsSync() + @Suppress("UNCHECKED_CAST") + return if (invalid) INVALID as LoadResult.Invalid else loadResult + } + + @NonNull + protected abstract fun convertRows(cursor: Cursor): List + + override fun getRefreshKey(state: PagingState): Int? { + return state.getClippedRefreshKey() + } + + override val jumpingSupported: Boolean + get() = true +} diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt new file mode 100644 index 00000000000..f2958f80043 --- /dev/null +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt @@ -0,0 +1,194 @@ +// Copyright Square, Inc. +package app.cash.sqldelight.paging3.util + +import android.database.Cursor +import android.os.CancellationSignal +import androidx.paging.PagingSource +import androidx.paging.PagingSource.LoadParams +import androidx.paging.PagingSource.LoadParams.Prepend +import androidx.paging.PagingSource.LoadParams.Append +import androidx.paging.PagingSource.LoadParams.Refresh +import androidx.paging.PagingSource.LoadResult +import androidx.paging.PagingState +import androidx.room.RoomDatabase +import androidx.room.RoomSQLiteQuery + +/** + * A [LoadResult] that can be returned to trigger a new generation of PagingSource + * + * Any loaded data or queued loads prior to returning INVALID will be discarded + */ +val INVALID = LoadResult.Invalid() + +/** + * The default itemCount value + */ +const val INITIAL_ITEM_COUNT = -1 + +/** + * Calculates query limit based on LoadType. + * + * Prepend: If requested loadSize is larger than available number of items to prepend, it will + * query with OFFSET = 0, LIMIT = prevKey + */ +fun getLimit(params: LoadParams, key: Int): Int { + return when (params) { + is Prepend -> + if (key < params.loadSize) { + key + } else { + params.loadSize + } + else -> params.loadSize + } +} + +/** + * calculates query offset amount based on loadtype + * + * Prepend: OFFSET is calculated by counting backwards the number of items that needs to be + * loaded before [key]. For example, if key = 30 and loadSize = 5, then offset = 25 and items + * in db position 26-30 are loaded. + * If requested loadSize is larger than the number of available items to + * prepend, OFFSET clips to 0 to prevent negative OFFSET. + * + * Refresh: + * If initialKey is supplied through Pager, Paging 3 will now start loading from + * initialKey with initialKey being the first item. + * If key is supplied by [getClippedRefreshKey], the key has already been adjusted to load half + * of the requested items before anchorPosition and the other half after anchorPosition. See + * comments on [getClippedRefreshKey] for more details. + * If key (regardless if from initialKey or [getClippedRefreshKey]) is larger than available items, + * the last page will be loaded by counting backwards the loadSize before last item in + * database. For example, this can happen if invalidation came from a large number of items + * dropped. i.e. in items 0 - 100, items 41-80 are dropped. Depending on last + * viewed item, hypothetically [getClippedRefreshKey] may return key = 60. If loadSize = 10, then items + * 31-40 will be loaded. + */ +fun getOffset(params: LoadParams, key: Int, itemCount: Int): Int { + return when (params) { + is Prepend -> + if (key < params.loadSize) { + 0 + } else { + key - params.loadSize + } + is Append -> key + is Refresh -> + if (key >= itemCount) { + maxOf(0, itemCount - params.loadSize) + } else { + key + } + } +} + +/** + * calls RoomDatabase.query() to return a cursor and then calls convertRows() to extract and + * return list of data + * + * throws [IllegalArgumentException] from CursorUtil if column does not exist + * + * @param params load params to calculate query limit and offset + * + * @param sourceQuery user provided [RoomSQLiteQuery] for database query + * + * @param db the [RoomDatabase] to query from + * + * @param itemCount the db row count, triggers a new PagingSource generation if itemCount changes, + * i.e. items are added / removed + * + * @param cancellationSignal the signal to cancel the query if the query hasn't yet completed + * + * @param convertRows the function to iterate data with provided [Cursor] to return List + */ +fun queryDatabase( + params: LoadParams, + sourceQuery: RoomSQLiteQuery, + db: RoomDatabase, + itemCount: Int, + cancellationSignal: CancellationSignal? = null, + convertRows: (Cursor) -> List, +): LoadResult { + val key = params.key ?: 0 + val limit: Int = getLimit(params, key) + val offset: Int = getOffset(params, key, itemCount) + val limitOffsetQuery = + "SELECT * FROM ( ${sourceQuery.sql} ) LIMIT $limit OFFSET $offset" + val sqLiteQuery: RoomSQLiteQuery = RoomSQLiteQuery.acquire( + limitOffsetQuery, + sourceQuery.argCount + ) + sqLiteQuery.copyArgumentsFrom(sourceQuery) + val cursor = db.query(sqLiteQuery, cancellationSignal) + val data: List + try { + data = convertRows(cursor) + } finally { + cursor.close() + sqLiteQuery.release() + } + val nextPosToLoad = offset + data.size + val nextKey = + if (data.isEmpty() || data.size < limit || nextPosToLoad >= itemCount) { + null + } else { + nextPosToLoad + } + val prevKey = if (offset <= 0 || data.isEmpty()) null else offset + return LoadResult.Page( + data = data, + prevKey = prevKey, + nextKey = nextKey, + itemsBefore = offset, + itemsAfter = maxOf(0, itemCount - nextPosToLoad) + ) +} + +/** + * returns count of requested items to calculate itemsAfter and itemsBefore for use in creating + * LoadResult.Page<> + * + * throws error when the column value is null, the column type is not an integral type, + * or the integer value is outside the range [Integer.MIN_VALUE, Integer.MAX_VALUE] + */ +fun queryItemCount( + sourceQuery: RoomSQLiteQuery, + db: RoomDatabase +): Int { + val countQuery = "SELECT COUNT(*) FROM ( ${sourceQuery.sql} )" + val sqLiteQuery: RoomSQLiteQuery = RoomSQLiteQuery.acquire( + countQuery, + sourceQuery.argCount + ) + sqLiteQuery.copyArgumentsFrom(sourceQuery) + val cursor: Cursor = db.query(sqLiteQuery) + try { + if (cursor.moveToFirst()) { + return cursor.getInt(0) + } + return 0 + } finally { + cursor.close() + sqLiteQuery.release() + } +} + +/** + * Returns the key for [PagingSource] for a non-initial REFRESH load. + * + * To prevent a negative key, key is clipped to 0 when the number of items available before + * anchorPosition is less than the requested amount of initialLoadSize / 2. + */ +fun PagingState.getClippedRefreshKey(): Int? { + return when (val anchorPosition = anchorPosition) { + null -> null + /** + * It is unknown whether anchorPosition represents the item at the top of the screen or item at + * the bottom of the screen. To ensure the number of items loaded is enough to fill up the + * screen, half of loadSize is loaded before the anchorPosition and the other half is + * loaded after the anchorPosition -- anchorPosition becomes the middle item. + */ + else -> maxOf(0, anchorPosition - (config.initialLoadSize / 2)) + } +} diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/ThreadSafeInvalidationObserver.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/ThreadSafeInvalidationObserver.kt new file mode 100644 index 00000000000..53435ba6d98 --- /dev/null +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/ThreadSafeInvalidationObserver.kt @@ -0,0 +1,26 @@ +// Copyright Square, Inc. +package app.cash.sqldelight.paging3.util + +import androidx.annotation.RestrictTo +import androidx.room.InvalidationTracker +import androidx.room.RoomDatabase +import java.util.concurrent.atomic.AtomicBoolean + +@Suppress("UNCHECKED_CAST") +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +class ThreadSafeInvalidationObserver( + tables: Array, + val onInvalidated: () -> Unit, +) : InvalidationTracker.Observer(tables = tables as Array) { + private val registered: AtomicBoolean = AtomicBoolean(false) + + override fun onInvalidated(tables: Set) { + onInvalidated() + } + + fun registerIfNecessary(db: RoomDatabase) { + if (registered.compareAndSet(false, true)) { + db.invalidationTracker.addWeakObserver(this) + } + } +} From 89e18bd9cf4a1b009905ac315ce48fe078304416 Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Mon, 25 Jul 2022 10:54:55 +0100 Subject: [PATCH 02/19] Don't limit to library scope --- .../java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt | 2 -- .../sqldelight/paging3/util/ThreadSafeInvalidationObserver.kt | 2 -- 2 files changed, 4 deletions(-) diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt index b3fd73a671f..107ec0c44b6 100644 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt @@ -3,7 +3,6 @@ package app.cash.sqldelight.paging3 import android.database.Cursor import androidx.annotation.NonNull -import androidx.annotation.RestrictTo import androidx.paging.PagingSource import androidx.paging.PagingState import androidx.room.RoomDatabase @@ -27,7 +26,6 @@ import java.util.concurrent.atomic.AtomicInteger * for Pager's consumption. Registers observers on tables lazily and automatically invalidates * itself when data changes. */ -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) abstract class LimitOffsetPagingSource( private val sourceQuery: RoomSQLiteQuery, private val db: RoomDatabase, diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/ThreadSafeInvalidationObserver.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/ThreadSafeInvalidationObserver.kt index 53435ba6d98..02c9e5de28d 100644 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/ThreadSafeInvalidationObserver.kt +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/ThreadSafeInvalidationObserver.kt @@ -1,13 +1,11 @@ // Copyright Square, Inc. package app.cash.sqldelight.paging3.util -import androidx.annotation.RestrictTo import androidx.room.InvalidationTracker import androidx.room.RoomDatabase import java.util.concurrent.atomic.AtomicBoolean @Suppress("UNCHECKED_CAST") -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) class ThreadSafeInvalidationObserver( tables: Array, val onInvalidated: () -> Unit, From 6c9b91364c8a8bccd7e6e381ef5cfdd760e421ca Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Mon, 25 Jul 2022 10:55:51 +0100 Subject: [PATCH 03/19] Remove unnecessary constructor --- .../sqldelight/paging3/LimitOffsetPagingSource.kt | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt index 107ec0c44b6..22734d4b629 100644 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt @@ -15,7 +15,6 @@ import app.cash.sqldelight.paging3.util.getClippedRefreshKey import app.cash.sqldelight.paging3.util.queryDatabase import app.cash.sqldelight.paging3.util.queryItemCount import androidx.room.withTransaction -import androidx.sqlite.db.SupportSQLiteQuery import kotlinx.coroutines.withContext import java.util.concurrent.atomic.AtomicInteger @@ -32,16 +31,6 @@ abstract class LimitOffsetPagingSource( vararg tables: String, ) : PagingSource() { - constructor( - supportSQLiteQuery: SupportSQLiteQuery, - db: RoomDatabase, - vararg tables: String, - ) : this( - sourceQuery = RoomSQLiteQuery.copyFrom(supportSQLiteQuery), - db = db, - tables = tables, - ) - internal val itemCount: AtomicInteger = AtomicInteger(INITIAL_ITEM_COUNT) private val observer = ThreadSafeInvalidationObserver( From 4efa346ad18bc5f4210a96c24dc2f21581f95c08 Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Mon, 25 Jul 2022 11:00:47 +0100 Subject: [PATCH 04/19] Remove unused argument --- .../java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt index f2958f80043..85c2583741d 100644 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt @@ -2,7 +2,6 @@ package app.cash.sqldelight.paging3.util import android.database.Cursor -import android.os.CancellationSignal import androidx.paging.PagingSource import androidx.paging.PagingSource.LoadParams import androidx.paging.PagingSource.LoadParams.Prepend @@ -98,8 +97,6 @@ fun getOffset(params: LoadParams, key: Int, itemCount: Int): Int { * @param itemCount the db row count, triggers a new PagingSource generation if itemCount changes, * i.e. items are added / removed * - * @param cancellationSignal the signal to cancel the query if the query hasn't yet completed - * * @param convertRows the function to iterate data with provided [Cursor] to return List */ fun queryDatabase( @@ -107,7 +104,6 @@ fun queryDatabase( sourceQuery: RoomSQLiteQuery, db: RoomDatabase, itemCount: Int, - cancellationSignal: CancellationSignal? = null, convertRows: (Cursor) -> List, ): LoadResult { val key = params.key ?: 0 @@ -120,7 +116,7 @@ fun queryDatabase( sourceQuery.argCount ) sqLiteQuery.copyArgumentsFrom(sourceQuery) - val cursor = db.query(sqLiteQuery, cancellationSignal) + val cursor = db.query(sqLiteQuery, null) val data: List try { data = convertRows(cursor) From 31b3f7359bb7cb7a1780aaf179923f20b77b2d00 Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Mon, 25 Jul 2022 11:02:34 +0100 Subject: [PATCH 05/19] Remove usages of getQueryDispatcher --- .../app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt index 22734d4b629..22db591434c 100644 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt @@ -7,7 +7,6 @@ import androidx.paging.PagingSource import androidx.paging.PagingState import androidx.room.RoomDatabase import androidx.room.RoomSQLiteQuery -import androidx.room.getQueryDispatcher import app.cash.sqldelight.paging3.util.INITIAL_ITEM_COUNT import app.cash.sqldelight.paging3.util.INVALID import app.cash.sqldelight.paging3.util.ThreadSafeInvalidationObserver @@ -17,6 +16,7 @@ import app.cash.sqldelight.paging3.util.queryItemCount import androidx.room.withTransaction import kotlinx.coroutines.withContext import java.util.concurrent.atomic.AtomicInteger +import kotlin.coroutines.CoroutineContext /** * An implementation of [PagingSource] to perform a LIMIT OFFSET query @@ -26,6 +26,7 @@ import java.util.concurrent.atomic.AtomicInteger * itself when data changes. */ abstract class LimitOffsetPagingSource( + private val context: CoroutineContext, private val sourceQuery: RoomSQLiteQuery, private val db: RoomDatabase, vararg tables: String, @@ -39,7 +40,7 @@ abstract class LimitOffsetPagingSource( ) override suspend fun load(params: LoadParams): LoadResult { - return withContext(db.getQueryDispatcher()) { + return withContext(context) { observer.registerIfNecessary(db) val tempCount = itemCount.get() // if itemCount is < 0, then it is initial load From 49eda6bd7cc17ada88b5db04da0924fb9d392076 Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Mon, 25 Jul 2022 11:06:05 +0100 Subject: [PATCH 06/19] Remove usages of androidx.room.withTransaction --- .../app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt index 22db591434c..feedf0eab9f 100644 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt @@ -13,7 +13,7 @@ import app.cash.sqldelight.paging3.util.ThreadSafeInvalidationObserver import app.cash.sqldelight.paging3.util.getClippedRefreshKey import app.cash.sqldelight.paging3.util.queryDatabase import app.cash.sqldelight.paging3.util.queryItemCount -import androidx.room.withTransaction +import app.cash.sqldelight.Transacter import kotlinx.coroutines.withContext import java.util.concurrent.atomic.AtomicInteger import kotlin.coroutines.CoroutineContext @@ -26,6 +26,7 @@ import kotlin.coroutines.CoroutineContext * itself when data changes. */ abstract class LimitOffsetPagingSource( + private val transacter: Transacter, private val context: CoroutineContext, private val sourceQuery: RoomSQLiteQuery, private val db: RoomDatabase, @@ -63,7 +64,7 @@ abstract class LimitOffsetPagingSource( * initial load. */ private suspend fun initialLoad(params: LoadParams): LoadResult { - return db.withTransaction { + return transacter.transactionWithResult { val tempCount = queryItemCount(sourceQuery, db) itemCount.set(tempCount) queryDatabase( From e1d997f74ce39a475a6d392876bb0b809d30e22a Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Mon, 25 Jul 2022 11:10:57 +0100 Subject: [PATCH 07/19] Remove usage of app.cash.sqldelight.paging3.util.queryItemCount --- .../paging3/LimitOffsetPagingSource.kt | 5 ++-- .../sqldelight/paging3/util/RoomPagingUtil.kt | 29 ------------------- 2 files changed, 3 insertions(+), 31 deletions(-) diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt index feedf0eab9f..c730ea7e5cd 100644 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt @@ -7,12 +7,12 @@ import androidx.paging.PagingSource import androidx.paging.PagingState import androidx.room.RoomDatabase import androidx.room.RoomSQLiteQuery +import app.cash.sqldelight.Query import app.cash.sqldelight.paging3.util.INITIAL_ITEM_COUNT import app.cash.sqldelight.paging3.util.INVALID import app.cash.sqldelight.paging3.util.ThreadSafeInvalidationObserver import app.cash.sqldelight.paging3.util.getClippedRefreshKey import app.cash.sqldelight.paging3.util.queryDatabase -import app.cash.sqldelight.paging3.util.queryItemCount import app.cash.sqldelight.Transacter import kotlinx.coroutines.withContext import java.util.concurrent.atomic.AtomicInteger @@ -26,6 +26,7 @@ import kotlin.coroutines.CoroutineContext * itself when data changes. */ abstract class LimitOffsetPagingSource( + private val countQuery: Query, private val transacter: Transacter, private val context: CoroutineContext, private val sourceQuery: RoomSQLiteQuery, @@ -65,7 +66,7 @@ abstract class LimitOffsetPagingSource( */ private suspend fun initialLoad(params: LoadParams): LoadResult { return transacter.transactionWithResult { - val tempCount = queryItemCount(sourceQuery, db) + val tempCount = countQuery.executeAsOne() itemCount.set(tempCount) queryDatabase( params = params, diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt index 85c2583741d..ef0dfebff2c 100644 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt @@ -141,35 +141,6 @@ fun queryDatabase( ) } -/** - * returns count of requested items to calculate itemsAfter and itemsBefore for use in creating - * LoadResult.Page<> - * - * throws error when the column value is null, the column type is not an integral type, - * or the integer value is outside the range [Integer.MIN_VALUE, Integer.MAX_VALUE] - */ -fun queryItemCount( - sourceQuery: RoomSQLiteQuery, - db: RoomDatabase -): Int { - val countQuery = "SELECT COUNT(*) FROM ( ${sourceQuery.sql} )" - val sqLiteQuery: RoomSQLiteQuery = RoomSQLiteQuery.acquire( - countQuery, - sourceQuery.argCount - ) - sqLiteQuery.copyArgumentsFrom(sourceQuery) - val cursor: Cursor = db.query(sqLiteQuery) - try { - if (cursor.moveToFirst()) { - return cursor.getInt(0) - } - return 0 - } finally { - cursor.close() - sqLiteQuery.release() - } -} - /** * Returns the key for [PagingSource] for a non-initial REFRESH load. * From 28ca3962f0d6d56366318e5b47ddfee9f0f61176 Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Mon, 25 Jul 2022 11:12:03 +0100 Subject: [PATCH 08/19] Rename Value to RowType to align with SQLDelight --- .../paging3/LimitOffsetPagingSource.kt | 16 ++++++++-------- .../sqldelight/paging3/util/RoomPagingUtil.kt | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt index c730ea7e5cd..5e607f51955 100644 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt @@ -25,14 +25,14 @@ import kotlin.coroutines.CoroutineContext * for Pager's consumption. Registers observers on tables lazily and automatically invalidates * itself when data changes. */ -abstract class LimitOffsetPagingSource( +abstract class LimitOffsetPagingSource( private val countQuery: Query, private val transacter: Transacter, private val context: CoroutineContext, private val sourceQuery: RoomSQLiteQuery, private val db: RoomDatabase, vararg tables: String, -) : PagingSource() { +) : PagingSource() { internal val itemCount: AtomicInteger = AtomicInteger(INITIAL_ITEM_COUNT) @@ -41,7 +41,7 @@ abstract class LimitOffsetPagingSource( onInvalidated = ::invalidate ) - override suspend fun load(params: LoadParams): LoadResult { + override suspend fun load(params: LoadParams): LoadResult { return withContext(context) { observer.registerIfNecessary(db) val tempCount = itemCount.get() @@ -64,7 +64,7 @@ abstract class LimitOffsetPagingSource( * data based on the original database that the count was performed on to ensure a valid * initial load. */ - private suspend fun initialLoad(params: LoadParams): LoadResult { + private suspend fun initialLoad(params: LoadParams): LoadResult { return transacter.transactionWithResult { val tempCount = countQuery.executeAsOne() itemCount.set(tempCount) @@ -81,7 +81,7 @@ abstract class LimitOffsetPagingSource( private suspend fun nonInitialLoad( params: LoadParams, tempCount: Int, - ): LoadResult { + ): LoadResult { val loadResult = queryDatabase( params = params, sourceQuery = sourceQuery, @@ -93,13 +93,13 @@ abstract class LimitOffsetPagingSource( // invalidation callback will invalidate this paging source db.invalidationTracker.refreshVersionsSync() @Suppress("UNCHECKED_CAST") - return if (invalid) INVALID as LoadResult.Invalid else loadResult + return if (invalid) INVALID as LoadResult.Invalid else loadResult } @NonNull - protected abstract fun convertRows(cursor: Cursor): List + protected abstract fun convertRows(cursor: Cursor): List - override fun getRefreshKey(state: PagingState): Int? { + override fun getRefreshKey(state: PagingState): Int? { return state.getClippedRefreshKey() } diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt index ef0dfebff2c..7eb7b04ba01 100644 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt @@ -97,15 +97,15 @@ fun getOffset(params: LoadParams, key: Int, itemCount: Int): Int { * @param itemCount the db row count, triggers a new PagingSource generation if itemCount changes, * i.e. items are added / removed * - * @param convertRows the function to iterate data with provided [Cursor] to return List + * @param convertRows the function to iterate data with provided [Cursor] to return List */ -fun queryDatabase( +fun queryDatabase( params: LoadParams, sourceQuery: RoomSQLiteQuery, db: RoomDatabase, itemCount: Int, - convertRows: (Cursor) -> List, -): LoadResult { + convertRows: (Cursor) -> List, +): LoadResult { val key = params.key ?: 0 val limit: Int = getLimit(params, key) val offset: Int = getOffset(params, key, itemCount) @@ -117,7 +117,7 @@ fun queryDatabase( ) sqLiteQuery.copyArgumentsFrom(sourceQuery) val cursor = db.query(sqLiteQuery, null) - val data: List + val data: List try { data = convertRows(cursor) } finally { @@ -147,7 +147,7 @@ fun queryDatabase( * To prevent a negative key, key is clipped to 0 when the number of items available before * anchorPosition is less than the requested amount of initialLoadSize / 2. */ -fun PagingState.getClippedRefreshKey(): Int? { +fun PagingState.getClippedRefreshKey(): Int? { return when (val anchorPosition = anchorPosition) { null -> null /** From 4ea18c0ab02ed32759f7aaa8214187ddf80bc4cd Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Mon, 25 Jul 2022 11:19:26 +0100 Subject: [PATCH 09/19] Pass in a QueryProvider and then remove all fluff --- .../paging3/LimitOffsetPagingSource.kt | 16 ++-------- .../sqldelight/paging3/util/RoomPagingUtil.kt | 31 +++---------------- 2 files changed, 7 insertions(+), 40 deletions(-) diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt index 5e607f51955..b2551c2537d 100644 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt @@ -1,12 +1,9 @@ // Copyright Square, Inc. package app.cash.sqldelight.paging3 -import android.database.Cursor -import androidx.annotation.NonNull import androidx.paging.PagingSource import androidx.paging.PagingState import androidx.room.RoomDatabase -import androidx.room.RoomSQLiteQuery import app.cash.sqldelight.Query import app.cash.sqldelight.paging3.util.INITIAL_ITEM_COUNT import app.cash.sqldelight.paging3.util.INVALID @@ -26,10 +23,10 @@ import kotlin.coroutines.CoroutineContext * itself when data changes. */ abstract class LimitOffsetPagingSource( + private val queryProvider: (limit: Int, offset: Int) -> Query, private val countQuery: Query, private val transacter: Transacter, private val context: CoroutineContext, - private val sourceQuery: RoomSQLiteQuery, private val db: RoomDatabase, vararg tables: String, ) : PagingSource() { @@ -69,11 +66,9 @@ abstract class LimitOffsetPagingSource( val tempCount = countQuery.executeAsOne() itemCount.set(tempCount) queryDatabase( + queryProvider = queryProvider, params = params, - sourceQuery = sourceQuery, - db = db, itemCount = tempCount, - convertRows = ::convertRows ) } } @@ -83,11 +78,9 @@ abstract class LimitOffsetPagingSource( tempCount: Int, ): LoadResult { val loadResult = queryDatabase( + queryProvider = queryProvider, params = params, - sourceQuery = sourceQuery, - db = db, itemCount = tempCount, - convertRows = ::convertRows ) // manually check if database has been updated. If so, the observer's // invalidation callback will invalidate this paging source @@ -96,9 +89,6 @@ abstract class LimitOffsetPagingSource( return if (invalid) INVALID as LoadResult.Invalid else loadResult } - @NonNull - protected abstract fun convertRows(cursor: Cursor): List - override fun getRefreshKey(state: PagingState): Int? { return state.getClippedRefreshKey() } diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt index 7eb7b04ba01..721d10faa47 100644 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt @@ -1,7 +1,6 @@ // Copyright Square, Inc. package app.cash.sqldelight.paging3.util -import android.database.Cursor import androidx.paging.PagingSource import androidx.paging.PagingSource.LoadParams import androidx.paging.PagingSource.LoadParams.Prepend @@ -9,8 +8,7 @@ import androidx.paging.PagingSource.LoadParams.Append import androidx.paging.PagingSource.LoadParams.Refresh import androidx.paging.PagingSource.LoadResult import androidx.paging.PagingState -import androidx.room.RoomDatabase -import androidx.room.RoomSQLiteQuery +import app.cash.sqldelight.Query /** * A [LoadResult] that can be returned to trigger a new generation of PagingSource @@ -90,40 +88,19 @@ fun getOffset(params: LoadParams, key: Int, itemCount: Int): Int { * * @param params load params to calculate query limit and offset * - * @param sourceQuery user provided [RoomSQLiteQuery] for database query - * - * @param db the [RoomDatabase] to query from - * * @param itemCount the db row count, triggers a new PagingSource generation if itemCount changes, * i.e. items are added / removed - * - * @param convertRows the function to iterate data with provided [Cursor] to return List */ fun queryDatabase( + queryProvider: (limit: Int, offset: Int) -> Query, params: LoadParams, - sourceQuery: RoomSQLiteQuery, - db: RoomDatabase, itemCount: Int, - convertRows: (Cursor) -> List, ): LoadResult { val key = params.key ?: 0 val limit: Int = getLimit(params, key) val offset: Int = getOffset(params, key, itemCount) - val limitOffsetQuery = - "SELECT * FROM ( ${sourceQuery.sql} ) LIMIT $limit OFFSET $offset" - val sqLiteQuery: RoomSQLiteQuery = RoomSQLiteQuery.acquire( - limitOffsetQuery, - sourceQuery.argCount - ) - sqLiteQuery.copyArgumentsFrom(sourceQuery) - val cursor = db.query(sqLiteQuery, null) - val data: List - try { - data = convertRows(cursor) - } finally { - cursor.close() - sqLiteQuery.release() - } + val data = queryProvider(limit, offset) + .executeAsList() val nextPosToLoad = offset + data.size val nextKey = if (data.isEmpty() || data.size < limit || nextPosToLoad >= itemCount) { From 5bf920cd3ca871135548922e9ecad8e257070af8 Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Mon, 25 Jul 2022 11:24:51 +0100 Subject: [PATCH 10/19] Put queryDatabase within LimitOffsetPagingSource --- .../paging3/LimitOffsetPagingSource.kt | 42 +++++++++++++++++-- .../sqldelight/paging3/util/RoomPagingUtil.kt | 38 ----------------- 2 files changed, 39 insertions(+), 41 deletions(-) diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt index b2551c2537d..40a0dfae44d 100644 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt @@ -9,8 +9,9 @@ import app.cash.sqldelight.paging3.util.INITIAL_ITEM_COUNT import app.cash.sqldelight.paging3.util.INVALID import app.cash.sqldelight.paging3.util.ThreadSafeInvalidationObserver import app.cash.sqldelight.paging3.util.getClippedRefreshKey -import app.cash.sqldelight.paging3.util.queryDatabase import app.cash.sqldelight.Transacter +import app.cash.sqldelight.paging3.util.getLimit +import app.cash.sqldelight.paging3.util.getOffset import kotlinx.coroutines.withContext import java.util.concurrent.atomic.AtomicInteger import kotlin.coroutines.CoroutineContext @@ -66,7 +67,6 @@ abstract class LimitOffsetPagingSource( val tempCount = countQuery.executeAsOne() itemCount.set(tempCount) queryDatabase( - queryProvider = queryProvider, params = params, itemCount = tempCount, ) @@ -78,7 +78,6 @@ abstract class LimitOffsetPagingSource( tempCount: Int, ): LoadResult { val loadResult = queryDatabase( - queryProvider = queryProvider, params = params, itemCount = tempCount, ) @@ -95,4 +94,41 @@ abstract class LimitOffsetPagingSource( override val jumpingSupported: Boolean get() = true + + /** + * calls RoomDatabase.query() to return a cursor and then calls convertRows() to extract and + * return list of data + * + * throws [IllegalArgumentException] from CursorUtil if column does not exist + * + * @param params load params to calculate query limit and offset + * + * @param itemCount the db row count, triggers a new PagingSource generation if itemCount changes, + * i.e. items are added / removed + */ + private fun queryDatabase( + params: LoadParams, + itemCount: Int, + ): LoadResult { + val key = params.key ?: 0 + val limit: Int = getLimit(params, key) + val offset: Int = getOffset(params, key, itemCount) + val data = queryProvider(limit, offset) + .executeAsList() + val nextPosToLoad = offset + data.size + val nextKey = + if (data.isEmpty() || data.size < limit || nextPosToLoad >= itemCount) { + null + } else { + nextPosToLoad + } + val prevKey = if (offset <= 0 || data.isEmpty()) null else offset + return LoadResult.Page( + data = data, + prevKey = prevKey, + nextKey = nextKey, + itemsBefore = offset, + itemsAfter = maxOf(0, itemCount - nextPosToLoad) + ) + } } diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt index 721d10faa47..5a48006b762 100644 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt @@ -80,44 +80,6 @@ fun getOffset(params: LoadParams, key: Int, itemCount: Int): Int { } } -/** - * calls RoomDatabase.query() to return a cursor and then calls convertRows() to extract and - * return list of data - * - * throws [IllegalArgumentException] from CursorUtil if column does not exist - * - * @param params load params to calculate query limit and offset - * - * @param itemCount the db row count, triggers a new PagingSource generation if itemCount changes, - * i.e. items are added / removed - */ -fun queryDatabase( - queryProvider: (limit: Int, offset: Int) -> Query, - params: LoadParams, - itemCount: Int, -): LoadResult { - val key = params.key ?: 0 - val limit: Int = getLimit(params, key) - val offset: Int = getOffset(params, key, itemCount) - val data = queryProvider(limit, offset) - .executeAsList() - val nextPosToLoad = offset + data.size - val nextKey = - if (data.isEmpty() || data.size < limit || nextPosToLoad >= itemCount) { - null - } else { - nextPosToLoad - } - val prevKey = if (offset <= 0 || data.isEmpty()) null else offset - return LoadResult.Page( - data = data, - prevKey = prevKey, - nextKey = nextKey, - itemsBefore = offset, - itemsAfter = maxOf(0, itemCount - nextPosToLoad) - ) -} - /** * Returns the key for [PagingSource] for a non-initial REFRESH load. * From c9e4b24ce93bb37fe724c10eab73d896d5313ac5 Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Mon, 25 Jul 2022 11:37:55 +0100 Subject: [PATCH 11/19] Use QueryPagingSource as the invalidater --- .../paging3/LimitOffsetPagingSource.kt | 21 ++++------------ .../util/ThreadSafeInvalidationObserver.kt | 24 ------------------- 2 files changed, 4 insertions(+), 41 deletions(-) delete mode 100644 extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/ThreadSafeInvalidationObserver.kt diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt index 40a0dfae44d..7b5bc7a50af 100644 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt +++ b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt @@ -3,11 +3,9 @@ package app.cash.sqldelight.paging3 import androidx.paging.PagingSource import androidx.paging.PagingState -import androidx.room.RoomDatabase import app.cash.sqldelight.Query import app.cash.sqldelight.paging3.util.INITIAL_ITEM_COUNT import app.cash.sqldelight.paging3.util.INVALID -import app.cash.sqldelight.paging3.util.ThreadSafeInvalidationObserver import app.cash.sqldelight.paging3.util.getClippedRefreshKey import app.cash.sqldelight.Transacter import app.cash.sqldelight.paging3.util.getLimit @@ -20,28 +18,19 @@ import kotlin.coroutines.CoroutineContext * An implementation of [PagingSource] to perform a LIMIT OFFSET query * * This class is used for Paging3 to perform Query and RawQuery in Room to return a PagingSource - * for Pager's consumption. Registers observers on tables lazily and automatically invalidates - * itself when data changes. + * for Pager's consumption. */ -abstract class LimitOffsetPagingSource( +internal class LimitOffsetPagingSource( private val queryProvider: (limit: Int, offset: Int) -> Query, private val countQuery: Query, private val transacter: Transacter, private val context: CoroutineContext, - private val db: RoomDatabase, - vararg tables: String, -) : PagingSource() { +) : QueryPagingSource() { internal val itemCount: AtomicInteger = AtomicInteger(INITIAL_ITEM_COUNT) - private val observer = ThreadSafeInvalidationObserver( - tables = tables, - onInvalidated = ::invalidate - ) - override suspend fun load(params: LoadParams): LoadResult { return withContext(context) { - observer.registerIfNecessary(db) val tempCount = itemCount.get() // if itemCount is < 0, then it is initial load if (tempCount == INITIAL_ITEM_COUNT) { @@ -81,9 +70,6 @@ abstract class LimitOffsetPagingSource( params = params, itemCount = tempCount, ) - // manually check if database has been updated. If so, the observer's - // invalidation callback will invalidate this paging source - db.invalidationTracker.refreshVersionsSync() @Suppress("UNCHECKED_CAST") return if (invalid) INVALID as LoadResult.Invalid else loadResult } @@ -114,6 +100,7 @@ abstract class LimitOffsetPagingSource( val limit: Int = getLimit(params, key) val offset: Int = getOffset(params, key, itemCount) val data = queryProvider(limit, offset) + .also { currentQuery = it } .executeAsList() val nextPosToLoad = offset + data.size val nextKey = diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/ThreadSafeInvalidationObserver.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/ThreadSafeInvalidationObserver.kt deleted file mode 100644 index 02c9e5de28d..00000000000 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/ThreadSafeInvalidationObserver.kt +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright Square, Inc. -package app.cash.sqldelight.paging3.util - -import androidx.room.InvalidationTracker -import androidx.room.RoomDatabase -import java.util.concurrent.atomic.AtomicBoolean - -@Suppress("UNCHECKED_CAST") -class ThreadSafeInvalidationObserver( - tables: Array, - val onInvalidated: () -> Unit, -) : InvalidationTracker.Observer(tables = tables as Array) { - private val registered: AtomicBoolean = AtomicBoolean(false) - - override fun onInvalidated(tables: Set) { - onInvalidated() - } - - fun registerIfNecessary(db: RoomDatabase) { - if (registered.compareAndSet(false, true)) { - db.invalidationTracker.addWeakObserver(this) - } - } -} From ac9219a746aa3a727dc12124af8a3c5536c2240b Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Mon, 25 Jul 2022 16:51:13 +0100 Subject: [PATCH 12/19] Simplify --- .../paging3/LimitOffsetPagingSource.kt | 121 ------------------ .../paging3/OffsetQueryPagingSource.kt | 46 ++++--- .../sqldelight/paging3/util/RoomPagingUtil.kt | 100 --------------- 3 files changed, 22 insertions(+), 245 deletions(-) delete mode 100644 extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt delete mode 100644 extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt deleted file mode 100644 index 7b5bc7a50af..00000000000 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/LimitOffsetPagingSource.kt +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright Square, Inc. -package app.cash.sqldelight.paging3 - -import androidx.paging.PagingSource -import androidx.paging.PagingState -import app.cash.sqldelight.Query -import app.cash.sqldelight.paging3.util.INITIAL_ITEM_COUNT -import app.cash.sqldelight.paging3.util.INVALID -import app.cash.sqldelight.paging3.util.getClippedRefreshKey -import app.cash.sqldelight.Transacter -import app.cash.sqldelight.paging3.util.getLimit -import app.cash.sqldelight.paging3.util.getOffset -import kotlinx.coroutines.withContext -import java.util.concurrent.atomic.AtomicInteger -import kotlin.coroutines.CoroutineContext - -/** - * An implementation of [PagingSource] to perform a LIMIT OFFSET query - * - * This class is used for Paging3 to perform Query and RawQuery in Room to return a PagingSource - * for Pager's consumption. - */ -internal class LimitOffsetPagingSource( - private val queryProvider: (limit: Int, offset: Int) -> Query, - private val countQuery: Query, - private val transacter: Transacter, - private val context: CoroutineContext, -) : QueryPagingSource() { - - internal val itemCount: AtomicInteger = AtomicInteger(INITIAL_ITEM_COUNT) - - override suspend fun load(params: LoadParams): LoadResult { - return withContext(context) { - val tempCount = itemCount.get() - // if itemCount is < 0, then it is initial load - if (tempCount == INITIAL_ITEM_COUNT) { - initialLoad(params) - } else { - nonInitialLoad(params, tempCount) - } - } - } - - /** - * For the very first time that this PagingSource's [load] is called. Executes the count - * query (initializes [itemCount]) and db query within a transaction to ensure initial load's - * data integrity. - * - * For example, if the database gets updated after the count query but before the db query - * completes, the paging source may not invalidate in time, but this method will return - * data based on the original database that the count was performed on to ensure a valid - * initial load. - */ - private suspend fun initialLoad(params: LoadParams): LoadResult { - return transacter.transactionWithResult { - val tempCount = countQuery.executeAsOne() - itemCount.set(tempCount) - queryDatabase( - params = params, - itemCount = tempCount, - ) - } - } - - private suspend fun nonInitialLoad( - params: LoadParams, - tempCount: Int, - ): LoadResult { - val loadResult = queryDatabase( - params = params, - itemCount = tempCount, - ) - @Suppress("UNCHECKED_CAST") - return if (invalid) INVALID as LoadResult.Invalid else loadResult - } - - override fun getRefreshKey(state: PagingState): Int? { - return state.getClippedRefreshKey() - } - - override val jumpingSupported: Boolean - get() = true - - /** - * calls RoomDatabase.query() to return a cursor and then calls convertRows() to extract and - * return list of data - * - * throws [IllegalArgumentException] from CursorUtil if column does not exist - * - * @param params load params to calculate query limit and offset - * - * @param itemCount the db row count, triggers a new PagingSource generation if itemCount changes, - * i.e. items are added / removed - */ - private fun queryDatabase( - params: LoadParams, - itemCount: Int, - ): LoadResult { - val key = params.key ?: 0 - val limit: Int = getLimit(params, key) - val offset: Int = getOffset(params, key, itemCount) - val data = queryProvider(limit, offset) - .also { currentQuery = it } - .executeAsList() - val nextPosToLoad = offset + data.size - val nextKey = - if (data.isEmpty() || data.size < limit || nextPosToLoad >= itemCount) { - null - } else { - nextPosToLoad - } - val prevKey = if (offset <= 0 || data.isEmpty()) null else offset - return LoadResult.Page( - data = data, - prevKey = prevKey, - nextKey = nextKey, - itemsBefore = offset, - itemsAfter = maxOf(0, itemCount - nextPosToLoad) - ) - } -} 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..54a9b4683f0 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,31 +33,29 @@ 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 + } + 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), + ) } } diff --git a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt b/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt deleted file mode 100644 index 5a48006b762..00000000000 --- a/extensions/android-paging3/src/main/java/app/cash/sqldelight/paging3/util/RoomPagingUtil.kt +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright Square, Inc. -package app.cash.sqldelight.paging3.util - -import androidx.paging.PagingSource -import androidx.paging.PagingSource.LoadParams -import androidx.paging.PagingSource.LoadParams.Prepend -import androidx.paging.PagingSource.LoadParams.Append -import androidx.paging.PagingSource.LoadParams.Refresh -import androidx.paging.PagingSource.LoadResult -import androidx.paging.PagingState -import app.cash.sqldelight.Query - -/** - * A [LoadResult] that can be returned to trigger a new generation of PagingSource - * - * Any loaded data or queued loads prior to returning INVALID will be discarded - */ -val INVALID = LoadResult.Invalid() - -/** - * The default itemCount value - */ -const val INITIAL_ITEM_COUNT = -1 - -/** - * Calculates query limit based on LoadType. - * - * Prepend: If requested loadSize is larger than available number of items to prepend, it will - * query with OFFSET = 0, LIMIT = prevKey - */ -fun getLimit(params: LoadParams, key: Int): Int { - return when (params) { - is Prepend -> - if (key < params.loadSize) { - key - } else { - params.loadSize - } - else -> params.loadSize - } -} - -/** - * calculates query offset amount based on loadtype - * - * Prepend: OFFSET is calculated by counting backwards the number of items that needs to be - * loaded before [key]. For example, if key = 30 and loadSize = 5, then offset = 25 and items - * in db position 26-30 are loaded. - * If requested loadSize is larger than the number of available items to - * prepend, OFFSET clips to 0 to prevent negative OFFSET. - * - * Refresh: - * If initialKey is supplied through Pager, Paging 3 will now start loading from - * initialKey with initialKey being the first item. - * If key is supplied by [getClippedRefreshKey], the key has already been adjusted to load half - * of the requested items before anchorPosition and the other half after anchorPosition. See - * comments on [getClippedRefreshKey] for more details. - * If key (regardless if from initialKey or [getClippedRefreshKey]) is larger than available items, - * the last page will be loaded by counting backwards the loadSize before last item in - * database. For example, this can happen if invalidation came from a large number of items - * dropped. i.e. in items 0 - 100, items 41-80 are dropped. Depending on last - * viewed item, hypothetically [getClippedRefreshKey] may return key = 60. If loadSize = 10, then items - * 31-40 will be loaded. - */ -fun getOffset(params: LoadParams, key: Int, itemCount: Int): Int { - return when (params) { - is Prepend -> - if (key < params.loadSize) { - 0 - } else { - key - params.loadSize - } - is Append -> key - is Refresh -> - if (key >= itemCount) { - maxOf(0, itemCount - params.loadSize) - } else { - key - } - } -} - -/** - * Returns the key for [PagingSource] for a non-initial REFRESH load. - * - * To prevent a negative key, key is clipped to 0 when the number of items available before - * anchorPosition is less than the requested amount of initialLoadSize / 2. - */ -fun PagingState.getClippedRefreshKey(): Int? { - return when (val anchorPosition = anchorPosition) { - null -> null - /** - * It is unknown whether anchorPosition represents the item at the top of the screen or item at - * the bottom of the screen. To ensure the number of items loaded is enough to fill up the - * screen, half of loadSize is loaded before the anchorPosition and the other half is - * loaded after the anchorPosition -- anchorPosition becomes the middle item. - */ - else -> maxOf(0, anchorPosition - (config.initialLoadSize / 2)) - } -} From f24f92b24fc2ca4b59cf90bffd50946babf5bd9b Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Mon, 25 Jul 2022 17:58:43 +0100 Subject: [PATCH 13/19] Copy-paste tests from AndroidX --- .../InvalidationTrackerExtRoomPaging.kt | 45 + .../sqldelight/paging3/LimitOffsetTestDb.kt | 25 + .../paging3/OffsetQueryPagingSourceTest.kt | 1114 ++++++++++++++--- .../app/cash/sqldelight/paging3/TestItem.kt | 26 + .../cash/sqldelight/paging3/TestItemDao.kt | 37 + 5 files changed, 1061 insertions(+), 186 deletions(-) create mode 100644 extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/InvalidationTrackerExtRoomPaging.kt create mode 100644 extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/LimitOffsetTestDb.kt create mode 100644 extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/TestItem.kt create mode 100644 extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/TestItemDao.kt diff --git a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/InvalidationTrackerExtRoomPaging.kt b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/InvalidationTrackerExtRoomPaging.kt new file mode 100644 index 00000000000..f34bc3d0e7a --- /dev/null +++ b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/InvalidationTrackerExtRoomPaging.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * 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 kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import java.util.concurrent.TimeUnit + +/** + * Makes refresh runnable accessible in tests. Used for LimitOffsetPagingSource unit tests that + * needs to block InvalidationTracker's invalidation notification + */ +val InvalidationTracker.refreshRunnableForTest: Runnable + get() = this.refreshRunnable + +/** + * True if invalidation tracker is pending a refresh event to get database changes. + */ +val InvalidationTracker.pendingRefreshForTest + get() = this.pendingRefresh.get() + +/** + * Polls [InvalidationTracker] until it sets its pending refresh flag to true. + */ +suspend fun InvalidationTracker.awaitPendingRefresh() { + withTimeout(TimeUnit.SECONDS.toMillis(3)) { + while (true) { + if (pendingRefreshForTest) return@withTimeout + delay(50) + } + } +} diff --git a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/LimitOffsetTestDb.kt b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/LimitOffsetTestDb.kt new file mode 100644 index 00000000000..9124b4d60ef --- /dev/null +++ b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/LimitOffsetTestDb.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * 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.room.Database +import androidx.room.RoomDatabase + +@Database(entities = [TestItem::class], version = 1, exportSchema = false) +abstract class LimitOffsetTestDb : RoomDatabase() { + abstract val dao: TestItemDao +} 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 index d83a457d6e1..7a7e10780e0 100644 --- 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 @@ -15,273 +15,1015 @@ */ package app.cash.sqldelight.paging3 -import androidx.paging.PagingSource.LoadParams.Refresh +import android.database.Cursor +import androidx.arch.core.executor.testing.CountingTaskExecutorRule +import androidx.paging.LoadType +import androidx.paging.PagingConfig +import androidx.paging.PagingSource 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 androidx.paging.PagingState +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.RoomSQLiteQuery +import androidx.room.awaitPendingRefresh +import androidx.room.util.getColumnIndexOrThrow +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.testutils.FilteringExecutor +import androidx.testutils.TestExecutor +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test -import kotlin.coroutines.EmptyCoroutineContext +import org.junit.runner.RunWith +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.Job -@ExperimentalCoroutinesApi -class OffsetQueryPagingSourceTest { +private const val tableName: String = "TestItem" - private lateinit var driver: SqlDriver - private lateinit var transacter: Transacter +@RunWith(AndroidJUnit4::class) +@SmallTest +class LimitOffsetPagingSourceTest { - @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) {} + @JvmField + @Rule + val countingTaskExecutorRule = CountingTaskExecutorRule() + + private lateinit var database: LimitOffsetTestDb + private lateinit var dao: TestItemDao + + @Before + fun init() { + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + LimitOffsetTestDb::class.java, + ).build() + dao = database.dao } - @Test fun `empty page gives correct prevKey and nextKey`() { - driver.execute(null, "DELETE FROM testTable", 0) - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) + @After + fun tearDown() { + database.close() + // At the end of all tests, query executor should be idle (transaction thread released). + countingTaskExecutorRule.drainTasks(500, TimeUnit.MILLISECONDS) + assertThat(countingTaskExecutorRule.isIdle).isTrue() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun load_usesQueryExecutor() = runTest { + val queryExecutor = TestExecutor() + val transactionExecutor = TestExecutor() + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + LimitOffsetTestDb::class.java, + ).setQueryExecutor(queryExecutor) + .setTransactionExecutor(transactionExecutor) + .build() + + // Ensure there are no init tasks enqueued on queryExecutor before we call .load(). + assertThat(queryExecutor.executeAll()).isFalse() + assertThat(transactionExecutor.executeAll()).isFalse() + + val job = Job() + launch(job) { + LimitOffsetPagingSourceImpl(database).load( + PagingSource.LoadParams.Refresh( + key = null, + loadSize = 1, + placeholdersEnabled = true + ) + ) + } + + // Let the launched job start and proceed as far as possible. + advanceUntilIdle() - val results = runBlocking { source.load(Refresh(null, 2, false)) } + // Check that .load() dispatches on queryExecutor before jumping into a transaction for + // initial load. + assertThat(transactionExecutor.executeAll()).isFalse() + assertThat(queryExecutor.executeAll()).isTrue() + + job.cancel() + } - assertNull((results as LoadResult.Page).prevKey) - assertNull(results.nextKey) + @Test + fun test_itemCount() { + dao.addAllItems(ITEMS_LIST) + val pagingSource = LimitOffsetPagingSourceImpl(database) + runBlocking { + // count query is executed on first load + pagingSource.refresh() + + assertThat(pagingSource.itemCount.get()).isEqualTo(100) + } } - @Test fun `aligned first page gives correct prevKey and nextKey`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, + @Test + fun test_itemCountWithSuppliedLimitOffset() { + dao.addAllItems(ITEMS_LIST) + val pagingSource = LimitOffsetPagingSourceImpl( + db = database, + queryString = "SELECT * FROM $tableName ORDER BY id ASC LIMIT 60 OFFSET 30", ) + runBlocking { + // count query is executed on first load + pagingSource.refresh() + // should be 60 instead of 100 + assertThat(pagingSource.itemCount.get()).isEqualTo(60) + } + } - val results = runBlocking { source.load(Refresh(null, 2, false)) } + @Test + fun dbInsert_pagingSourceInvalidates() { + dao.addAllItems(ITEMS_LIST) + val pagingSource = LimitOffsetPagingSourceImpl(database) + runBlocking { + // load once to register db observers + pagingSource.refresh() + assertThat(pagingSource.invalid).isFalse() + // paging source should be invalidated when insert into db + val result = dao.addTestItem(TestItem(101)) + countingTaskExecutorRule.drainTasks(500, TimeUnit.MILLISECONDS) + assertThat(result).isEqualTo(101) + assertTrue(pagingSource.invalid) + } + } - assertNull((results as LoadResult.Page).prevKey) - assertEquals(2, results.nextKey) + @Test + fun dbDelete_pagingSourceInvalidates() { + dao.addAllItems(ITEMS_LIST) + val pagingSource = LimitOffsetPagingSourceImpl(database) + runBlocking { + // load once to register db observers + pagingSource.refresh() + assertThat(pagingSource.invalid).isFalse() + // paging source should be invalidated when delete from db + dao.deleteTestItem(TestItem(50)) + countingTaskExecutorRule.drainTasks(5, TimeUnit.SECONDS) + assertTrue(pagingSource.invalid) + } } - @Test fun `aligned last page gives correct prevKey and nextKey`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) + @Test + fun invalidDbQuery_pagingSourceDoesNotInvalidate() { + dao.addAllItems(ITEMS_LIST) + val pagingSource = LimitOffsetPagingSourceImpl(database) + runBlocking { + // load once to register db observers + pagingSource.refresh() + assertThat(pagingSource.invalid).isFalse() - val results = runBlocking { source.load(Refresh(8, 2, false)) } + val result = dao.deleteTestItem(TestItem(1000)) - assertEquals(6, (results as LoadResult.Page).prevKey) - assertNull(results.nextKey) + // invalid delete. Should have 0 items deleted and paging source remains valid + assertThat(result).isEqualTo(0) + assertFalse(pagingSource.invalid) + } } - @Test fun `simple sequential page exhaustion gives correct results`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) + @Test + fun load_initialLoad() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + dao.addAllItems(ITEMS_LIST) + runBlocking { + val result = pagingSource.refresh() as LoadResult.Page + assertThat(result.data).containsExactlyElementsIn( + ITEMS_LIST.subList(0, 15) + ) + } + } + + @Test + fun load_initialEmptyLoad() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + runBlocking { + val result = pagingSource.refresh() as LoadResult.Page + + assertTrue(result.data.isEmpty()) + + // now add items + dao.addAllItems(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() { + dao.addAllItems(ITEMS_LIST) + val pagingSource = LimitOffsetPagingSourceImpl(database) + // refresh with initial key = 20 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) + 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 `misaligned refresh at end page boundary gives null nextKey`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, + @Test + fun load_initialLoadWithSuppliedLimitOffset() { + dao.addAllItems(ITEMS_LIST) + val pagingSource = LimitOffsetPagingSourceImpl( + db = database, + queryString = "SELECT * FROM $tableName ORDER BY id ASC LIMIT 10 OFFSET 30", ) + runBlocking { + val result = pagingSource.refresh() as LoadResult.Page - val results = runBlocking { source.load(Refresh(9, 2, false)) } - - assertEquals(7, (results as LoadResult.Page).prevKey) - assertNull(results.nextKey) + // default initial loadSize = 15 starting from index 0. + // user supplied limit offset should cause initial loadSize = 10, starting from index 30 + assertThat(result.data).containsExactlyElementsIn( + ITEMS_LIST.subList(30, 40) + ) + // check that no append/prepend can be triggered after this terminal load + assertThat(result.nextKey).isNull() + assertThat(result.prevKey).isNull() + assertThat(result.itemsBefore).isEqualTo(0) + assertThat(result.itemsAfter).isEqualTo(0) + } } - @Test fun `misaligned refresh at first page boundary gives proper prevKey`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, + @Test + fun load_oneAdditionalQueryArguments() { + dao.addAllItems(ITEMS_LIST) + val pagingSource = LimitOffsetPagingSourceImpl( + db = database, + queryString = "SELECT * FROM $tableName WHERE id < 50 ORDER BY id ASC", ) + // refresh with initial key = 40 + runBlocking { + val result = pagingSource.refresh(key = 40) as LoadResult.Page - val results = runBlocking { source.load(Refresh(1, 2, false)) } + // initial loadSize = 15, but limited by id < 50, should only load items 40 - 50 + assertThat(result.data).containsExactlyElementsIn( + ITEMS_LIST.subList(40, 50) + ) + // should have 50 items fulfilling condition of id < 50 (TestItem id 0 - 49) + assertThat(pagingSource.itemCount.get()).isEqualTo(50) + } + } + + @Test + fun load_multipleQueryArguments() { + dao.addAllItems(ITEMS_LIST) + val pagingSource = LimitOffsetPagingSourceImpl( + db = database, + queryString = "SELECT * " + + "FROM $tableName " + + "WHERE id > 50 AND value LIKE 'item 90'" + + "ORDER BY id ASC", + ) + runBlocking { + val result = pagingSource.refresh() as LoadResult.Page - assertEquals(-1, (results as LoadResult.Page).prevKey) - assertEquals(3, results.nextKey) + assertThat(result.data).containsExactly(ITEMS_LIST[90]) + assertThat(pagingSource.itemCount.get()).isEqualTo(1) + } } - @Test fun `initial page has correct itemsBefore and itemsAfter`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, + @Test + fun load_InvalidUserSuppliedOffset_returnEmpty() { + dao.addAllItems(ITEMS_LIST) + val pagingSource = LimitOffsetPagingSourceImpl( + db = database, + queryString = "SELECT * FROM $tableName ORDER BY id ASC LIMIT 10 OFFSET 500", ) + runBlocking { + val result = pagingSource.refresh() as LoadResult.Page - val results = runBlocking { source.load(Refresh(null, 2, false)) } + // invalid OFFSET = 500 should return empty data + assertThat(result.data).isEmpty() - assertEquals(0, (results as LoadResult.Page).itemsBefore) - assertEquals(8, results.itemsAfter) + // check that no append/prepend can be triggered + assertThat(pagingSource.itemCount.get()).isEqualTo(0) + assertThat(result.nextKey).isNull() + assertThat(result.prevKey).isNull() + assertThat(result.itemsBefore).isEqualTo(0) + assertThat(result.itemsAfter).isEqualTo(0) + } } - @Test fun `middle page has correct itemsBefore and itemsAfter`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, + @Test + fun load_UserSuppliedNegativeLimit() { + dao.addAllItems(ITEMS_LIST) + val pagingSource = LimitOffsetPagingSourceImpl( + db = database, + queryString = "SELECT * FROM $tableName ORDER BY id ASC LIMIT -1", ) + runBlocking { + val result = pagingSource.refresh() as LoadResult.Page - val results = runBlocking { source.load(Refresh(4, 2, false)) } + // ensure that it respects SQLite's default behavior for negative LIMIT + assertThat(result.data).containsExactlyElementsIn( + ITEMS_LIST.subList(0, 15) + ) + // should behave as if no LIMIT were set + assertThat(pagingSource.itemCount.get()).isEqualTo(100) + assertThat(result.nextKey).isEqualTo(15) + assertThat(result.prevKey).isNull() + assertThat(result.itemsBefore).isEqualTo(0) + assertThat(result.itemsAfter).isEqualTo(85) + } + } - assertEquals(4, (results as LoadResult.Page).itemsBefore) - assertEquals(4, results.itemsAfter) + @Test + fun invalidInitialKey_dbEmpty_returnsEmpty() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + runBlocking { + val result = pagingSource.refresh(key = 101) as LoadResult.Page + + assertThat(result.data).isEmpty() + } } - @Test fun `end page has correct itemsBefore and itemsAfter`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) + @Test + fun invalidInitialKey_keyTooLarge_returnsLastPage() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + dao.addAllItems(ITEMS_LIST) + runBlocking { + val result = pagingSource.refresh(key = 101) as LoadResult.Page - val results = runBlocking { source.load(Refresh(8, 2, false)) } + // should load the last page + assertThat(result.data).containsExactlyElementsIn( + ITEMS_LIST.subList(85, 100) + ) + } + } - assertEquals(8, (results as LoadResult.Page).itemsBefore) - assertEquals(0, results.itemsAfter) + @Test + fun invalidInitialKey_negativeKey() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + dao.addAllItems(ITEMS_LIST) + runBlocking { + // 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 `misaligned end page has correct itemsBefore and itemsAfter`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) + @Test + fun append_middleOfList() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + dao.addAllItems(ITEMS_LIST) + // to bypass check for initial load and run as non-initial load + pagingSource.itemCount.set(100) + runBlocking { + val result = pagingSource.append(key = 20) as LoadResult.Page - val results = runBlocking { source.load(Refresh(9, 2, false)) } + // 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) + } + } - assertEquals(9, (results as LoadResult.Page).itemsBefore) - assertEquals(0, results.itemsAfter) + @Test + fun append_availableItemsLessThanLoadSize() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + dao.addAllItems(ITEMS_LIST) + // to bypass check for initial load and run as non-initial load + pagingSource.itemCount.set(100) + runBlocking { + 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).isEqualTo(null) + assertThat(result.prevKey).isEqualTo(97) + } } - @Test fun `misaligned start page has correct itemsBefore and itemsAfter`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) + @Test + fun load_consecutiveAppend() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + dao.addAllItems(ITEMS_LIST) + // to bypass check for initial load and run as non-initial load + pagingSource.itemCount.set(100) + runBlocking { + // first append + val result = pagingSource.append(key = 30) as LoadResult.Page - val results = runBlocking { source.load(Refresh(1, 2, false)) } + // 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 - assertEquals(1, (results as LoadResult.Page).itemsBefore) - assertEquals(7, results.itemsAfter) + // TestItemId 35 - 39 loaded + assertThat(result2.data).containsExactlyElementsIn( + ITEMS_LIST.subList(35, 40) + ) + } } - @Test fun `prepend paging misaligned start page produces correct values`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) + @Test + fun append_invalidResult() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + dao.addAllItems(ITEMS_LIST) + // to bypass check for initial load and run as non-initial load + pagingSource.itemCount.set(100) + runBlocking { + // 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() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + dao.addAllItems(ITEMS_LIST) + // to bypass check for initial load and run as non-initial load + pagingSource.itemCount.set(100) 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) + 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 `key too big throws IndexOutOfBoundsException`() { - val source = OffsetQueryPagingSource( - this::query, - countQuery(), - transacter, - EmptyCoroutineContext, - ) + @Test + fun prepend_availableItemsLessThanLoadSize() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + dao.addAllItems(ITEMS_LIST) + // to bypass check for initial load and run as non-initial load + pagingSource.itemCount.set(100) + runBlocking { + 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).isEqualTo(null) + } + } + + @Test + fun load_consecutivePrepend() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + dao.addAllItems(ITEMS_LIST) + // to bypass check for initial load and run as non-initial load + pagingSource.itemCount.set(100) + runBlocking { + // 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() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + dao.addAllItems(ITEMS_LIST) + // to bypass check for initial load and run as non-initial load + pagingSource.itemCount.set(100) runBlocking { - assertFailsWith { - source.load(Refresh(10, 2, false)) + // 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() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + dao.addAllItems(ITEMS_LIST) + runBlocking { + // 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() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + dao.addAllItems(ITEMS_LIST) + runBlocking { + // 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() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + dao.addAllItems(ITEMS_LIST) + runBlocking { + // 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() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + dao.addAllItems(ITEMS_LIST) + runBlocking { + + pagingSource.refresh(key = 70) + + dao.deleteTestItems(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 = LimitOffsetPagingSourceImpl(database) + 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) + assertThat(pagingSource2.itemCount.get()).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).isEqualTo(null) + } + } + + /** + * 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() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + dao.addAllItems(ITEMS_LIST) + runBlocking { + pagingSource.refresh() + + assertThat(pagingSource.itemCount.get()).isEqualTo(100) + + // items id 0 - 29 deleted (30 items removed) + dao.deleteTestItems(0, 29) + + val pagingSource2 = LimitOffsetPagingSourceImpl(database) + // 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 + assertThat(pagingSource2.itemCount.get()).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).isEqualTo(null) + assertThat(result2.nextKey).isEqualTo(15) + } + } + + @Test + fun load_loadSizeAndRefreshKeyGreaterThanItemCount() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + dao.addAllItems(ITEMS_LIST) + runBlocking { + + pagingSource.refresh(key = 30) + + assertThat(pagingSource.itemCount.get()).isEqualTo(100) + // items id 0 - 94 deleted (95 items removed) + dao.deleteTestItems(0, 94) + + val pagingSource2 = LimitOffsetPagingSourceImpl(database) + // 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 + assertThat(pagingSource2.itemCount.get()).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).isEqualTo(null) + assertThat(result2.nextKey).isEqualTo(null) + } + } + + @Test + fun test_jumpSupport() { + val pagingSource = LimitOffsetPagingSourceImpl(database) + assertTrue(pagingSource.jumpingSupported) + } +} + +@RunWith(AndroidJUnit4::class) +@SmallTest +class LimitOffsetPagingSourceTestWithFilteringExecutor { + + private lateinit var db: LimitOffsetTestDb + private lateinit var dao: TestItemDao + + // Multiple threads are necessary to prevent deadlock, since Room will acquire a thread to + // dispatch on, when using the query / transaction dispatchers. + private val queryExecutor = FilteringExecutor(delegate = Executors.newFixedThreadPool(2)) + private val mainThreadQueries = mutableListOf>() + + @Before + fun init() { + val mainThread: Thread = runBlocking(Dispatchers.Main) { + Thread.currentThread() + } + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + LimitOffsetTestDb::class.java + ).setQueryCallback( + object : RoomDatabase.QueryCallback { + override fun onQuery(sqlQuery: String, bindArgs: List) { + if (Thread.currentThread() === mainThread) { + mainThreadQueries.add( + sqlQuery to Throwable().stackTraceToString() + ) + } + } + } + ) { + // instantly execute the log callback so that we can check the thread. + it.run() + }.setQueryExecutor(queryExecutor) + .build() + dao = db.dao + } + + @After + fun tearDown() { + // Check no mainThread queries happened. + assertThat(mainThreadQueries).isEmpty() + db.close() + } + + @Test + fun invalid_refresh() { + val pagingSource = LimitOffsetPagingSourceImpl(db) + runBlocking { + val result = pagingSource.refresh() as LoadResult.Page + + assertTrue(result.data.isEmpty()) + + // blocks invalidation notification from Room + queryExecutor.filterFunction = { runnable -> + runnable !== db.invalidationTracker.refreshRunnable } + + // now write to database + dao.addAllItems(ITEMS_LIST) + + // make sure room requests a refresh + db.invalidationTracker.awaitPendingRefresh() + // and that this is blocked to simulate delayed notification from room + queryExecutor.awaitDeferredSizeAtLeast(1) + + // the db write should cause pagingSource to realize it is invalid + assertThat(pagingSource.refresh()).isInstanceOf( + LoadResult.Invalid::class.java + ) + assertTrue(pagingSource.invalid) } } - @Test fun `query invalidation invalidates paging source`() { - val query = query(2, 0) - val source = OffsetQueryPagingSource( - { _, _ -> query }, - countQuery(), - transacter, - EmptyCoroutineContext, - ) + @Test + fun invalid_append() { + val pagingSource = LimitOffsetPagingSourceImpl(db) + dao.addAllItems(ITEMS_LIST) - runBlocking { source.load(Refresh(null, 0, false)) } + runBlocking { + val result = pagingSource.refresh() as LoadResult.Page + + // initial load + assertThat(result.data).containsExactlyElementsIn( + ITEMS_LIST.subList(0, 15) + ) + + // blocks invalidation notification from Room + queryExecutor.filterFunction = { runnable -> + runnable !== db.invalidationTracker.refreshRunnable + } - driver.notifyListeners(arrayOf("testTable")) + // now write to the database + dao.deleteTestItem(ITEMS_LIST[30]) - assertTrue(source.invalid) + // make sure room requests a refresh + db.invalidationTracker.awaitPendingRefresh() + // and that this is blocked to simulate delayed notification from room + queryExecutor.awaitDeferredSizeAtLeast(1) + + // the db write should cause pagingSource to realize it is invalid when it tries to + // append + assertThat(pagingSource.append(15)).isInstanceOf( + LoadResult.Invalid::class.java + ) + assertTrue(pagingSource.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()) + @Test + fun invalid_prepend() { + val pagingSource = LimitOffsetPagingSourceImpl(db) + dao.addAllItems(ITEMS_LIST) + + runBlocking { + val result = pagingSource.refresh(key = 20) as LoadResult.Page + + // initial load + assertThat(result.data).containsExactlyElementsIn( + ITEMS_LIST.subList(20, 35) + ) + + // blocks invalidation notification from Room + queryExecutor.filterFunction = { runnable -> + runnable !== db.invalidationTracker.refreshRunnable + } + + // now write to the database + dao.deleteTestItem(ITEMS_LIST[30]) + + // make sure room requests a refresh + db.invalidationTracker.awaitPendingRefresh() + // and that this is blocked to simulate delayed notification from room + queryExecutor.awaitDeferredSizeAtLeast(1) + + // the db write should cause pagingSource to realize it is invalid when it tries to + // append + assertThat(pagingSource.prepend(20)).isInstanceOf( + LoadResult.Invalid::class.java + ) + assertTrue(pagingSource.invalid) } + } +} + +class LimitOffsetPagingSourceImpl( + db: RoomDatabase, + queryString: String = "SELECT * FROM $tableName ORDER BY id ASC", +) : LimitOffsetPagingSource( + sourceQuery = RoomSQLiteQuery.acquire( + queryString, + 0 + ), + db = db, + tables = arrayOf("$tableName") +) { - override fun addListener(listener: Listener) = driver.addListener(listener, arrayOf("testTable")) - override fun removeListener(listener: Listener) = driver.removeListener(listener, arrayOf("testTable")) + override fun convertRows(cursor: Cursor): List { + val cursorIndexOfId = getColumnIndexOrThrow(cursor, "id") + val data = mutableListOf() + while (cursor.moveToNext()) { + val tmpId = cursor.getInt(cursorIndexOfId) + data.add(TestItem(tmpId)) + } + return data } +} - private fun countQuery() = Query( - 2, - arrayOf("testTable"), - driver, - "Test.sq", - "count", - "SELECT count(*) FROM testTable", - { it.getLong(0)!!.toInt() }, - ) +private val CONFIG = PagingConfig( + pageSize = 5, + enablePlaceholders = true, + initialLoadSize = 15 +) - private fun insert(value: Long, db: SqlDriver = driver) { - db.execute(0, "INSERT INTO testTable (value) VALUES (?)", 1) { - bindLong(0, value) +private val ITEMS_LIST = createItemsForDb(0, 100) + +private fun createLoadParam( + loadType: LoadType, + key: Int? = null, + initialLoadSize: Int = CONFIG.initialLoadSize, + pageSize: Int = CONFIG.pageSize, + placeholdersEnabled: Boolean = CONFIG.enablePlaceholders +): PagingSource.LoadParams { + return when (loadType) { + LoadType.REFRESH -> { + PagingSource.LoadParams.Refresh( + key = key, + loadSize = initialLoadSize, + placeholdersEnabled = placeholdersEnabled + ) + } + LoadType.APPEND -> { + PagingSource.LoadParams.Append( + key = key ?: -1, + loadSize = pageSize, + placeholdersEnabled = placeholdersEnabled + ) + } + LoadType.PREPEND -> { + PagingSource.LoadParams.Prepend( + key = key ?: -1, + loadSize = pageSize, + placeholdersEnabled = placeholdersEnabled + ) } } } + +private fun createItemsForDb(startId: Int, count: Int): List { + return List(count) { + TestItem( + id = it + startId, + ) + } +} + +private suspend fun PagingSource.refresh( + key: Int? = null, +): LoadResult { + return this.load( + createLoadParam( + loadType = LoadType.REFRESH, + key = key, + ) + ) +} + +private suspend fun PagingSource.append( + key: Int? = -1, +): LoadResult { + return this.load( + createLoadParam( + loadType = LoadType.APPEND, + key = key, + ) + ) +} + +private suspend fun PagingSource.prepend( + key: Int? = -1, +): LoadResult { + return this.load( + createLoadParam( + loadType = LoadType.PREPEND, + key = key, + ) + ) +} diff --git a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/TestItem.kt b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/TestItem.kt new file mode 100644 index 00000000000..0a804e2f5c6 --- /dev/null +++ b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/TestItem.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * 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.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class TestItem( + @PrimaryKey val id: Int, + val value: String = "item $id" +) diff --git a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/TestItemDao.kt b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/TestItemDao.kt new file mode 100644 index 00000000000..a1f5c1f032f --- /dev/null +++ b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/TestItemDao.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * 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.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query + +@Dao +interface TestItemDao { + @Insert + fun addAllItems(testItems: List) + + @Insert + fun addTestItem(testItem: TestItem): Long + + @Delete + fun deleteTestItem(testItem: TestItem): Int + + @Query("DELETE FROM TestItem WHERE id >= :start AND id <= :end") + fun deleteTestItems(start: Int, end: Int): Int +} From 562c0928d275b93e5b16b173ef77f43c94e54188 Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Thu, 28 Jul 2022 15:39:23 +0100 Subject: [PATCH 14/19] Fix test compilation --- extensions/android-paging3/build.gradle | 10 +- .../paging3/OffsetQueryPagingSource.kt | 6 +- .../InvalidationTrackerExtRoomPaging.kt | 45 - .../sqldelight/paging3/LimitOffsetTestDb.kt | 25 - .../paging3/OffsetQueryPagingSourceTest.kt | 1345 +++++++---------- .../app/cash/sqldelight/paging3/TestItem.kt | 26 - .../cash/sqldelight/paging3/TestItemDao.kt | 37 - .../app/cash/sqldelight/paging3/pagingData.kt | 39 + gradle/libs.versions.toml | 1 + 9 files changed, 572 insertions(+), 962 deletions(-) delete mode 100644 extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/InvalidationTrackerExtRoomPaging.kt delete mode 100644 extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/LimitOffsetTestDb.kt delete mode 100644 extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/TestItem.kt delete mode 100644 extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/TestItemDao.kt create mode 100644 extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/pagingData.kt diff --git a/extensions/android-paging3/build.gradle b/extensions/android-paging3/build.gradle index f3092dd1957..a66d6eedf7f 100644 --- a/extensions/android-paging3/build.gradle +++ b/extensions/android-paging3/build.gradle @@ -1,9 +1,15 @@ plugins { - alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) alias(deps.plugins.publish) alias(deps.plugins.dokka) } +android { + namespace 'app.cash.sqldelight.paging3' + compileSdkVersion 23 +} + archivesBaseName = 'sqldelight-androidx-paging3' dependencies { @@ -12,6 +18,8 @@ dependencies { api deps.androidx.paging3.common testImplementation project(':drivers:sqlite-driver') + testImplementation deps.androidx.paging3.runtime + testImplementation deps.androidx.recyclerView testImplementation deps.truth testImplementation deps.kotlin.test.junit testImplementation deps.kotlin.coroutines.test 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 54a9b4683f0..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 @@ -38,7 +38,7 @@ internal class OffsetQueryPagingSource( is LoadParams.Prepend -> minOf(key, params.loadSize) else -> params.loadSize } - transacter.transactionWithResult { + val loadResult = transacter.transactionWithResult { val count = countQuery.executeAsOne() val offset = when (params) { is LoadParams.Prepend -> maxOf(0, key - params.loadSize) @@ -57,7 +57,9 @@ internal class OffsetQueryPagingSource( 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/InvalidationTrackerExtRoomPaging.kt b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/InvalidationTrackerExtRoomPaging.kt deleted file mode 100644 index f34bc3d0e7a..00000000000 --- a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/InvalidationTrackerExtRoomPaging.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2021 The Android Open Source Project - * - * 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 kotlinx.coroutines.delay -import kotlinx.coroutines.withTimeout -import java.util.concurrent.TimeUnit - -/** - * Makes refresh runnable accessible in tests. Used for LimitOffsetPagingSource unit tests that - * needs to block InvalidationTracker's invalidation notification - */ -val InvalidationTracker.refreshRunnableForTest: Runnable - get() = this.refreshRunnable - -/** - * True if invalidation tracker is pending a refresh event to get database changes. - */ -val InvalidationTracker.pendingRefreshForTest - get() = this.pendingRefresh.get() - -/** - * Polls [InvalidationTracker] until it sets its pending refresh flag to true. - */ -suspend fun InvalidationTracker.awaitPendingRefresh() { - withTimeout(TimeUnit.SECONDS.toMillis(3)) { - while (true) { - if (pendingRefreshForTest) return@withTimeout - delay(50) - } - } -} diff --git a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/LimitOffsetTestDb.kt b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/LimitOffsetTestDb.kt deleted file mode 100644 index 9124b4d60ef..00000000000 --- a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/LimitOffsetTestDb.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2021 The Android Open Source Project - * - * 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.room.Database -import androidx.room.RoomDatabase - -@Database(entities = [TestItem::class], version = 1, exportSchema = false) -abstract class LimitOffsetTestDb : RoomDatabase() { - abstract val dao: TestItemDao -} 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 index 7a7e10780e0..bbdb8e879dd 100644 --- 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 @@ -15,680 +15,495 @@ */ package app.cash.sqldelight.paging3 -import android.database.Cursor -import androidx.arch.core.executor.testing.CountingTaskExecutorRule 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.room.Room -import androidx.room.RoomDatabase -import androidx.room.RoomSQLiteQuery -import androidx.room.awaitPendingRefresh -import androidx.room.util.getColumnIndexOrThrow -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.SmallTest -import androidx.testutils.FilteringExecutor -import androidx.testutils.TestExecutor +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.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.advanceUntilIdle +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.Rule import org.junit.Test -import org.junit.runner.RunWith -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit +import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.assertFailsWith -import kotlin.test.assertFalse import kotlin.test.assertTrue -import kotlinx.coroutines.Job -private const val tableName: String = "TestItem" +@ExperimentalCoroutinesApi +class OffsetQueryPagingSourceTest { -@RunWith(AndroidJUnit4::class) -@SmallTest -class LimitOffsetPagingSourceTest { - - @JvmField - @Rule - val countingTaskExecutorRule = CountingTaskExecutorRule() - - private lateinit var database: LimitOffsetTestDb - private lateinit var dao: TestItemDao + private lateinit var driver: SqlDriver + private lateinit var transacter: Transacter @Before fun init() { - database = Room.inMemoryDatabaseBuilder( - ApplicationProvider.getApplicationContext(), - LimitOffsetTestDb::class.java, - ).build() - dao = database.dao + 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() { - database.close() - // At the end of all tests, query executor should be idle (transaction thread released). - countingTaskExecutorRule.drainTasks(500, TimeUnit.MILLISECONDS) - assertThat(countingTaskExecutorRule.isIdle).isTrue() + Dispatchers.resetMain() } - @OptIn(ExperimentalCoroutinesApi::class) @Test - fun load_usesQueryExecutor() = runTest { - val queryExecutor = TestExecutor() - val transactionExecutor = TestExecutor() - database = Room.inMemoryDatabaseBuilder( - ApplicationProvider.getApplicationContext(), - LimitOffsetTestDb::class.java, - ).setQueryExecutor(queryExecutor) - .setTransactionExecutor(transactionExecutor) - .build() - - // Ensure there are no init tasks enqueued on queryExecutor before we call .load(). - assertThat(queryExecutor.executeAll()).isFalse() - assertThat(transactionExecutor.executeAll()).isFalse() - - val job = Job() - launch(job) { - LimitOffsetPagingSourceImpl(database).load( - PagingSource.LoadParams.Refresh( - key = null, - loadSize = 1, - placeholdersEnabled = true - ) - ) - } - - // Let the launched job start and proceed as far as possible. - advanceUntilIdle() - - // Check that .load() dispatches on queryExecutor before jumping into a transaction for - // initial load. - assertThat(transactionExecutor.executeAll()).isFalse() - assertThat(queryExecutor.executeAll()).isTrue() + fun test_itemCount() = runTest { + insertItems(ITEMS_LIST) - job.cancel() - } + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) + pagingSource.refresh() - @Test - fun test_itemCount() { - dao.addAllItems(ITEMS_LIST) - val pagingSource = LimitOffsetPagingSourceImpl(database) - runBlocking { - // count query is executed on first load - pagingSource.refresh() - - assertThat(pagingSource.itemCount.get()).isEqualTo(100) - } + Pager(CONFIG, pagingSourceFactory = { pagingSource }) + .flow + .first() + .withPagingDataDiffer(this, testItemDiffCallback) { + assertThat(itemCount).isEqualTo(100) + } } @Test - fun test_itemCountWithSuppliedLimitOffset() { - dao.addAllItems(ITEMS_LIST) - val pagingSource = LimitOffsetPagingSourceImpl( - db = database, - queryString = "SELECT * FROM $tableName ORDER BY id ASC LIMIT 60 OFFSET 30", + fun invalidDbQuery_pagingSourceDoesNotInvalidate() = runTest { + insertItems(ITEMS_LIST) + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, ) - runBlocking { - // count query is executed on first load - pagingSource.refresh() - // should be 60 instead of 100 - assertThat(pagingSource.itemCount.get()).isEqualTo(60) - } - } + // load once to register db observers + pagingSource.refresh() + assertThat(pagingSource.invalid).isFalse() - @Test - fun dbInsert_pagingSourceInvalidates() { - dao.addAllItems(ITEMS_LIST) - val pagingSource = LimitOffsetPagingSourceImpl(database) - runBlocking { - // load once to register db observers - pagingSource.refresh() - assertThat(pagingSource.invalid).isFalse() - // paging source should be invalidated when insert into db - val result = dao.addTestItem(TestItem(101)) - countingTaskExecutorRule.drainTasks(500, TimeUnit.MILLISECONDS) - assertThat(result).isEqualTo(101) - assertTrue(pagingSource.invalid) - } - } + val result = deleteItem(TestItem(1000)) - @Test - fun dbDelete_pagingSourceInvalidates() { - dao.addAllItems(ITEMS_LIST) - val pagingSource = LimitOffsetPagingSourceImpl(database) - runBlocking { - // load once to register db observers - pagingSource.refresh() - assertThat(pagingSource.invalid).isFalse() - // paging source should be invalidated when delete from db - dao.deleteTestItem(TestItem(50)) - countingTaskExecutorRule.drainTasks(5, TimeUnit.SECONDS) - assertTrue(pagingSource.invalid) - } + // invalid delete. Should have 0 items deleted and paging source remains valid + assertThat(result).isEqualTo(0) + assertThat(pagingSource.invalid).isFalse() } @Test - fun invalidDbQuery_pagingSourceDoesNotInvalidate() { - dao.addAllItems(ITEMS_LIST) - val pagingSource = LimitOffsetPagingSourceImpl(database) - runBlocking { - // load once to register db observers - pagingSource.refresh() - assertThat(pagingSource.invalid).isFalse() - - val result = dao.deleteTestItem(TestItem(1000)) - - // invalid delete. Should have 0 items deleted and paging source remains valid - assertThat(result).isEqualTo(0) - assertFalse(pagingSource.invalid) - } - } + fun load_initialLoad() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) + insertItems(ITEMS_LIST) + val result = pagingSource.refresh() as LoadResult.Page - @Test - fun load_initialLoad() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - dao.addAllItems(ITEMS_LIST) - runBlocking { - val result = pagingSource.refresh() as LoadResult.Page - - assertThat(result.data).containsExactlyElementsIn( - ITEMS_LIST.subList(0, 15) - ) - } + assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(0, 15)) } @Test - fun load_initialEmptyLoad() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - runBlocking { - val result = pagingSource.refresh() as LoadResult.Page + fun load_initialEmptyLoad() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) + val result = pagingSource.refresh() as LoadResult.Page - assertTrue(result.data.isEmpty()) + assertTrue(result.data.isEmpty()) - // now add items - dao.addAllItems(ITEMS_LIST) + // now add items + insertItems(ITEMS_LIST) - // invalidate pagingSource to imitate invalidation from running refreshVersionSync - pagingSource.invalidate() - assertTrue(pagingSource.invalid) + // 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 - ) - } + // 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() { - dao.addAllItems(ITEMS_LIST) - val pagingSource = LimitOffsetPagingSourceImpl(database) + fun load_initialLoadWithInitialKey() = runTest { + insertItems(ITEMS_LIST) + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) // refresh with initial key = 20 - runBlocking { - val result = pagingSource.refresh(key = 20) as LoadResult.Page + 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) - ) - } + // item in pos 21-35 (TestItemId 20-34) loaded + assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(20, 35)) } @Test - fun load_initialLoadWithSuppliedLimitOffset() { - dao.addAllItems(ITEMS_LIST) - val pagingSource = LimitOffsetPagingSourceImpl( - db = database, - queryString = "SELECT * FROM $tableName ORDER BY id ASC LIMIT 10 OFFSET 30", + fun invalidInitialKey_dbEmpty_returnsEmpty() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, ) - runBlocking { - val result = pagingSource.refresh() as LoadResult.Page + val result = pagingSource.refresh(key = 101) as LoadResult.Page - // default initial loadSize = 15 starting from index 0. - // user supplied limit offset should cause initial loadSize = 10, starting from index 30 - assertThat(result.data).containsExactlyElementsIn( - ITEMS_LIST.subList(30, 40) - ) - // check that no append/prepend can be triggered after this terminal load - assertThat(result.nextKey).isNull() - assertThat(result.prevKey).isNull() - assertThat(result.itemsBefore).isEqualTo(0) - assertThat(result.itemsAfter).isEqualTo(0) - } + assertThat(result.data).isEmpty() } @Test - fun load_oneAdditionalQueryArguments() { - dao.addAllItems(ITEMS_LIST) - val pagingSource = LimitOffsetPagingSourceImpl( - db = database, - queryString = "SELECT * FROM $tableName WHERE id < 50 ORDER BY id ASC", + fun invalidInitialKey_keyTooLarge_returnsLastPage() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, ) - // refresh with initial key = 40 - runBlocking { - val result = pagingSource.refresh(key = 40) as LoadResult.Page + insertItems(ITEMS_LIST) + val result = pagingSource.refresh(key = 101) as LoadResult.Page - // initial loadSize = 15, but limited by id < 50, should only load items 40 - 50 - assertThat(result.data).containsExactlyElementsIn( - ITEMS_LIST.subList(40, 50) - ) - // should have 50 items fulfilling condition of id < 50 (TestItem id 0 - 49) - assertThat(pagingSource.itemCount.get()).isEqualTo(50) - } + // should load the last page + assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(85, 100)) } @Test - fun load_multipleQueryArguments() { - dao.addAllItems(ITEMS_LIST) - val pagingSource = LimitOffsetPagingSourceImpl( - db = database, - queryString = "SELECT * " + - "FROM $tableName " + - "WHERE id > 50 AND value LIKE 'item 90'" + - "ORDER BY id ASC", + fun invalidInitialKey_negativeKey() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, ) - runBlocking { - val result = pagingSource.refresh() as LoadResult.Page - - assertThat(result.data).containsExactly(ITEMS_LIST[90]) - assertThat(pagingSource.itemCount.get()).isEqualTo(1) + 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 load_InvalidUserSuppliedOffset_returnEmpty() { - dao.addAllItems(ITEMS_LIST) - val pagingSource = LimitOffsetPagingSourceImpl( - db = database, - queryString = "SELECT * FROM $tableName ORDER BY id ASC LIMIT 10 OFFSET 500", + fun append_middleOfList() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, ) - runBlocking { - val result = pagingSource.refresh() as LoadResult.Page - - // invalid OFFSET = 500 should return empty data - assertThat(result.data).isEmpty() - - // check that no append/prepend can be triggered - assertThat(pagingSource.itemCount.get()).isEqualTo(0) - assertThat(result.nextKey).isNull() - assertThat(result.prevKey).isNull() - assertThat(result.itemsBefore).isEqualTo(0) - assertThat(result.itemsAfter).isEqualTo(0) - } + 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 load_UserSuppliedNegativeLimit() { - dao.addAllItems(ITEMS_LIST) - val pagingSource = LimitOffsetPagingSourceImpl( - db = database, - queryString = "SELECT * FROM $tableName ORDER BY id ASC LIMIT -1", + fun append_availableItemsLessThanLoadSize() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, ) - runBlocking { - val result = pagingSource.refresh() as LoadResult.Page + insertItems(ITEMS_LIST) + val result = pagingSource.append(key = 97) as LoadResult.Page - // ensure that it respects SQLite's default behavior for negative LIMIT - assertThat(result.data).containsExactlyElementsIn( - ITEMS_LIST.subList(0, 15) - ) - // should behave as if no LIMIT were set - assertThat(pagingSource.itemCount.get()).isEqualTo(100) - assertThat(result.nextKey).isEqualTo(15) - assertThat(result.prevKey).isNull() - assertThat(result.itemsBefore).isEqualTo(0) - assertThat(result.itemsAfter).isEqualTo(85) - } + // 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 invalidInitialKey_dbEmpty_returnsEmpty() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - runBlocking { - val result = pagingSource.refresh(key = 101) as LoadResult.Page + fun load_consecutiveAppend() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) + insertItems(ITEMS_LIST) + // first append + val result = pagingSource.append(key = 30) as LoadResult.Page - assertThat(result.data).isEmpty() - } - } + // 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 - @Test - fun invalidInitialKey_keyTooLarge_returnsLastPage() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - dao.addAllItems(ITEMS_LIST) - runBlocking { - val result = pagingSource.refresh(key = 101) as LoadResult.Page - - // should load the last page - assertThat(result.data).containsExactlyElementsIn( - ITEMS_LIST.subList(85, 100) - ) - } + // TestItemId 35 - 39 loaded + assertThat(result2.data).containsExactlyElementsIn(ITEMS_LIST.subList(35, 40)) } @Test - fun invalidInitialKey_negativeKey() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - dao.addAllItems(ITEMS_LIST) - runBlocking { - // 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" - ) - } - } + fun append_invalidResult() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) + insertItems(ITEMS_LIST) + // first append + val result = pagingSource.append(key = 30) as LoadResult.Page - @Test - fun append_middleOfList() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - dao.addAllItems(ITEMS_LIST) - // to bypass check for initial load and run as non-initial load - pagingSource.itemCount.set(100) - runBlocking { - 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) - } - } + // TestItemId 30-34 loaded + assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(30, 35)) - @Test - fun append_availableItemsLessThanLoadSize() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - dao.addAllItems(ITEMS_LIST) - // to bypass check for initial load and run as non-initial load - pagingSource.itemCount.set(100) - runBlocking { - 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).isEqualTo(null) - assertThat(result.prevKey).isEqualTo(97) - } - } + // invalidate pagingSource to imitate invalidation from running refreshVersionSync + pagingSource.invalidate() - @Test - fun load_consecutiveAppend() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - dao.addAllItems(ITEMS_LIST) - // to bypass check for initial load and run as non-initial load - pagingSource.itemCount.set(100) - runBlocking { - // 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 + // this append should check pagingSource's invalid status, realize it is invalid, and + // return a LoadResult.Invalid + val result2 = pagingSource.append(key = result.nextKey) - // TestItemId 35 - 39 loaded - assertThat(result2.data).containsExactlyElementsIn( - ITEMS_LIST.subList(35, 40) - ) - } + assertThat(result2).isInstanceOf(LoadResult.Invalid::class.java) } @Test - fun append_invalidResult() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - dao.addAllItems(ITEMS_LIST) - // to bypass check for initial load and run as non-initial load - pagingSource.itemCount.set(100) - runBlocking { - // 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) + fun prepend_middleOfList() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) + insertItems(ITEMS_LIST) + val result = pagingSource.prepend(key = 30) as LoadResult.Page - assertThat(result2).isInstanceOf(LoadResult.Invalid::class.java) - } + assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(25, 30)) + assertThat(result.nextKey).isEqualTo(30) + assertThat(result.prevKey).isEqualTo(25) } @Test - fun prepend_middleOfList() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - dao.addAllItems(ITEMS_LIST) - // to bypass check for initial load and run as non-initial load - pagingSource.itemCount.set(100) - runBlocking { - 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) - } - } + fun prepend_availableItemsLessThanLoadSize() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) + insertItems(ITEMS_LIST) + val result = pagingSource.prepend(key = 3) as LoadResult.Page - @Test - fun prepend_availableItemsLessThanLoadSize() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - dao.addAllItems(ITEMS_LIST) - // to bypass check for initial load and run as non-initial load - pagingSource.itemCount.set(100) - runBlocking { - 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).isEqualTo(null) - } + // 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() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - dao.addAllItems(ITEMS_LIST) - // to bypass check for initial load and run as non-initial load - pagingSource.itemCount.set(100) - runBlocking { - // 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 + fun load_consecutivePrepend() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) + insertItems(ITEMS_LIST) + // first prepend + val result = pagingSource.prepend(key = 20) as LoadResult.Page - // items pos 11-15 (TestItemId 10 - 14) loaded - assertThat(result2.data).containsExactlyElementsIn( - ITEMS_LIST.subList(10, 15) - ) - } + // 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() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - dao.addAllItems(ITEMS_LIST) - // to bypass check for initial load and run as non-initial load - pagingSource.itemCount.set(100) - runBlocking { - // 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) - ) + fun prepend_invalidResult() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) + insertItems(ITEMS_LIST) + // first prepend + val result = pagingSource.prepend(key = 20) as LoadResult.Page - // invalidate pagingSource to imitate invalidation from running refreshVersionSync - pagingSource.invalidate() + // items pos 16-20 (TestItemId 15-19) loaded + assertThat(result.data).containsExactlyElementsIn(ITEMS_LIST.subList(15, 20)) - // this prepend should check pagingSource's invalid status, realize it is invalid, and - // return LoadResult.Invalid - val result2 = pagingSource.prepend(key = result.prevKey) + // invalidate pagingSource to imitate invalidation from running refreshVersionSync + pagingSource.invalidate() - assertThat(result2).isInstanceOf(LoadResult.Invalid::class.java) - } + // 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() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - dao.addAllItems(ITEMS_LIST) - runBlocking { - // for initial load - val result = pagingSource.refresh(key = 50) as LoadResult.Page + fun test_itemsBefore() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) + 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) + // 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 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) + // 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 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) - } + // append loads items in position 66 - 70 , should have 65 item before + assertThat(result3.itemsBefore).isEqualTo(65) } @Test - fun test_itemsAfter() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - dao.addAllItems(ITEMS_LIST) - runBlocking { - // for initial load - val result = pagingSource.refresh(key = 30) as LoadResult.Page + fun test_itemsAfter() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) + 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) + // 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 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) + // 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 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) - } + // append loads items in position 46 - 50 , should have 50 item after + assertThat(result3.itemsAfter).isEqualTo(50) } @Test - fun test_getRefreshKey() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - dao.addAllItems(ITEMS_LIST) - runBlocking { - // 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) + fun test_getRefreshKey() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) + 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 ) - 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 - ) + ) + // 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) - } + ) + // 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() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - dao.addAllItems(ITEMS_LIST) - runBlocking { - - pagingSource.refresh(key = 70) - - dao.deleteTestItems(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 = LimitOffsetPagingSourceImpl(database) - 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) - assertThat(pagingSource2.itemCount.get()).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) - ) + fun load_refreshKeyGreaterThanItemCount_lastPage() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) + insertItems(ITEMS_LIST) + pagingSource.refresh(key = 70) - // 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).isEqualTo(null) - } + 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 = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) + 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() } /** @@ -703,248 +518,163 @@ class LimitOffsetPagingSourceTest { * Ideally, in the future Paging will be able to handle this case better. */ @Test - fun load_refreshKeyGreaterThanItemCount_firstPage() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - dao.addAllItems(ITEMS_LIST) - runBlocking { - pagingSource.refresh() - - assertThat(pagingSource.itemCount.get()).isEqualTo(100) - - // items id 0 - 29 deleted (30 items removed) - dao.deleteTestItems(0, 29) - - val pagingSource2 = LimitOffsetPagingSourceImpl(database) - // assume user was viewing first few items with anchorPosition = 0 and refresh key - // clips to 0 - val refreshKey = 0 + fun load_refreshKeyGreaterThanItemCount_firstPage() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) + insertItems(ITEMS_LIST) + pagingSource.refresh() + + Pager(CONFIG, pagingSourceFactory = { pagingSource }) + .flow + .first() + .withPagingDataDiffer(this, testItemDiffCallback) { + assertThat(itemCount).isEqualTo(100) + } - val result2 = pagingSource2.refresh(key = refreshKey) as LoadResult.Page + // items id 0 - 29 deleted (30 items removed) + deleteItems(0..29) - // database should only have 70 items left - assertThat(pagingSource2.itemCount.get()).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) - ) + val pagingSource2 = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) + // 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).isEqualTo(null) - assertThat(result2.nextKey).isEqualTo(15) - } + // 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() { - val pagingSource = LimitOffsetPagingSourceImpl(database) - dao.addAllItems(ITEMS_LIST) - runBlocking { - - pagingSource.refresh(key = 30) - - assertThat(pagingSource.itemCount.get()).isEqualTo(100) - // items id 0 - 94 deleted (95 items removed) - dao.deleteTestItems(0, 94) - - val pagingSource2 = LimitOffsetPagingSourceImpl(database) - // 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 - assertThat(pagingSource2.itemCount.get()).isEqualTo(5) - // only 5 items should be loaded with offset = 0 - assertThat(result2.data).containsExactlyElementsIn( - ITEMS_LIST.subList(95, 100) - ) + fun load_loadSizeAndRefreshKeyGreaterThanItemCount() = runTest { + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) + 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 = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) + // 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).isEqualTo(null) - assertThat(result2.nextKey).isEqualTo(null) - } + // 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 = LimitOffsetPagingSourceImpl(database) + val pagingSource = OffsetQueryPagingSource( + ::query, + countQuery(), + transacter, + EmptyCoroutineContext, + ) assertTrue(pagingSource.jumpingSupported) } -} - -@RunWith(AndroidJUnit4::class) -@SmallTest -class LimitOffsetPagingSourceTestWithFilteringExecutor { - private lateinit var db: LimitOffsetTestDb - private lateinit var dao: TestItemDao - - // Multiple threads are necessary to prevent deadlock, since Room will acquire a thread to - // dispatch on, when using the query / transaction dispatchers. - private val queryExecutor = FilteringExecutor(delegate = Executors.newFixedThreadPool(2)) - private val mainThreadQueries = mutableListOf>() - - @Before - fun init() { - val mainThread: Thread = runBlocking(Dispatchers.Main) { - Thread.currentThread() + 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()) } - db = Room.inMemoryDatabaseBuilder( - ApplicationProvider.getApplicationContext(), - LimitOffsetTestDb::class.java - ).setQueryCallback( - object : RoomDatabase.QueryCallback { - override fun onQuery(sqlQuery: String, bindArgs: List) { - if (Thread.currentThread() === mainThread) { - mainThreadQueries.add( - sqlQuery to Throwable().stackTraceToString() - ) - } - } - } - ) { - // instantly execute the log callback so that we can check the thread. - it.run() - }.setQueryExecutor(queryExecutor) - .build() - dao = db.dao - } - @After - fun tearDown() { - // Check no mainThread queries happened. - assertThat(mainThreadQueries).isEmpty() - db.close() + override fun addListener(listener: Listener) = driver.addListener(listener, arrayOf("TestItem")) + override fun removeListener(listener: Listener) = driver.removeListener(listener, arrayOf("TestItem")) } - @Test - fun invalid_refresh() { - val pagingSource = LimitOffsetPagingSourceImpl(db) - runBlocking { - val result = pagingSource.refresh() as LoadResult.Page - - assertTrue(result.data.isEmpty()) + private fun countQuery() = Query( + 2, + arrayOf("TestItem"), + driver, + "Test.sq", + "count", + "SELECT count(*) FROM TestItem", + { it.getLong(0)!!.toInt() }, + ) - // blocks invalidation notification from Room - queryExecutor.filterFunction = { runnable -> - runnable !== db.invalidationTracker.refreshRunnable + private fun insertItems(items: List) { + items.forEach { + driver.execute(0, "INSERT INTO TestItem (id) VALUES (?)", 1) { + bindLong(0, it.id) } - - // now write to database - dao.addAllItems(ITEMS_LIST) - - // make sure room requests a refresh - db.invalidationTracker.awaitPendingRefresh() - // and that this is blocked to simulate delayed notification from room - queryExecutor.awaitDeferredSizeAtLeast(1) - - // the db write should cause pagingSource to realize it is invalid - assertThat(pagingSource.refresh()).isInstanceOf( - LoadResult.Invalid::class.java - ) - assertTrue(pagingSource.invalid) } } - @Test - fun invalid_append() { - val pagingSource = LimitOffsetPagingSourceImpl(db) - dao.addAllItems(ITEMS_LIST) - - runBlocking { - val result = pagingSource.refresh() as LoadResult.Page - - // initial load - assertThat(result.data).containsExactlyElementsIn( - ITEMS_LIST.subList(0, 15) - ) - - // blocks invalidation notification from Room - queryExecutor.filterFunction = { runnable -> - runnable !== db.invalidationTracker.refreshRunnable + private fun deleteItem(item: TestItem): Long = + driver + .execute(0, "DELETE FROM TestItem WHERE id = ?;", 1) { + bindLong(0, item.id) } + .value - // now write to the database - dao.deleteTestItem(ITEMS_LIST[30]) - - // make sure room requests a refresh - db.invalidationTracker.awaitPendingRefresh() - // and that this is blocked to simulate delayed notification from room - queryExecutor.awaitDeferredSizeAtLeast(1) - - // the db write should cause pagingSource to realize it is invalid when it tries to - // append - assertThat(pagingSource.append(15)).isInstanceOf( - LoadResult.Invalid::class.java - ) - assertTrue(pagingSource.invalid) - } - } - - @Test - fun invalid_prepend() { - val pagingSource = LimitOffsetPagingSourceImpl(db) - dao.addAllItems(ITEMS_LIST) - - runBlocking { - val result = pagingSource.refresh(key = 20) as LoadResult.Page - - // initial load - assertThat(result.data).containsExactlyElementsIn( - ITEMS_LIST.subList(20, 35) - ) - - // blocks invalidation notification from Room - queryExecutor.filterFunction = { runnable -> - runnable !== db.invalidationTracker.refreshRunnable + 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()) } - - // now write to the database - dao.deleteTestItem(ITEMS_LIST[30]) - - // make sure room requests a refresh - db.invalidationTracker.awaitPendingRefresh() - // and that this is blocked to simulate delayed notification from room - queryExecutor.awaitDeferredSizeAtLeast(1) - - // the db write should cause pagingSource to realize it is invalid when it tries to - // append - assertThat(pagingSource.prepend(20)).isInstanceOf( - LoadResult.Invalid::class.java - ) - assertTrue(pagingSource.invalid) - } - } -} - -class LimitOffsetPagingSourceImpl( - db: RoomDatabase, - queryString: String = "SELECT * FROM $tableName ORDER BY id ASC", -) : LimitOffsetPagingSource( - sourceQuery = RoomSQLiteQuery.acquire( - queryString, - 0 - ), - db = db, - tables = arrayOf("$tableName") -) { - - override fun convertRows(cursor: Cursor): List { - val cursorIndexOfId = getColumnIndexOrThrow(cursor, "id") - val data = mutableListOf() - while (cursor.moveToNext()) { - val tmpId = cursor.getInt(cursorIndexOfId) - data.add(TestItem(tmpId)) - } - return data - } + .value } private val CONFIG = PagingConfig( @@ -953,77 +683,40 @@ private val CONFIG = PagingConfig( initialLoadSize = 15 ) -private val ITEMS_LIST = createItemsForDb(0, 100) - -private fun createLoadParam( - loadType: LoadType, - key: Int? = null, - initialLoadSize: Int = CONFIG.initialLoadSize, - pageSize: Int = CONFIG.pageSize, - placeholdersEnabled: Boolean = CONFIG.enablePlaceholders -): PagingSource.LoadParams { - return when (loadType) { - LoadType.REFRESH -> { - PagingSource.LoadParams.Refresh( - key = key, - loadSize = initialLoadSize, - placeholdersEnabled = placeholdersEnabled - ) - } - LoadType.APPEND -> { - PagingSource.LoadParams.Append( - key = key ?: -1, - loadSize = pageSize, - placeholdersEnabled = placeholdersEnabled - ) - } - LoadType.PREPEND -> { - PagingSource.LoadParams.Prepend( - key = key ?: -1, - loadSize = pageSize, - placeholdersEnabled = placeholdersEnabled - ) - } - } -} +private val ITEMS_LIST = List(100) { TestItem(id = it.toLong()) } -private fun createItemsForDb(startId: Int, count: Int): List { - return List(count) { - TestItem( - id = it + startId, - ) - } +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 } -private suspend fun PagingSource.refresh( - key: Int? = null, -): LoadResult { - return this.load( - createLoadParam( - loadType = LoadType.REFRESH, - key = key, - ) +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 ) -} -private suspend fun PagingSource.append( - key: Int? = -1, -): LoadResult { - return this.load( - createLoadParam( - loadType = LoadType.APPEND, - key = key, - ) + LoadType.APPEND -> PagingSource.LoadParams.Append( + key = key ?: -1, + loadSize = CONFIG.pageSize, + placeholdersEnabled = CONFIG.enablePlaceholders ) -} -private suspend fun PagingSource.prepend( - key: Int? = -1, -): LoadResult { - return this.load( - createLoadParam( - loadType = LoadType.PREPEND, - key = key, - ) + 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/src/test/java/app/cash/sqldelight/paging3/TestItem.kt b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/TestItem.kt deleted file mode 100644 index 0a804e2f5c6..00000000000 --- a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/TestItem.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2021 The Android Open Source Project - * - * 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.room.Entity -import androidx.room.PrimaryKey - -@Entity -data class TestItem( - @PrimaryKey val id: Int, - val value: String = "item $id" -) diff --git a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/TestItemDao.kt b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/TestItemDao.kt deleted file mode 100644 index a1f5c1f032f..00000000000 --- a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/TestItemDao.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2021 The Android Open Source Project - * - * 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.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.Query - -@Dao -interface TestItemDao { - @Insert - fun addAllItems(testItems: List) - - @Insert - fun addTestItem(testItem: TestItem): Long - - @Delete - fun deleteTestItem(testItem: TestItem): Int - - @Query("DELETE FROM TestItem WHERE id >= :start AND id <= :end") - fun deleteTestItems(start: Int, end: Int): Int -} diff --git a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/pagingData.kt b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/pagingData.kt new file mode 100644 index 00000000000..0265ecc0720 --- /dev/null +++ b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/pagingData.kt @@ -0,0 +1,39 @@ + +// Copyright Square, Inc. +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?) {} + override fun onMoved(fromPosition: Int, toPosition: Int) {} + override fun onInserted(position: Int, count: Int) {} + override fun onRemoved(position: Int, count: Int) {} +} + +@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/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" } From 26832375fdfabb7114e88197f38a8d4ddb0e1b2a Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Thu, 28 Jul 2022 15:58:12 +0100 Subject: [PATCH 15/19] Spotless --- .../paging3/OffsetQueryPagingSourceTest.kt | 16 ++++++++-------- ...{pagingData.kt => WithPagingDataDiffer.kt} | 19 ++++++++++++++++--- 2 files changed, 24 insertions(+), 11 deletions(-) rename extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/{pagingData.kt => WithPagingDataDiffer.kt} (64%) 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 index bbdb8e879dd..0e28406dd31 100644 --- 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 @@ -433,8 +433,8 @@ class OffsetQueryPagingSourceTest { pages = listOf(result), anchorPosition = 14, config = CONFIG, - leadingPlaceholderCount = 0 - ) + leadingPlaceholderCount = 0, + ), ) // should load around anchor position // Initial load size = 15, refresh key should be (15/2 = 7) items @@ -451,8 +451,8 @@ class OffsetQueryPagingSourceTest { // 20 items loaded, assume anchorPosition = 19 as the last item loaded anchorPosition = 19, config = CONFIG, - leadingPlaceholderCount = 0 - ) + leadingPlaceholderCount = 0, + ), ) // initial load size 15. Refresh key should be (15/2 = 7) items before anchorPosition // (19 - 7 = 12) @@ -680,7 +680,7 @@ class OffsetQueryPagingSourceTest { private val CONFIG = PagingConfig( pageSize = 5, enablePlaceholders = true, - initialLoadSize = 15 + initialLoadSize = 15, ) private val ITEMS_LIST = List(100) { TestItem(id = it.toLong()) } @@ -696,19 +696,19 @@ private fun createLoadParam(loadType: LoadType, key: Int?): PagingSource.LoadPar LoadType.REFRESH -> PagingSource.LoadParams.Refresh( key = key, loadSize = CONFIG.initialLoadSize, - placeholdersEnabled = CONFIG.enablePlaceholders + placeholdersEnabled = CONFIG.enablePlaceholders, ) LoadType.APPEND -> PagingSource.LoadParams.Append( key = key ?: -1, loadSize = CONFIG.pageSize, - placeholdersEnabled = CONFIG.enablePlaceholders + placeholdersEnabled = CONFIG.enablePlaceholders, ) LoadType.PREPEND -> PagingSource.LoadParams.Prepend( key = key ?: -1, loadSize = CONFIG.pageSize, - placeholdersEnabled = CONFIG.enablePlaceholders + placeholdersEnabled = CONFIG.enablePlaceholders, ) } diff --git a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/pagingData.kt b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt similarity index 64% rename from extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/pagingData.kt rename to extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt index 0265ecc0720..437a19b9fa2 100644 --- a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/pagingData.kt +++ b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt @@ -1,5 +1,18 @@ - -// Copyright Square, Inc. +/* + * 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 @@ -28,7 +41,7 @@ fun PagingData.withPagingDataDiffer( val pagingDataDiffer = AsyncPagingDataDiffer( diffCallback, NoopListCallback, - workerDispatcher = Dispatchers.Main + workerDispatcher = Dispatchers.Main, ) val job = testScope.launch { pagingDataDiffer.submitData(this@withPagingDataDiffer) From 9bc2b43dba4f7a73a35910eb1b0de943b0920625 Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Thu, 28 Jul 2022 18:11:18 +0200 Subject: [PATCH 16/19] Update extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt Co-authored-by: Niklas Baudy --- .../java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt index 437a19b9fa2..e4d59c4b9fc 100644 --- a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt +++ b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt @@ -26,7 +26,7 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle private object NoopListCallback : ListUpdateCallback { - override fun onChanged(position: Int, count: Int, payload: Any?) {} + override fun onChanged(position: Int, count: Int, payload: Any?) = Unit override fun onMoved(fromPosition: Int, toPosition: Int) {} override fun onInserted(position: Int, count: Int) {} override fun onRemoved(position: Int, count: Int) {} From 03744d053d8946109320dfad0686ac50f77c0c2b Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Thu, 28 Jul 2022 18:11:23 +0200 Subject: [PATCH 17/19] Update extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt Co-authored-by: Niklas Baudy --- .../java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt index e4d59c4b9fc..9d442f81ac4 100644 --- a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt +++ b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt @@ -27,7 +27,7 @@ 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) {} + override fun onMoved(fromPosition: Int, toPosition: Int) = Unit override fun onInserted(position: Int, count: Int) {} override fun onRemoved(position: Int, count: Int) {} } From f3ce22b26509d1a513e21def239042044d921ae1 Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Thu, 28 Jul 2022 18:11:39 +0200 Subject: [PATCH 18/19] Apply suggestions from code review Co-authored-by: Niklas Baudy --- .../java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt index 9d442f81ac4..53c5cc5bd83 100644 --- a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt +++ b/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt @@ -28,8 +28,8 @@ 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) {} - override fun onRemoved(position: Int, count: Int) {} + override fun onInserted(position: Int, count: Int) = Unit + override fun onRemoved(position: Int, count: Int) = Unit } @ExperimentalCoroutinesApi From 71501d207a642b4d8f84882d10431a120cb6cc7f Mon Sep 17 00:00:00 2001 From: Veyndan Stuart Date: Thu, 28 Jul 2022 17:26:09 +0100 Subject: [PATCH 19/19] Make the artifact a JVM one again --- .../android-paging3/android-test/build.gradle | 26 +++++ .../paging3/OffsetQueryPagingSourceTest.kt | 104 +++++++++--------- .../paging3/WithPagingDataDiffer.kt | 0 extensions/android-paging3/build.gradle | 10 +- settings.gradle | 1 + 5 files changed, 80 insertions(+), 61 deletions(-) create mode 100644 extensions/android-paging3/android-test/build.gradle rename extensions/android-paging3/{ => android-test}/src/test/java/app/cash/sqldelight/paging3/OffsetQueryPagingSourceTest.kt (94%) rename extensions/android-paging3/{ => android-test}/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt (100%) 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/src/test/java/app/cash/sqldelight/paging3/OffsetQueryPagingSourceTest.kt b/extensions/android-paging3/android-test/src/test/java/app/cash/sqldelight/paging3/OffsetQueryPagingSourceTest.kt similarity index 94% rename from extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/OffsetQueryPagingSourceTest.kt rename to extensions/android-paging3/android-test/src/test/java/app/cash/sqldelight/paging3/OffsetQueryPagingSourceTest.kt index 0e28406dd31..f72725eb3a5 100644 --- a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/OffsetQueryPagingSourceTest.kt +++ b/extensions/android-paging3/android-test/src/test/java/app/cash/sqldelight/paging3/OffsetQueryPagingSourceTest.kt @@ -66,11 +66,11 @@ class OffsetQueryPagingSourceTest { fun test_itemCount() = runTest { insertItems(ITEMS_LIST) - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) pagingSource.refresh() @@ -85,11 +85,11 @@ class OffsetQueryPagingSourceTest { @Test fun invalidDbQuery_pagingSourceDoesNotInvalidate() = runTest { insertItems(ITEMS_LIST) - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) // load once to register db observers pagingSource.refresh() @@ -104,11 +104,11 @@ class OffsetQueryPagingSourceTest { @Test fun load_initialLoad() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) insertItems(ITEMS_LIST) val result = pagingSource.refresh() as LoadResult.Page @@ -118,11 +118,11 @@ class OffsetQueryPagingSourceTest { @Test fun load_initialEmptyLoad() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) val result = pagingSource.refresh() as LoadResult.Page @@ -143,11 +143,11 @@ class OffsetQueryPagingSourceTest { @Test fun load_initialLoadWithInitialKey() = runTest { insertItems(ITEMS_LIST) - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) // refresh with initial key = 20 val result = pagingSource.refresh(key = 20) as LoadResult.Page @@ -158,11 +158,11 @@ class OffsetQueryPagingSourceTest { @Test fun invalidInitialKey_dbEmpty_returnsEmpty() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) val result = pagingSource.refresh(key = 101) as LoadResult.Page @@ -171,11 +171,11 @@ class OffsetQueryPagingSourceTest { @Test fun invalidInitialKey_keyTooLarge_returnsLastPage() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) insertItems(ITEMS_LIST) val result = pagingSource.refresh(key = 101) as LoadResult.Page @@ -186,11 +186,11 @@ class OffsetQueryPagingSourceTest { @Test fun invalidInitialKey_negativeKey() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) insertItems(ITEMS_LIST) // should throw error when initial key is negative @@ -203,11 +203,11 @@ class OffsetQueryPagingSourceTest { @Test fun append_middleOfList() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) insertItems(ITEMS_LIST) val result = pagingSource.append(key = 20) as LoadResult.Page @@ -220,11 +220,11 @@ class OffsetQueryPagingSourceTest { @Test fun append_availableItemsLessThanLoadSize() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) insertItems(ITEMS_LIST) val result = pagingSource.append(key = 97) as LoadResult.Page @@ -237,11 +237,11 @@ class OffsetQueryPagingSourceTest { @Test fun load_consecutiveAppend() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) insertItems(ITEMS_LIST) // first append @@ -258,11 +258,11 @@ class OffsetQueryPagingSourceTest { @Test fun append_invalidResult() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) insertItems(ITEMS_LIST) // first append @@ -283,11 +283,11 @@ class OffsetQueryPagingSourceTest { @Test fun prepend_middleOfList() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) insertItems(ITEMS_LIST) val result = pagingSource.prepend(key = 30) as LoadResult.Page @@ -299,11 +299,11 @@ class OffsetQueryPagingSourceTest { @Test fun prepend_availableItemsLessThanLoadSize() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) insertItems(ITEMS_LIST) val result = pagingSource.prepend(key = 3) as LoadResult.Page @@ -316,11 +316,11 @@ class OffsetQueryPagingSourceTest { @Test fun load_consecutivePrepend() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) insertItems(ITEMS_LIST) // first prepend @@ -337,11 +337,11 @@ class OffsetQueryPagingSourceTest { @Test fun prepend_invalidResult() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) insertItems(ITEMS_LIST) // first prepend @@ -362,11 +362,11 @@ class OffsetQueryPagingSourceTest { @Test fun test_itemsBefore() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) insertItems(ITEMS_LIST) // for initial load @@ -390,11 +390,11 @@ class OffsetQueryPagingSourceTest { @Test fun test_itemsAfter() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) insertItems(ITEMS_LIST) // for initial load @@ -418,11 +418,11 @@ class OffsetQueryPagingSourceTest { @Test fun test_getRefreshKey() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) insertItems(ITEMS_LIST) // initial load @@ -461,11 +461,11 @@ class OffsetQueryPagingSourceTest { @Test fun load_refreshKeyGreaterThanItemCount_lastPage() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) insertItems(ITEMS_LIST) pagingSource.refresh(key = 70) @@ -477,11 +477,11 @@ class OffsetQueryPagingSourceTest { val refreshKey = 85 - (15 / 2) assertThat(refreshKey).isEqualTo(78) - val pagingSource2 = OffsetQueryPagingSource( - ::query, + val pagingSource2 = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) val result2 = pagingSource2.refresh(key = refreshKey) as LoadResult.Page @@ -519,11 +519,11 @@ class OffsetQueryPagingSourceTest { */ @Test fun load_refreshKeyGreaterThanItemCount_firstPage() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) insertItems(ITEMS_LIST) pagingSource.refresh() @@ -538,11 +538,11 @@ class OffsetQueryPagingSourceTest { // items id 0 - 29 deleted (30 items removed) deleteItems(0..29) - val pagingSource2 = OffsetQueryPagingSource( - ::query, + val pagingSource2 = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) // assume user was viewing first few items with anchorPosition = 0 and refresh key // clips to 0 @@ -571,11 +571,11 @@ class OffsetQueryPagingSourceTest { @Test fun load_loadSizeAndRefreshKeyGreaterThanItemCount() = runTest { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) insertItems(ITEMS_LIST) pagingSource.refresh(key = 30) @@ -589,11 +589,11 @@ class OffsetQueryPagingSourceTest { // items id 0 - 94 deleted (95 items removed) deleteItems(0..94) - val pagingSource2 = OffsetQueryPagingSource( - ::query, + val pagingSource2 = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) // assume user was viewing first few items with anchorPosition = 0 and refresh key // clips to 0 @@ -620,11 +620,11 @@ class OffsetQueryPagingSourceTest { @Test fun test_jumpSupport() { - val pagingSource = OffsetQueryPagingSource( - ::query, + val pagingSource = QueryPagingSource( countQuery(), transacter, EmptyCoroutineContext, + ::query, ) assertTrue(pagingSource.jumpingSupported) } diff --git a/extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt b/extensions/android-paging3/android-test/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt similarity index 100% rename from extensions/android-paging3/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt rename to extensions/android-paging3/android-test/src/test/java/app/cash/sqldelight/paging3/WithPagingDataDiffer.kt diff --git a/extensions/android-paging3/build.gradle b/extensions/android-paging3/build.gradle index a66d6eedf7f..f3092dd1957 100644 --- a/extensions/android-paging3/build.gradle +++ b/extensions/android-paging3/build.gradle @@ -1,15 +1,9 @@ plugins { - alias(deps.plugins.android.library) - alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.jvm) alias(deps.plugins.publish) alias(deps.plugins.dokka) } -android { - namespace 'app.cash.sqldelight.paging3' - compileSdkVersion 23 -} - archivesBaseName = 'sqldelight-androidx-paging3' dependencies { @@ -18,8 +12,6 @@ dependencies { api deps.androidx.paging3.common testImplementation project(':drivers:sqlite-driver') - testImplementation deps.androidx.paging3.runtime - testImplementation deps.androidx.recyclerView testImplementation deps.truth testImplementation deps.kotlin.test.junit testImplementation deps.kotlin.coroutines.test 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'