| /* |
| * Copyright 2022 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. |
| */ |
| @file:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| |
| package androidx.room.paging.util |
| |
| import android.database.Cursor |
| import android.os.CancellationSignal |
| import androidx.annotation.RestrictTo |
| 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<Any, Any>() |
| |
| /** |
| * 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<Int>, 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<Int>, 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<Value> |
| */ |
| fun <Value : Any> queryDatabase( |
| params: LoadParams<Int>, |
| sourceQuery: RoomSQLiteQuery, |
| db: RoomDatabase, |
| itemCount: Int, |
| cancellationSignal: CancellationSignal? = null, |
| convertRows: (Cursor) -> List<Value>, |
| ): LoadResult<Int, Value> { |
| 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<Value> |
| 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 <Value : Any> PagingState<Int, Value>.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)) |
| } |
| } |