RoomPagingUtil.kt

/*
 * 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))
    }
}