Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implements the QueryCachingStrategy #69

Merged
merged 2 commits into from
Aug 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions soil-query-compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ kotlin {
commonMain.dependencies {
api(projects.soilQueryCore)
implementation(compose.runtime)
implementation(compose.runtimeSaveable)
}

commonTest.dependencies {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@
package soil.query.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import soil.query.InfiniteQueryKey
Expand All @@ -20,7 +17,6 @@ import soil.query.core.isNone
import soil.query.core.map
import soil.query.invalidate
import soil.query.loadMore
import soil.query.resume

/**
* Remember a [InfiniteQueryObject] and subscribes to the query state of [key].
Expand All @@ -34,17 +30,12 @@ import soil.query.resume
@Composable
fun <T, S> rememberInfiniteQuery(
key: InfiniteQueryKey<T, S>,
strategy: QueryCachingStrategy = QueryCachingStrategy,
client: QueryClient = LocalQueryClient.current
): InfiniteQueryObject<QueryChunks<T, S>, S> {
val scope = rememberCoroutineScope()
val query = remember(key) { client.getInfiniteQuery(key).also { it.launchIn(scope) } }
val state by query.state.collectAsState()
LaunchedEffect(query) {
query.resume()
}
return remember(query, state) {
state.toInfiniteObject(query = query, select = { it })
}
return strategy.collectAsState(query).toInfiniteObject(query = query, select = { it })
}

/**
Expand All @@ -61,17 +52,12 @@ fun <T, S> rememberInfiniteQuery(
fun <T, S, U> rememberInfiniteQuery(
key: InfiniteQueryKey<T, S>,
select: (chunks: QueryChunks<T, S>) -> U,
strategy: QueryCachingStrategy = QueryCachingStrategy,
client: QueryClient = LocalQueryClient.current
): InfiniteQueryObject<U, S> {
val scope = rememberCoroutineScope()
val query = remember(key) { client.getInfiniteQuery(key).also { it.launchIn(scope) } }
val state by query.state.collectAsState()
LaunchedEffect(query) {
query.resume()
}
return remember(query, state) {
state.toInfiniteObject(query = query, select = select)
}
return strategy.collectAsState(query).toInfiniteObject(query = query, select = select)
}

private fun <T, S, U> QueryState<QueryChunks<T, S>>.toInfiniteObject(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,7 @@ fun <T, S> rememberMutation(
val scope = rememberCoroutineScope()
val mutation = remember(key) { client.getMutation(key).also { it.launchIn(scope) } }
val state by mutation.state.collectAsState()
return remember(mutation, state) {
state.toObject(mutation = mutation)
}
return state.toObject(mutation = mutation)
}

private fun <T, S> MutationState<T>.toObject(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright 2024 Soil Contributors
// SPDX-License-Identifier: Apache-2.0

package soil.query.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import kotlinx.coroutines.flow.StateFlow
import soil.query.InfiniteQueryRef
import soil.query.QueryChunks
import soil.query.QueryRef
import soil.query.QueryState
import soil.query.annotation.ExperimentalSoilQueryApi
import soil.query.core.UniqueId
import soil.query.core.isNone
import soil.query.resume

/**
* A mechanism to finely adjust the behavior of the query results on a component basis in Composable functions.
*
* In addition to the default behavior provided by Stale-While-Revalidate, two experimental strategies are now available:
*
* 1. Cache-First:
* This strategy avoids requesting data re-fetch as long as valid cached data is available.
* It prioritizes using the cached data over network requests.
*
* 2. Network-First:
* This strategy maintains the initial loading state until data is re-fetched, regardless of the presence of valid cached data.
* This ensures that the most up-to-date data is always displayed.
*
* If you want to customize further, please create a class implementing [QueryCachingStrategy].
* However, as this is an experimental API, the interface may change significantly in future versions.
*
* In future updates, we plan to provide additional options for more granular control over the behavior at the component level.
*
* Background:
* During in-app development, there are scenarios where returning cached data first can lead to issues.
* For example, if the externally updated data state is not accurately reflected on the screen, inconsistencies can occur.
* This is particularly problematic in processes that automatically redirect to other screens based on the data state.
*
* On the other hand, there are situations where data re-fetching should be suppressed to minimize data traffic.
* In such cases, setting a long staleTime in QueryOptions is not sufficient, as specific conditions for reducing data traffic may persist.
*/
@Stable
interface QueryCachingStrategy {
@Composable
fun <T> collectAsState(query: QueryRef<T>): QueryState<T>

@Composable
fun <T, S> collectAsState(query: InfiniteQueryRef<T, S>): QueryState<QueryChunks<T, S>>

companion object Default : QueryCachingStrategy by StaleWhileRevalidate {

@Suppress("FunctionName")
@ExperimentalSoilQueryApi
fun CacheFirst(): QueryCachingStrategy = CacheFirst

@Suppress("FunctionName")
@ExperimentalSoilQueryApi
fun NetworkFirst(): QueryCachingStrategy = NetworkFirst
}
}

@Stable
private object StaleWhileRevalidate : QueryCachingStrategy {
@Composable
override fun <T> collectAsState(query: QueryRef<T>): QueryState<T> {
return collectAsState(key = query.key.id, flow = query.state, resume = query::resume)
}

@Composable
override fun <T, S> collectAsState(query: InfiniteQueryRef<T, S>): QueryState<QueryChunks<T, S>> {
return collectAsState(key = query.key.id, flow = query.state, resume = query::resume)
}

@Composable
private inline fun <T> collectAsState(
key: UniqueId,
flow: StateFlow<QueryState<T>>,
crossinline resume: suspend () -> Unit
): QueryState<T> {
val state by flow.collectAsState()
LaunchedEffect(key) {
resume()
}
return state
}
}


@Stable
private object CacheFirst : QueryCachingStrategy {
@Composable
override fun <T> collectAsState(query: QueryRef<T>): QueryState<T> {
return collectAsState(query.key.id, query.state, query::resume)
}

@Composable
override fun <T, S> collectAsState(query: InfiniteQueryRef<T, S>): QueryState<QueryChunks<T, S>> {
return collectAsState(query.key.id, query.state, query::resume)
}

@Composable
private inline fun <T> collectAsState(
key: UniqueId,
flow: StateFlow<QueryState<T>>,
crossinline resume: suspend () -> Unit
): QueryState<T> {
val state by flow.collectAsState()
LaunchedEffect(key) {
val currentValue = flow.value
if (currentValue.reply.isNone || currentValue.isInvalidated) {
resume()
}
}
return state
}
}

@Stable
private object NetworkFirst : QueryCachingStrategy {
@Composable
override fun <T> collectAsState(query: QueryRef<T>): QueryState<T> {
return collectAsState(query.key.id, query.state, query::resume)
}

@Composable
override fun <T, S> collectAsState(query: InfiniteQueryRef<T, S>): QueryState<QueryChunks<T, S>> {
return collectAsState(query.key.id, query.state, query::resume)
}

@Composable
private inline fun <T> collectAsState(
key: UniqueId,
flow: StateFlow<QueryState<T>>,
crossinline resume: suspend () -> Unit
): QueryState<T> {
var resumed by rememberSaveable(key) { mutableStateOf(false) }
val initialValue = if (resumed) flow.value else QueryState.initial()
val state = produceState(initialValue, key) {
resume()
resumed = true
flow.collect { value = it }
}
return state.value
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@
package soil.query.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import soil.query.QueryClient
Expand All @@ -17,7 +14,6 @@ import soil.query.QueryStatus
import soil.query.core.isNone
import soil.query.core.map
import soil.query.invalidate
import soil.query.resume

/**
* Remember a [QueryObject] and subscribes to the query state of [key].
Expand All @@ -30,17 +26,12 @@ import soil.query.resume
@Composable
fun <T> rememberQuery(
key: QueryKey<T>,
strategy: QueryCachingStrategy = QueryCachingStrategy,
client: QueryClient = LocalQueryClient.current
): QueryObject<T> {
val scope = rememberCoroutineScope()
val query = remember(key) { client.getQuery(key).also { it.launchIn(scope) } }
val state by query.state.collectAsState()
LaunchedEffect(query) {
query.resume()
}
return remember(query, state) {
state.toObject(query = query, select = { it })
}
return strategy.collectAsState(query).toObject(query = query, select = { it })
}

/**
Expand All @@ -57,17 +48,12 @@ fun <T> rememberQuery(
fun <T, U> rememberQuery(
key: QueryKey<T>,
select: (T) -> U,
strategy: QueryCachingStrategy = QueryCachingStrategy,
client: QueryClient = LocalQueryClient.current
): QueryObject<U> {
val scope = rememberCoroutineScope()
val query = remember(key) { client.getQuery(key).also { it.launchIn(scope) } }
val state by query.state.collectAsState()
LaunchedEffect(query) {
query.resume()
}
return remember(query, state) {
state.toObject(query = query, select = select)
}
return strategy.collectAsState(query).toObject(query = query, select = select)
}

private fun <T, U> QueryState<T>.toObject(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@

package soil.query

import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.completeWith
import kotlinx.coroutines.flow.StateFlow
import soil.query.core.Actor
import soil.query.core.awaitOrNull

/**
* A reference to an Query for [InfiniteQueryKey].
Expand All @@ -31,19 +34,25 @@ interface InfiniteQueryRef<T, S> : Actor {
* setting [QueryModel.isInvalidated] to `true` until revalidation is completed.
*/
suspend fun <T, S> InfiniteQueryRef<T, S>.invalidate() {
send(InfiniteQueryCommands.Invalidate(key, state.value.revision))
val deferred = CompletableDeferred<QueryChunks<T, S>>()
send(InfiniteQueryCommands.Invalidate(key, state.value.revision, deferred::completeWith))
deferred.awaitOrNull()
}

/**
* Resumes the Query.
*/
suspend fun <T, S> InfiniteQueryRef<T, S>.resume() {
send(InfiniteQueryCommands.Connect(key, state.value.revision))
val deferred = CompletableDeferred<QueryChunks<T, S>>()
send(InfiniteQueryCommands.Connect(key, state.value.revision, deferred::completeWith))
deferred.awaitOrNull()
}

/**
* Fetches data for the [InfiniteQueryKey] using the value of [param].
*/
suspend fun <T, S> InfiniteQueryRef<T, S>.loadMore(param: S) {
send(InfiniteQueryCommands.LoadMore(key, param))
val deferred = CompletableDeferred<QueryChunks<T, S>>()
send(InfiniteQueryCommands.LoadMore(key, param, deferred::completeWith))
deferred.awaitOrNull()
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ data class MutationState<T> internal constructor(
) : MutationModel<T> {
companion object {

/**
* Creates a new [MutationState] with the [MutationStatus.Idle] status.
*/
fun <T> initial(): MutationState<T> {
return MutationState()
}

/**
* Creates a new [MutationState] with the [MutationStatus.Success] status.
*
Expand Down
11 changes: 9 additions & 2 deletions soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@

package soil.query

import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.completeWith
import kotlinx.coroutines.flow.StateFlow
import soil.query.core.Actor
import soil.query.core.awaitOrNull

/**
* A reference to an Query for [QueryKey].
Expand All @@ -30,12 +33,16 @@ interface QueryRef<T> : Actor {
* setting [QueryModel.isInvalidated] to `true` until revalidation is completed.
*/
suspend fun <T> QueryRef<T>.invalidate() {
send(QueryCommands.Invalidate(key, state.value.revision))
val deferred = CompletableDeferred<T>()
send(QueryCommands.Invalidate(key, state.value.revision, deferred::completeWith))
deferred.awaitOrNull()
}

/**
* Resumes the Query.
*/
suspend fun <T> QueryRef<T>.resume() {
send(QueryCommands.Connect(key, state.value.revision))
val deferred = CompletableDeferred<T>()
send(QueryCommands.Connect(key, state.value.revision, deferred::completeWith))
deferred.awaitOrNull()
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ data class QueryState<T> internal constructor(

companion object {

/**
* Creates a new [QueryState] with the [QueryStatus.Pending] status.
*/
fun <T> initial(): QueryState<T> {
return QueryState()
}

/**
* Creates a new [QueryState] with the [QueryStatus.Success] status.
*
Expand Down
Loading