├── project.yaml ├── jitpack.yml ├── jvm-app ├── module.yaml └── src │ └── main.kt ├── compose-query ├── module.yaml ├── src │ ├── core │ │ ├── QueryOptions.kt │ │ ├── QueryCache.kt │ │ ├── Key.kt │ │ └── QueryClient.kt │ ├── utils │ │ └── KeyUtils.kt │ ├── QueryClientProvider.kt │ ├── query.kt │ └── mutation.kt └── test │ ├── QueryTest.kt │ ├── MutationTest.kt │ ├── KeyTest.kt │ ├── CacheEntryTest.kt │ └── QueryClientTest.kt ├── shared ├── src │ ├── theme │ │ ├── Color.kt │ │ ├── Shape.kt │ │ ├── Type.kt │ │ └── Theme.kt │ ├── screens │ │ ├── Examples.kt │ │ ├── QueryExample.kt │ │ ├── MainScreen.kt │ │ ├── SimpleExample.kt │ │ ├── MutationExample.kt │ │ ├── BasicExample.kt │ │ └── PrefetchingExample.kt │ ├── common │ │ └── HttpClient.kt │ ├── Screen.kt │ └── App.kt └── module.yaml ├── android-app ├── module.yaml └── src │ ├── MainActivity.kt │ └── AndroidManifest.xml ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── .gitignore ├── README.md ├── amper.bat ├── amper └── docs └── query-dsl-api-design.md /project.yaml: -------------------------------------------------------------------------------- 1 | modules: 2 | - android-app 3 | - jvm-app 4 | - shared 5 | - compose-query 6 | -------------------------------------------------------------------------------- /jitpack.yml: -------------------------------------------------------------------------------- 1 | jdk: 2 | - openjdk17 3 | before_install: 4 | - sdk install java 17.0.1-open 5 | - sdk use java 17.0.1-open -------------------------------------------------------------------------------- /jvm-app/module.yaml: -------------------------------------------------------------------------------- 1 | product: jvm/app 2 | 3 | dependencies: 4 | - ../shared 5 | - $compose.desktop.currentOs 6 | 7 | 8 | settings: 9 | compose: enabled -------------------------------------------------------------------------------- /compose-query/module.yaml: -------------------------------------------------------------------------------- 1 | product: 2 | type: lib 3 | platforms: [jvm, android, iosArm64, iosSimulatorArm64, iosX64] 4 | 5 | dependencies: 6 | - $compose.foundation: exported 7 | 8 | settings: 9 | compose: 10 | enabled: true -------------------------------------------------------------------------------- /shared/src/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.demo.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple200 = Color(0xFFBB86FC) 6 | val Purple500 = Color(0xFF6200EE) 7 | val Purple700 = Color(0xFF3700B3) 8 | val Teal200 = Color(0xFF03DAC5) -------------------------------------------------------------------------------- /android-app/module.yaml: -------------------------------------------------------------------------------- 1 | product: android/app 2 | 3 | dependencies: 4 | - ../shared 5 | - androidx.activity:activity-compose:1.7.2 6 | 7 | settings: 8 | compose: enabled 9 | junit: junit-4 10 | android: 11 | namespace: "com.pavi2410.useCompose.demo" 12 | minSdk: 24 13 | -------------------------------------------------------------------------------- /jvm-app/src/main.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.ui.window.Window 2 | import androidx.compose.ui.window.application 3 | import com.pavi2410.useCompose.demo.App 4 | 5 | fun main() = application { 6 | Window( 7 | onCloseRequest = ::exitApplication, 8 | title = "useCompose Demo" 9 | ) { 10 | App() 11 | } 12 | } -------------------------------------------------------------------------------- /shared/src/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.demo.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = Shapes( 8 | small = RoundedCornerShape(4.dp), 9 | medium = RoundedCornerShape(4.dp), 10 | large = RoundedCornerShape(0.dp) 11 | ) -------------------------------------------------------------------------------- /android-app/src/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package hello.world 2 | 3 | import com.pavi2410.useCompose.demo.App 4 | import android.os.Bundle 5 | import androidx.activity.ComponentActivity 6 | import androidx.activity.compose.setContent 7 | 8 | class MainActivity : ComponentActivity() { 9 | override fun onCreate(savedInstanceState: Bundle?) { 10 | super.onCreate(savedInstanceState) 11 | setContent { 12 | App() 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /android-app/src/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /shared/src/screens/Examples.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.demo.screens 2 | 3 | import com.pavi2410.useCompose.demo.Screen 4 | 5 | data class ExampleScreen( 6 | val screen: Screen, 7 | val title: String, 8 | ) 9 | 10 | val exampleScreens = listOf( 11 | ExampleScreen(Screen.Query, "Query"), 12 | ExampleScreen(Screen.Mutation, "Mutation"), 13 | ExampleScreen(Screen.Simple, "Simple"), 14 | ExampleScreen(Screen.Basic, "Basic"), 15 | ExampleScreen(Screen.Prefetching, "Prefetching"), 16 | ) -------------------------------------------------------------------------------- /shared/module.yaml: -------------------------------------------------------------------------------- 1 | product: 2 | type: lib 3 | platforms: [jvm, android, iosArm64, iosSimulatorArm64, iosX64] 4 | 5 | dependencies: 6 | - ../compose-query 7 | 8 | - $compose.foundation: exported 9 | - $compose.material: exported 10 | - androidx.compose.material:material-icons-core:1.7.8 11 | 12 | - $ktor.client.core 13 | - $ktor.client.cio 14 | - $ktor.client.contentNegotiation 15 | - $ktor.serialization.kotlinx.json 16 | 17 | settings: 18 | compose: 19 | enabled: true 20 | ktor: enabled 21 | kotlin: 22 | serialization: json -------------------------------------------------------------------------------- /shared/src/common/HttpClient.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.demo.common 2 | 3 | import io.ktor.client.HttpClient 4 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 5 | import io.ktor.serialization.kotlinx.json.json 6 | import kotlinx.serialization.json.Json 7 | 8 | /** 9 | * Shared HTTP client configured with JSON serialization. 10 | * Used across all demo examples for consistent network configuration. 11 | */ 12 | val httpClient = HttpClient { 13 | install(ContentNegotiation) { 14 | json(Json { 15 | ignoreUnknownKeys = true 16 | }) 17 | } 18 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: set up JDK 17 16 | uses: actions/setup-java@v4 17 | with: 18 | java-version: '17' 19 | distribution: 'temurin' 20 | cache: gradle 21 | - name: Grant execute permission for gradlew 22 | run: chmod +x gradlew 23 | - name: Build with Gradle 24 | run: ./gradlew --console=plain --stacktrace build 25 | -------------------------------------------------------------------------------- /shared/src/Screen.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.demo 2 | 3 | sealed interface Screen { 4 | val title: String 5 | 6 | data object Home : Screen { 7 | override val title = "useCompose Demo" 8 | } 9 | 10 | data object Query : Screen { 11 | override val title = "Query Example" 12 | } 13 | 14 | data object Mutation : Screen { 15 | override val title = "Mutation Example" 16 | } 17 | 18 | data object Simple : Screen { 19 | override val title = "Simple Example" 20 | } 21 | 22 | data object Basic : Screen { 23 | override val title = "Basic Example" 24 | } 25 | 26 | data object Prefetching : Screen { 27 | override val title = "Prefetching Example" 28 | } 29 | } -------------------------------------------------------------------------------- /compose-query/src/core/QueryOptions.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.query.core 2 | 3 | /** 4 | * Simple configuration options for queries. 5 | */ 6 | data class QueryOptions( 7 | /** 8 | * Whether the query is enabled. 9 | * Disabled queries will not execute automatically. 10 | * Default: true 11 | */ 12 | val enabled: Boolean = true, 13 | /** 14 | * Time in milliseconds for how long data remains fresh. 15 | * If data is younger than staleTime, prefetch will be skipped. 16 | * Default: 0 (always stale) 17 | */ 18 | val staleTime: Long = 0, 19 | ) { 20 | companion object { 21 | /** 22 | * Default query options. 23 | */ 24 | val Default = QueryOptions() 25 | } 26 | } -------------------------------------------------------------------------------- /shared/src/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.demo.theme 2 | 3 | import androidx.compose.material.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | body1 = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp 15 | ) 16 | /* Other default text styles to override 17 | button = TextStyle( 18 | fontFamily = FontFamily.Default, 19 | fontWeight = FontWeight.W500, 20 | fontSize = 14.sp 21 | ), 22 | caption = TextStyle( 23 | fontFamily = FontFamily.Default, 24 | fontWeight = FontWeight.Normal, 25 | fontSize = 12.sp 26 | ) 27 | */ 28 | ) -------------------------------------------------------------------------------- /compose-query/src/utils/KeyUtils.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.query.utils 2 | 3 | import com.pavi2410.useCompose.query.core.Key 4 | import com.pavi2410.useCompose.query.core.KeyMatcher 5 | 6 | /** 7 | * Utility functions for working with query keys. 8 | */ 9 | 10 | /** 11 | * Create a matcher that matches all keys of the same type as the given key. 12 | */ 13 | fun Key.matchType(): KeyMatcher.ByType = KeyMatcher.ByType(this::class) 14 | 15 | /** 16 | * Create a matcher that matches keys using a predicate on the same type. 17 | */ 18 | inline fun matchWhere(noinline predicate: (T) -> Boolean): KeyMatcher.Predicate = 19 | KeyMatcher.Predicate { key -> 20 | key is T && predicate(key) 21 | } 22 | 23 | /** 24 | * Helper for matching all keys. 25 | */ 26 | fun matchAll(): KeyMatcher.Predicate = KeyMatcher.Predicate { true } 27 | 28 | /** 29 | * Helper for matching no keys. 30 | */ 31 | fun matchNone(): KeyMatcher.Predicate = KeyMatcher.Predicate { false } -------------------------------------------------------------------------------- /compose-query/src/QueryClientProvider.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.query 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.CompositionLocalProvider 5 | import androidx.compose.runtime.compositionLocalOf 6 | import com.pavi2410.useCompose.query.core.QueryClient 7 | 8 | /** 9 | * CompositionLocal for providing QueryClient throughout the Compose tree. 10 | */ 11 | val LocalQueryClient = compositionLocalOf { 12 | error("QueryClient not provided! Wrap your app with QueryClientProvider.") 13 | } 14 | 15 | /** 16 | * Provides a QueryClient to the Compose tree. 17 | */ 18 | @Composable 19 | fun QueryClientProvider( 20 | client: QueryClient, 21 | content: @Composable () -> Unit, 22 | ) { 23 | CompositionLocalProvider( 24 | LocalQueryClient provides client, 25 | content = content 26 | ) 27 | } 28 | 29 | /** 30 | * Gets the current QueryClient from the Compose context. 31 | */ 32 | @Composable 33 | fun useQueryClient(): QueryClient = LocalQueryClient.current -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Pavitra Golchha 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /compose-query/test/QueryTest.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.query 2 | 3 | import kotlin.test.* 4 | 5 | class QueryTest { 6 | 7 | @Test 8 | fun queryStateSealed_hasCorrectTypes() { 9 | // Test QueryState sealed interface structure 10 | val loading: DataState = DataState.Pending 11 | val error: DataState = DataState.Error("test") 12 | val content: DataState = DataState.Success("data") 13 | 14 | assertTrue(loading is DataState.Pending) 15 | assertTrue(error is DataState.Error) 16 | assertTrue(content is DataState.Success) 17 | assertEquals("data", content.data) 18 | } 19 | 20 | @Test 21 | fun queryStateError_preservesExceptionMessage() { 22 | val errorMessage = "Test error message" 23 | val errorState = DataState.Error(errorMessage) 24 | 25 | assertEquals(errorMessage, errorState.message) 26 | } 27 | 28 | @Test 29 | fun queryStateContent_preservesData() { 30 | val testData = mapOf("key" to "value", "count" to 42) 31 | val contentState = DataState.Success(testData) 32 | 33 | assertEquals(testData, contentState.data) 34 | assertEquals("value", contentState.data["key"]) 35 | assertEquals(42, contentState.data["count"]) 36 | } 37 | } -------------------------------------------------------------------------------- /shared/src/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.demo.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.darkColors 6 | import androidx.compose.material.lightColors 7 | import androidx.compose.runtime.Composable 8 | 9 | private val DarkColorPalette = darkColors( 10 | primary = Purple200, 11 | primaryVariant = Purple700, 12 | secondary = Teal200 13 | ) 14 | 15 | private val LightColorPalette = lightColors( 16 | primary = Purple500, 17 | primaryVariant = Purple700, 18 | secondary = Teal200 19 | 20 | /* Other default colors to override 21 | background = Color.White, 22 | surface = Color.White, 23 | onPrimary = Color.White, 24 | onSecondary = Color.Black, 25 | onBackground = Color.Black, 26 | onSurface = Color.Black, 27 | */ 28 | ) 29 | 30 | @Composable 31 | fun UseComposeTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) { 32 | val colors = if (darkTheme) { 33 | DarkColorPalette 34 | } else { 35 | LightColorPalette 36 | } 37 | 38 | MaterialTheme( 39 | colors = colors, 40 | typography = Typography, 41 | shapes = Shapes, 42 | content = content 43 | ) 44 | } -------------------------------------------------------------------------------- /compose-query/test/MutationTest.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.query 2 | 3 | import kotlin.test.* 4 | 5 | class MutationTest { 6 | 7 | @Test 8 | fun mutationStateSealed_hasCorrectTypes() { 9 | // Test MutationState sealed interface structure 10 | val idle: MutationState = MutationState.Idle 11 | val loading: MutationState = MutationState.Loading 12 | val error: MutationState = MutationState.Error("test") 13 | val success: MutationState = MutationState.Success("data") 14 | 15 | assertTrue(idle is MutationState.Idle) 16 | assertTrue(loading is MutationState.Loading) 17 | assertTrue(error is MutationState.Error) 18 | assertTrue(success is MutationState.Success) 19 | assertEquals("data", success.data) 20 | } 21 | 22 | @Test 23 | fun mutationStateError_preservesExceptionMessage() { 24 | val errorMessage = "Test mutation error" 25 | val errorState = MutationState.Error(errorMessage) 26 | 27 | assertEquals(errorMessage, errorState.message) 28 | } 29 | 30 | @Test 31 | fun mutationStateContent_preservesData() { 32 | val testResult = listOf("item1", "item2", "item3") 33 | val successState = MutationState.Success(testResult) 34 | 35 | assertEquals(testResult, successState.data) 36 | assertEquals(3, successState.data.size) 37 | assertEquals("item1", successState.data[0]) 38 | } 39 | } -------------------------------------------------------------------------------- /shared/src/screens/QueryExample.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.demo.screens 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.runtime.remember 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.unit.dp 11 | import com.pavi2410.useCompose.query.DataState 12 | import com.pavi2410.useCompose.query.core.Key 13 | import com.pavi2410.useCompose.query.useQuery 14 | import kotlinx.coroutines.delay 15 | 16 | data object TokenKey : Key 17 | 18 | @Composable 19 | fun QueryExample(modifier: Modifier = Modifier) { 20 | Column(modifier = modifier.padding(16.dp)) { 21 | val startTime = remember { System.currentTimeMillis() } 22 | 23 | val queryState by useQuery( 24 | key = TokenKey, 25 | queryFn = { 26 | delay(3000) 27 | "secret_token@${System.currentTimeMillis()}" 28 | } 29 | ) 30 | 31 | Text("Started at $startTime") 32 | 33 | when (val state = queryState.dataState) { 34 | is DataState.Pending -> { 35 | Text("Loading data...") 36 | } 37 | 38 | is DataState.Error -> { 39 | Text("Error: ${state.message}") 40 | } 41 | 42 | is DataState.Success<*> -> { 43 | Text("Success: ${state.data}") 44 | } 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /shared/src/screens/MainScreen.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.demo.screens 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.lazy.LazyColumn 9 | import androidx.compose.foundation.lazy.items 10 | import androidx.compose.material.Card 11 | import androidx.compose.material.ExperimentalMaterialApi 12 | import androidx.compose.material.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.unit.dp 16 | import com.pavi2410.useCompose.demo.Screen 17 | 18 | @OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) 19 | @Composable 20 | fun MainScreen( 21 | onNavigate: (Screen) -> Unit, 22 | modifier: Modifier = Modifier, 23 | ) { 24 | LazyColumn( 25 | modifier = modifier.padding(16.dp), 26 | verticalArrangement = Arrangement.spacedBy(8.dp), 27 | ) { 28 | items(exampleScreens) { screen -> 29 | Card( 30 | modifier = Modifier 31 | .fillMaxWidth(), 32 | onClick = { 33 | onNavigate(screen.screen) 34 | } 35 | ) { 36 | Text( 37 | text = screen.title, 38 | modifier = Modifier 39 | .fillMaxSize() 40 | .padding(16.dp) 41 | ) 42 | } 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /compose-query/test/KeyTest.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.query 2 | 3 | import com.pavi2410.useCompose.query.core.Key 4 | import com.pavi2410.useCompose.query.core.KeyMatcher 5 | import com.pavi2410.useCompose.query.core.keyMatching 6 | import com.pavi2410.useCompose.query.core.keyTypeOf 7 | import com.pavi2410.useCompose.query.core.matches 8 | import kotlin.test.Test 9 | import kotlin.test.assertEquals 10 | import kotlin.test.assertFalse 11 | import kotlin.test.assertNotEquals 12 | import kotlin.test.assertTrue 13 | 14 | // Test keys 15 | data class UserKey(val userId: Long) : Key 16 | data class PostKey(val postId: String) : Key 17 | 18 | class KeyTest { 19 | 20 | @Test 21 | fun keyEquality_worksCorrectly() { 22 | val key1 = UserKey(123L) 23 | val key2 = UserKey(123L) 24 | val key3 = UserKey(456L) 25 | 26 | assertEquals(key1, key2) 27 | assertNotEquals(key1, key3) 28 | } 29 | 30 | @Test 31 | fun keyMatching_exactMatch() { 32 | val key = UserKey(123L) 33 | val matcher = KeyMatcher.Exact(key) 34 | 35 | assertTrue(key.matches(matcher)) 36 | assertFalse(UserKey(456L).matches(matcher)) 37 | } 38 | 39 | @Test 40 | fun keyMatching_typeMatch() { 41 | val userKey = UserKey(123L) 42 | val postKey = PostKey("abc") 43 | val matcher = keyTypeOf() 44 | 45 | assertTrue(userKey.matches(matcher)) 46 | assertFalse(postKey.matches(matcher)) 47 | } 48 | 49 | @Test 50 | fun keyMatching_predicateMatch() { 51 | val key1 = UserKey(123L) 52 | val key2 = UserKey(456L) 53 | val matcher = keyMatching { key -> 54 | key is UserKey && key.userId > 200L 55 | } 56 | 57 | assertFalse(key1.matches(matcher)) 58 | assertTrue(key2.matches(matcher)) 59 | } 60 | } -------------------------------------------------------------------------------- /compose-query/src/query.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.query 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.State 5 | import androidx.compose.runtime.produceState 6 | import com.pavi2410.useCompose.query.core.Key 7 | import com.pavi2410.useCompose.query.core.QueryOptions 8 | import kotlinx.coroutines.CoroutineScope 9 | 10 | data class QueryState( 11 | val fetchStatus: FetchStatus, 12 | val dataState: DataState, 13 | ) 14 | 15 | enum class FetchStatus { 16 | Idle, Fetching 17 | } 18 | 19 | sealed interface DataState { 20 | object Pending : DataState 21 | data class Error(val message: String) : DataState 22 | data class Success(val data: T) : DataState 23 | } 24 | 25 | /** 26 | * Use a query with type-safe key and QueryClient integration. 27 | */ 28 | @Composable 29 | fun useQuery( 30 | key: Key, 31 | queryFn: suspend CoroutineScope.() -> T, 32 | options: QueryOptions = QueryOptions.Default, 33 | ): State> { 34 | val queryClient = useQueryClient() 35 | 36 | return produceState>( 37 | initialValue = QueryState(FetchStatus.Idle, DataState.Pending), 38 | key1 = key, 39 | key2 = options.enabled 40 | ) { 41 | if (!options.enabled) { 42 | value = QueryState(FetchStatus.Idle, DataState.Pending) 43 | return@produceState 44 | } 45 | 46 | value = QueryState(FetchStatus.Fetching, value.dataState) 47 | 48 | value = try { 49 | val cacheEntry = queryClient.getQuery(key, queryFn) 50 | 51 | if (cacheEntry.error != null) { 52 | QueryState( 53 | FetchStatus.Idle, 54 | DataState.Error(cacheEntry.error.message ?: "unknown error") 55 | ) 56 | } else { 57 | QueryState(FetchStatus.Idle, DataState.Success(cacheEntry.data)) 58 | } 59 | } catch (e: Throwable) { 60 | QueryState(FetchStatus.Idle, DataState.Error(e.message ?: "unknown error")) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /shared/src/screens/SimpleExample.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.demo.screens 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.unit.dp 10 | import com.pavi2410.useCompose.demo.common.httpClient 11 | import com.pavi2410.useCompose.query.DataState 12 | import com.pavi2410.useCompose.query.FetchStatus 13 | import com.pavi2410.useCompose.query.core.Key 14 | import com.pavi2410.useCompose.query.useQuery 15 | import io.ktor.client.call.body 16 | import io.ktor.client.request.get 17 | import kotlinx.serialization.Serializable 18 | 19 | @Serializable 20 | data class RepoData( 21 | val full_name: String, 22 | val description: String, 23 | val subscribers_count: Int, 24 | val stargazers_count: Int, 25 | val forks_count: Int, 26 | ) 27 | 28 | data class RepoKey(val repoPath: String) : Key 29 | 30 | @Composable 31 | fun SimpleExample(modifier: Modifier = Modifier) { 32 | Column(modifier = modifier.padding(16.dp)) { 33 | 34 | val queryState by useQuery( 35 | key = RepoKey("pavi2410/useCompose"), 36 | queryFn = { 37 | httpClient.get("https://api.github.com/repos/pavi2410/useCompose").body() 38 | } 39 | ) 40 | 41 | when (val state = queryState.dataState) { 42 | is DataState.Pending -> { 43 | Text("Loading...") 44 | } 45 | 46 | is DataState.Error -> { 47 | Text("Error: ${state.message}") 48 | } 49 | 50 | is DataState.Success<*> -> { 51 | val data = state.data as RepoData 52 | Column { 53 | Text("Name: ${data.full_name}") 54 | Text("Description: ${data.description}") 55 | Text("Subscribers: ${data.subscribers_count}") 56 | Text("Stargazers: ${data.stargazers_count}") 57 | Text("Forks: ${data.forks_count}") 58 | if (queryState.fetchStatus == FetchStatus.Fetching) { 59 | Text("Updating...") 60 | } 61 | } 62 | } 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /compose-query/src/core/QueryCache.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.query.core 2 | 3 | import kotlinx.coroutines.sync.Mutex 4 | import kotlinx.coroutines.sync.withLock 5 | 6 | /** 7 | * Simple cache entry containing query data and metadata. 8 | */ 9 | data class CacheEntry( 10 | val data: T, 11 | val error: Throwable? = null, 12 | val isInvalidated: Boolean = false, 13 | val timestamp: Long = System.currentTimeMillis(), 14 | ) { 15 | /** 16 | * Create a copy marked as invalidated. 17 | */ 18 | fun invalidate(): CacheEntry = copy(isInvalidated = true) 19 | 20 | /** 21 | * Check if data is stale based on the given stale time. 22 | */ 23 | fun isStale(staleTime: Long): Boolean { 24 | if (staleTime == 0L) return true 25 | return System.currentTimeMillis() - timestamp > staleTime 26 | } 27 | } 28 | 29 | /** 30 | * Simple thread-safe query cache. 31 | */ 32 | class QueryCache { 33 | private val cache = mutableMapOf>() 34 | private val mutex = Mutex() 35 | 36 | /** 37 | * Get a cache entry for the given key. 38 | */ 39 | suspend fun get(key: Key): CacheEntry? = mutex.withLock { 40 | @Suppress("UNCHECKED_CAST") 41 | cache[key] as? CacheEntry 42 | } 43 | 44 | /** 45 | * Set a cache entry for the given key. 46 | */ 47 | suspend fun set(key: Key, entry: CacheEntry) = mutex.withLock { 48 | cache[key] = entry 49 | } 50 | 51 | /** 52 | * Invalidate a specific cache entry. 53 | */ 54 | suspend fun invalidate(key: Key) = mutex.withLock { 55 | cache[key]?.let { entry -> 56 | @Suppress("UNCHECKED_CAST") 57 | cache[key] = (entry as CacheEntry).invalidate() 58 | } 59 | } 60 | 61 | /** 62 | * Invalidate all cache entries matching the given matcher. 63 | */ 64 | suspend fun invalidateMatching(matcher: KeyMatcher): Int = mutex.withLock { 65 | var count = 0 66 | cache.keys.filter { it.matches(matcher) }.forEach { key -> 67 | cache[key]?.let { entry -> 68 | @Suppress("UNCHECKED_CAST") 69 | cache[key] = (entry as CacheEntry).invalidate() 70 | count++ 71 | } 72 | } 73 | count 74 | } 75 | 76 | /** 77 | * Clear all cache entries. 78 | */ 79 | suspend fun clear() = mutex.withLock { 80 | cache.clear() 81 | } 82 | } -------------------------------------------------------------------------------- /compose-query/src/core/Key.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.query.core 2 | 3 | /** 4 | * Type-safe query key interface. 5 | * 6 | * Implement this interface on data classes to create strongly-typed query keys. 7 | * The data class automatically provides equality and hashing for cache operations. 8 | * 9 | * Example: 10 | * ```kotlin 11 | * data class UserKey(val userId: Long) : Key 12 | * data class PostsKey(val userId: Long, val page: Int = 1) : Key 13 | * ``` 14 | */ 15 | interface Key 16 | 17 | /** 18 | * Extended key interface for hierarchical relationships. 19 | * Allows for cascade invalidation patterns. 20 | */ 21 | interface HierarchicalKey : Key { 22 | /** 23 | * Returns the parent key in the hierarchy. 24 | * Used for cascade invalidation. 25 | */ 26 | fun parentKey(): Key? 27 | } 28 | 29 | /** 30 | * Key matcher for flexible key matching and invalidation. 31 | */ 32 | sealed interface KeyMatcher { 33 | /** 34 | * Match a specific key exactly. 35 | */ 36 | data class Exact(val key: Key) : KeyMatcher 37 | 38 | /** 39 | * Match all keys of a specific type. 40 | */ 41 | data class ByType(val type: kotlin.reflect.KClass) : KeyMatcher 42 | 43 | /** 44 | * Match keys using a custom predicate. 45 | */ 46 | data class Predicate(val predicate: (Key) -> Boolean) : KeyMatcher 47 | 48 | /** 49 | * Match keys that start with a prefix (for hierarchical keys). 50 | */ 51 | data class Prefix(val prefix: Key) : KeyMatcher 52 | } 53 | 54 | /** 55 | * Matches a key against a matcher. 56 | */ 57 | fun Key.matches(matcher: KeyMatcher): Boolean { 58 | return when (matcher) { 59 | is KeyMatcher.Exact -> this == matcher.key 60 | is KeyMatcher.ByType -> matcher.type.isInstance(this) 61 | is KeyMatcher.Predicate -> matcher.predicate(this) 62 | is KeyMatcher.Prefix -> { 63 | // For prefix matching, we check if this key "starts with" the prefix 64 | // This is useful for hierarchical keys where we want to match all related keys 65 | this.toString().startsWith(matcher.prefix.toString()) 66 | } 67 | } 68 | } 69 | 70 | /** 71 | * Creates a type-based matcher. 72 | */ 73 | inline fun keyTypeOf(): KeyMatcher.ByType = 74 | KeyMatcher.ByType(T::class) 75 | 76 | /** 77 | * Creates an exact key matcher. 78 | */ 79 | fun Key.toMatcher(): KeyMatcher.Exact = KeyMatcher.Exact(this) 80 | 81 | /** 82 | * Creates a predicate-based matcher. 83 | */ 84 | fun keyMatching(predicate: (Key) -> Boolean): KeyMatcher.Predicate = 85 | KeyMatcher.Predicate(predicate) -------------------------------------------------------------------------------- /compose-query/src/mutation.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.query 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.State 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.runtime.rememberCoroutineScope 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.cancel 11 | import kotlinx.coroutines.launch 12 | import kotlinx.coroutines.withContext 13 | 14 | sealed interface MutationState { 15 | object Idle : MutationState 16 | object Loading : MutationState 17 | data class Error(val message: String) : MutationState 18 | data class Success(val data: T) : MutationState 19 | } 20 | 21 | interface Mutation { 22 | val mutationState: State> 23 | 24 | fun mutate( 25 | vararg args: String, 26 | onSuccess: (T) -> Unit = {}, 27 | onError: (String) -> Unit = {}, 28 | ) 29 | 30 | fun cancel() 31 | } 32 | 33 | @Composable 34 | fun useMutation(mutationFn: suspend CoroutineScope.(args: Array) -> T): Mutation { 35 | val coroutineScope = rememberCoroutineScope() 36 | return remember { 37 | object : Mutation { 38 | private val _mutationState = mutableStateOf>(MutationState.Idle) 39 | override val mutationState: State> = _mutationState 40 | 41 | override fun mutate( 42 | vararg args: String, 43 | onSuccess: (T) -> Unit, 44 | onError: (String) -> Unit, 45 | ) { 46 | _mutationState.value = MutationState.Loading 47 | coroutineScope.launch { 48 | withContext(Dispatchers.IO) { 49 | try { 50 | val result = mutationFn(args) 51 | _mutationState.value = MutationState.Success(result) 52 | withContext(Dispatchers.Main) { 53 | onSuccess(result) 54 | } 55 | } catch (e: Throwable) { 56 | val errorMessage = e.message ?: "unknown error" 57 | _mutationState.value = MutationState.Error(errorMessage) 58 | withContext(Dispatchers.Main) { 59 | onError(errorMessage) 60 | } 61 | } 62 | } 63 | } 64 | } 65 | 66 | override fun cancel() { 67 | coroutineScope.cancel() 68 | } 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /compose-query/test/CacheEntryTest.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.query 2 | 3 | import com.pavi2410.useCompose.query.core.CacheEntry 4 | import kotlin.test.Test 5 | import kotlin.test.assertFalse 6 | import kotlin.test.assertTrue 7 | 8 | class CacheEntryTest { 9 | 10 | @Test 11 | fun cacheEntry_hasCurrentTimestamp() { 12 | val beforeTime = System.currentTimeMillis() 13 | val entry = CacheEntry("test-data") 14 | val afterTime = System.currentTimeMillis() 15 | 16 | // Timestamp should be between before and after time 17 | assertTrue(entry.timestamp >= beforeTime) 18 | assertTrue(entry.timestamp <= afterTime) 19 | } 20 | 21 | @Test 22 | fun cacheEntry_isNotStaleWhenFresh() { 23 | val entry = CacheEntry("test-data") 24 | 25 | // Data should not be stale immediately with non-zero stale time 26 | assertFalse(entry.isStale(1000)) // 1 second stale time 27 | assertFalse(entry.isStale(100)) // 100ms stale time 28 | assertTrue(entry.isStale(0)) // 0ms stale time (always stale) 29 | } 30 | 31 | @Test 32 | fun cacheEntry_isStaleAfterTime() { 33 | // Create entry with timestamp from past 34 | val pastTime = System.currentTimeMillis() - 200 // 200ms ago 35 | val entry = CacheEntry("test-data", timestamp = pastTime) 36 | 37 | // Should be stale with short stale time 38 | assertTrue(entry.isStale(100)) // 100ms stale time 39 | 40 | // Should not be stale with longer stale time 41 | assertFalse(entry.isStale(1000)) // 1 second stale time 42 | } 43 | 44 | @Test 45 | fun cacheEntry_alwaysStaleWithZeroStaleTime() { 46 | val entry = CacheEntry("test-data") 47 | 48 | // Even immediately, should be stale with 0 stale time 49 | assertTrue(entry.isStale(0)) 50 | 51 | // Create another entry and check immediately 52 | val entry2 = CacheEntry("test-data-2") 53 | assertTrue(entry2.isStale(0)) 54 | } 55 | 56 | @Test 57 | fun cacheEntry_invalidationPreservesTimestamp() { 58 | val entry = CacheEntry("test-data") 59 | val originalTimestamp = entry.timestamp 60 | 61 | val invalidatedEntry = entry.invalidate() 62 | 63 | // Timestamp should be preserved 64 | assertTrue(invalidatedEntry.timestamp == originalTimestamp) 65 | assertTrue(invalidatedEntry.isInvalidated) 66 | } 67 | 68 | @Test 69 | fun cacheEntry_stalenessCheckWorksWithInvalidation() { 70 | // Create entry with timestamp from past 71 | val pastTime = System.currentTimeMillis() - 200 // 200ms ago 72 | val entry = CacheEntry("test-data", timestamp = pastTime) 73 | 74 | val invalidatedEntry = entry.invalidate() 75 | 76 | // Staleness check should still work on invalidated entries 77 | assertTrue(invalidatedEntry.isStale(100)) // 100ms stale time 78 | assertFalse(invalidatedEntry.isStale(1000)) // 1 second stale time 79 | } 80 | 81 | @Test 82 | fun cacheEntry_customTimestamp() { 83 | val customTime = System.currentTimeMillis() - 5000 // 5 seconds ago 84 | val entry = CacheEntry("test-data", timestamp = customTime) 85 | 86 | // Should be stale with short stale time 87 | assertTrue(entry.isStale(1000)) // 1 second stale time 88 | 89 | // Should be stale with longer stale time too 90 | assertTrue(entry.isStale(4000)) // 4 seconds stale time 91 | 92 | // Should not be stale with very long stale time 93 | assertFalse(entry.isStale(10000)) // 10 seconds stale time 94 | } 95 | } -------------------------------------------------------------------------------- /shared/src/App.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.demo 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.foundation.layout.systemBarsPadding 5 | import androidx.compose.material.Icon 6 | import androidx.compose.material.IconButton 7 | import androidx.compose.material.Scaffold 8 | import androidx.compose.material.Text 9 | import androidx.compose.material.TopAppBar 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.getValue 14 | import androidx.compose.runtime.mutableStateOf 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.runtime.setValue 17 | import androidx.compose.ui.Modifier 18 | import com.pavi2410.useCompose.demo.screens.BasicExample 19 | import com.pavi2410.useCompose.demo.screens.MainScreen 20 | import com.pavi2410.useCompose.demo.screens.MutationExample 21 | import com.pavi2410.useCompose.demo.screens.PrefetchingExample 22 | import com.pavi2410.useCompose.demo.screens.QueryExample 23 | import com.pavi2410.useCompose.demo.screens.SimpleExample 24 | import com.pavi2410.useCompose.demo.theme.UseComposeTheme 25 | import com.pavi2410.useCompose.query.QueryClientProvider 26 | import com.pavi2410.useCompose.query.core.QueryClient 27 | 28 | @Composable 29 | fun App() { 30 | UseComposeTheme { 31 | QueryClientProvider(client = remember { QueryClient() }) { 32 | var currentScreen by remember { mutableStateOf(Screen.Home) } 33 | 34 | val showBackButton = currentScreen != Screen.Home 35 | 36 | Scaffold( 37 | modifier = Modifier.systemBarsPadding(), 38 | topBar = { 39 | TopAppBar( 40 | title = { Text(currentScreen.title) }, 41 | navigationIcon = if (showBackButton) { 42 | { 43 | IconButton(onClick = { currentScreen = Screen.Home }) { 44 | Icon( 45 | imageVector = Icons.AutoMirrored.Filled.ArrowBack, 46 | contentDescription = "Back" 47 | ) 48 | } 49 | } 50 | } else null 51 | ) 52 | } 53 | ) { paddingValues -> 54 | when (currentScreen) { 55 | Screen.Home -> MainScreen( 56 | onNavigate = { screen -> currentScreen = screen }, 57 | modifier = Modifier.padding(paddingValues) 58 | ) 59 | 60 | Screen.Query -> QueryExample( 61 | modifier = Modifier.padding(paddingValues) 62 | ) 63 | 64 | Screen.Mutation -> MutationExample( 65 | modifier = Modifier.padding(paddingValues) 66 | ) 67 | 68 | Screen.Simple -> SimpleExample( 69 | modifier = Modifier.padding(paddingValues) 70 | ) 71 | 72 | Screen.Basic -> BasicExample( 73 | modifier = Modifier.padding(paddingValues) 74 | ) 75 | 76 | Screen.Prefetching -> PrefetchingExample( 77 | modifier = Modifier.padding(paddingValues) 78 | ) 79 | } 80 | } 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /shared/src/screens/MutationExample.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.demo.screens 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material.Button 7 | import androidx.compose.material.Text 8 | import androidx.compose.material.TextField 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.runtime.mutableStateOf 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.runtime.setValue 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.unit.dp 16 | import com.pavi2410.useCompose.query.MutationState 17 | import com.pavi2410.useCompose.query.useMutation 18 | import kotlinx.coroutines.delay 19 | 20 | @Composable 21 | fun MutationExample(modifier: Modifier = Modifier) { 22 | Column( 23 | modifier = modifier.padding(16.dp), 24 | verticalArrangement = Arrangement.spacedBy(16.dp), 25 | ) { 26 | var username by remember { mutableStateOf("useCompose") } 27 | var password by remember { mutableStateOf("plsUseCompose!") } 28 | var token by remember { mutableStateOf("") } 29 | var errorMessage by remember { mutableStateOf("") } 30 | 31 | val loginMutation = useMutation { (username, password) -> 32 | delay(3000) 33 | if (username != "useCompose" || password != "plsUseCompose!") { 34 | throw Exception("Invalid credentials") 35 | } 36 | "secret_token:$username/$password@${System.currentTimeMillis()}" 37 | } 38 | val mutationState by loginMutation.mutationState 39 | 40 | TextField( 41 | username, 42 | { username = it }, 43 | label = { Text("Username") } 44 | ) 45 | 46 | TextField( 47 | password, 48 | { password = it }, 49 | label = { Text("Password") } 50 | ) 51 | 52 | Button( 53 | enabled = mutationState != MutationState.Loading, 54 | onClick = { 55 | loginMutation.mutate( 56 | username, password, 57 | onSuccess = { token = it }, 58 | onError = { errorMessage = it } 59 | ) 60 | } 61 | ) { 62 | Text( 63 | text = when (mutationState) { 64 | MutationState.Idle -> "Login" 65 | MutationState.Loading -> "Logging in..." 66 | is MutationState.Error -> "Retry" 67 | is MutationState.Success -> "Re login" 68 | } 69 | ) 70 | } 71 | 72 | when (mutationState) { 73 | MutationState.Idle -> { 74 | Text( 75 | text = "Please login!", 76 | ) 77 | } 78 | 79 | MutationState.Loading -> { 80 | Text( 81 | text = "Please wait...", 82 | ) 83 | } 84 | 85 | is MutationState.Error -> { 86 | Text( 87 | text = "Error: $errorMessage", 88 | ) 89 | } 90 | 91 | is MutationState.Success -> { 92 | val token = (mutationState as MutationState.Success).data 93 | Text( 94 | text = "Welcome! token = $token", 95 | ) 96 | } 97 | } 98 | 99 | Text( 100 | text = "Token from onSuccess = $token" 101 | ) 102 | Text( 103 | text = "Error from onError = $errorMessage" 104 | ) 105 | } 106 | } -------------------------------------------------------------------------------- /compose-query/src/core/QueryClient.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.query.core 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.withContext 6 | 7 | /** 8 | * Simple query client for managing queries and cache. 9 | */ 10 | class QueryClient { 11 | private val cache = QueryCache() 12 | 13 | /** 14 | * Get cached data for a key, or fetch it if not cached. 15 | */ 16 | suspend fun getQuery( 17 | key: Key, 18 | queryFn: suspend CoroutineScope.() -> T, 19 | ): CacheEntry { 20 | // Try to get from cache first 21 | val cached = cache.get(key) 22 | if (cached != null && !cached.isInvalidated) { 23 | return cached 24 | } 25 | 26 | // Not in cache or invalidated, fetch new data 27 | return try { 28 | val data = withContext(Dispatchers.IO) { queryFn() } 29 | val entry = CacheEntry(data) 30 | cache.set(key, entry) 31 | entry 32 | } catch (e: Throwable) { 33 | // If we have cached data, return it with error, otherwise rethrow 34 | if (cached != null) { 35 | val entry = CacheEntry( 36 | data = cached.data, 37 | error = e 38 | ) 39 | cache.set(key, entry) 40 | entry 41 | } else { 42 | throw e 43 | } 44 | } 45 | } 46 | 47 | /** 48 | * Invalidate a specific query by key. 49 | */ 50 | suspend fun invalidateQuery(key: Key) { 51 | cache.invalidate(key) 52 | } 53 | 54 | /** 55 | * Invalidate all queries of a specific type. 56 | */ 57 | suspend fun invalidateQueries(matcher: KeyMatcher) { 58 | cache.invalidateMatching(matcher) 59 | } 60 | 61 | /** 62 | * Invalidate all queries of a specific type using reified generics. 63 | */ 64 | suspend inline fun invalidateQueriesOfType() { 65 | invalidateQueries(KeyMatcher.ByType(T::class)) 66 | } 67 | 68 | /** 69 | * Clear all cached queries. 70 | */ 71 | suspend fun clear() { 72 | cache.clear() 73 | } 74 | 75 | /** 76 | * Prefetch a query without returning it. 77 | * Only fetches if the data is stale based on the staleTime option. 78 | */ 79 | suspend fun prefetchQuery( 80 | key: Key, 81 | queryFn: suspend CoroutineScope.() -> T, 82 | options: QueryOptions = QueryOptions.Default, 83 | ) { 84 | val cached = cache.get(key) 85 | 86 | // Skip prefetch if data exists and is not stale 87 | if (cached != null && !cached.isInvalidated && !cached.isStale(options.staleTime)) { 88 | return 89 | } 90 | 91 | try { 92 | val data = withContext(Dispatchers.IO) { queryFn() } 93 | val entry = CacheEntry(data) 94 | cache.set(key, entry) 95 | } catch (e: Throwable) { 96 | // For prefetch, we don't propagate errors 97 | // Just cache the error if we don't have existing data 98 | if (cached == null) { 99 | val entry = CacheEntry( 100 | data = null as T, 101 | error = e 102 | ) 103 | cache.set(key, entry) 104 | } 105 | } 106 | } 107 | 108 | /** 109 | * Get cached data for a key without fetching. 110 | * Returns null if no data is cached. 111 | */ 112 | suspend fun getQueryData(key: Key): T? { 113 | val cached = cache.get(key) 114 | return if (cached != null && cached.error == null && !cached.isInvalidated) { 115 | cached.data 116 | } else { 117 | null 118 | } 119 | } 120 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/android,androidstudio,gradle,kotlin 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=android,androidstudio,gradle,kotlin 4 | 5 | ### Android ### 6 | # Gradle files 7 | .gradle/ 8 | build/ 9 | 10 | # Local configuration file (sdk path, etc) 11 | local.properties 12 | 13 | # Log/OS Files 14 | *.log 15 | 16 | # Android Studio generated files and folders 17 | captures/ 18 | .externalNativeBuild/ 19 | .cxx/ 20 | *.apk 21 | output.json 22 | 23 | # IntelliJ 24 | *.iml 25 | .idea/ 26 | 27 | # Keystore files 28 | *.jks 29 | *.keystore 30 | 31 | # Google Services (e.g. APIs or Firebase) 32 | google-services.json 33 | 34 | # Android Profiling 35 | *.hprof 36 | 37 | ### Android Patch ### 38 | gen-external-apklibs 39 | 40 | # Replacement of .externalNativeBuild directories introduced 41 | # with Android Studio 3.5. 42 | 43 | ### Kotlin ### 44 | # Compiled class file 45 | *.class 46 | 47 | # Log file 48 | 49 | # BlueJ files 50 | *.ctxt 51 | 52 | # Mobile Tools for Java (J2ME) 53 | .mtj.tmp/ 54 | 55 | # Package Files # 56 | *.jar 57 | *.war 58 | *.nar 59 | *.ear 60 | *.zip 61 | *.tar.gz 62 | *.rar 63 | 64 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 65 | hs_err_pid* 66 | replay_pid* 67 | 68 | ### Gradle ### 69 | .gradle 70 | 71 | # Ignore Gradle GUI config 72 | gradle-app.setting 73 | 74 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 75 | !gradle-wrapper.jar 76 | 77 | # Cache of project 78 | .gradletasknamecache 79 | 80 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 81 | # gradle/wrapper/gradle-wrapper.properties 82 | 83 | ### Gradle Patch ### 84 | **/build/ 85 | 86 | ### AndroidStudio ### 87 | # Covers files to be ignored for android development using Android Studio. 88 | 89 | # Built application files 90 | *.ap_ 91 | *.aab 92 | 93 | # Files for the ART/Dalvik VM 94 | *.dex 95 | 96 | # Java class files 97 | 98 | # Generated files 99 | bin/ 100 | gen/ 101 | out/ 102 | 103 | # Gradle files 104 | 105 | # Signing files 106 | .signing/ 107 | 108 | # Local configuration file (sdk path, etc) 109 | 110 | # Proguard folder generated by Eclipse 111 | proguard/ 112 | 113 | # Log Files 114 | 115 | # Android Studio 116 | /*/build/ 117 | /*/local.properties 118 | /*/out 119 | /*/*/build 120 | /*/*/production 121 | .navigation/ 122 | *.ipr 123 | *~ 124 | *.swp 125 | 126 | # Keystore files 127 | 128 | # Google Services (e.g. APIs or Firebase) 129 | # google-services.json 130 | 131 | # Android Patch 132 | 133 | # External native build folder generated in Android Studio 2.2 and later 134 | .externalNativeBuild 135 | 136 | # NDK 137 | obj/ 138 | 139 | # IntelliJ IDEA 140 | *.iws 141 | /out/ 142 | 143 | # User-specific configurations 144 | .idea/caches/ 145 | .idea/libraries/ 146 | .idea/shelf/ 147 | .idea/workspace.xml 148 | .idea/tasks.xml 149 | .idea/.name 150 | .idea/compiler.xml 151 | .idea/copyright/profiles_settings.xml 152 | .idea/encodings.xml 153 | .idea/misc.xml 154 | .idea/modules.xml 155 | .idea/scopes/scope_settings.xml 156 | .idea/dictionaries 157 | .idea/vcs.xml 158 | .idea/jsLibraryMappings.xml 159 | .idea/datasources.xml 160 | .idea/dataSources.ids 161 | .idea/sqlDataSources.xml 162 | .idea/dynamic.xml 163 | .idea/uiDesigner.xml 164 | .idea/assetWizardSettings.xml 165 | .idea/gradle.xml 166 | .idea/jarRepositories.xml 167 | .idea/navEditor.xml 168 | 169 | # OS-specific files 170 | .DS_Store 171 | .DS_Store? 172 | ._* 173 | .Spotlight-V100 174 | .Trashes 175 | ehthumbs.db 176 | Thumbs.db 177 | 178 | # Legacy Eclipse project files 179 | .classpath 180 | .project 181 | .cproject 182 | .settings/ 183 | 184 | # Mobile Tools for Java (J2ME) 185 | 186 | # Package Files # 187 | 188 | # virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml) 189 | 190 | ## Plugin-specific files: 191 | 192 | # mpeltonen/sbt-idea plugin 193 | .idea_modules/ 194 | 195 | # JIRA plugin 196 | atlassian-ide-plugin.xml 197 | 198 | # Mongo Explorer plugin 199 | .idea/mongoSettings.xml 200 | 201 | # Crashlytics plugin (for Android Studio and IntelliJ) 202 | com_crashlytics_export_strings.xml 203 | crashlytics.properties 204 | crashlytics-build.properties 205 | fabric.properties 206 | 207 | ### AndroidStudio Patch ### 208 | 209 | !/gradle/wrapper/gradle-wrapper.jar 210 | 211 | # End of https://www.toptal.com/developers/gitignore/api/android,androidstudio,gradle,kotlin 212 | .kotlin/ 213 | -------------------------------------------------------------------------------- /compose-query/test/QueryClientTest.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.query 2 | 3 | import com.pavi2410.useCompose.query.core.QueryClient 4 | import com.pavi2410.useCompose.query.core.QueryOptions 5 | import kotlinx.coroutines.test.runTest 6 | import kotlin.test.Test 7 | import kotlin.test.assertEquals 8 | import kotlin.test.assertFails 9 | import kotlin.test.assertNull 10 | 11 | class QueryClientTest { 12 | 13 | @Test 14 | fun queryClient_cachesAndReturnsData() = runTest { 15 | val client = QueryClient() 16 | val key = UserKey(123L) 17 | 18 | var callCount = 0 19 | 20 | // First call should execute the query 21 | val entry1 = client.getQuery(key) { 22 | callCount++ 23 | "user-123" 24 | } 25 | assertEquals("user-123", entry1.data) 26 | assertEquals(1, callCount) 27 | 28 | // Second call should return cached data 29 | val entry2 = client.getQuery(key) { 30 | callCount++ 31 | "user-123" 32 | } 33 | assertEquals("user-123", entry2.data) 34 | assertEquals(1, callCount) // Should not increase 35 | } 36 | 37 | @Test 38 | fun queryClient_invalidationForcesRefetch() = runTest { 39 | val client = QueryClient() 40 | val key = UserKey(123L) 41 | 42 | var callCount = 0 43 | 44 | // First call 45 | val entry1 = client.getQuery(key) { 46 | callCount++ 47 | "user-$callCount" 48 | } 49 | assertEquals("user-1", entry1.data) 50 | assertEquals(1, callCount) 51 | 52 | // Invalidate and call again 53 | client.invalidateQuery(key) 54 | val entry2 = client.getQuery(key) { 55 | callCount++ 56 | "user-$callCount" 57 | } 58 | assertEquals("user-2", entry2.data) 59 | assertEquals(2, callCount) 60 | } 61 | 62 | @Test 63 | fun queryClient_invalidatesByType() = runTest { 64 | val client = QueryClient() 65 | val userKey1 = UserKey(123L) 66 | val userKey2 = UserKey(456L) 67 | val postKey = PostKey("abc") 68 | 69 | var userCallCount = 0 70 | var postCallCount = 0 71 | 72 | // Cache some data 73 | client.getQuery(userKey1) { 74 | userCallCount++ 75 | "user-$userCallCount" 76 | } 77 | client.getQuery(userKey2) { 78 | userCallCount++ 79 | "user-$userCallCount" 80 | } 81 | client.getQuery(postKey) { 82 | postCallCount++ 83 | "post-$postCallCount" 84 | } 85 | 86 | assertEquals(2, userCallCount) 87 | assertEquals(1, postCallCount) 88 | 89 | // Invalidate all user queries 90 | client.invalidateQueriesOfType() 91 | 92 | // UserKey queries should refetch, PostKey should not 93 | client.getQuery(userKey1) { 94 | userCallCount++ 95 | "user-$userCallCount" 96 | } 97 | client.getQuery(postKey) { 98 | postCallCount++ 99 | "post-$postCallCount" 100 | } 101 | 102 | assertEquals(3, userCallCount) // UserKey refetched 103 | assertEquals(1, postCallCount) // PostKey used cache 104 | } 105 | 106 | @Test 107 | fun prefetchQuery_cachesDataWithoutReturning() = runTest { 108 | val client = QueryClient() 109 | val key = UserKey(123L) 110 | 111 | var callCount = 0 112 | 113 | // Prefetch should cache the data 114 | client.prefetchQuery( 115 | key, 116 | queryFn = { 117 | callCount++ 118 | "user-123" 119 | } 120 | ) 121 | assertEquals(1, callCount) 122 | 123 | // Subsequent getQuery should use cached data 124 | val entry = client.getQuery(key) { 125 | callCount++ 126 | "user-456" // Different data, should not be called 127 | } 128 | assertEquals("user-123", entry.data) 129 | assertEquals(1, callCount) // Should not increase 130 | } 131 | 132 | @Test 133 | fun prefetchQuery_respectsStaleTime() = runTest { 134 | val client = QueryClient() 135 | val key = UserKey(123L) 136 | 137 | var callCount = 0 138 | 139 | // Initial prefetch with long stale time 140 | client.prefetchQuery( 141 | key = key, 142 | queryFn = { 143 | callCount++ 144 | "user-$callCount" 145 | }, 146 | options = QueryOptions(staleTime = 10000) // 10 seconds stale time 147 | ) 148 | assertEquals(1, callCount) 149 | 150 | // Immediate prefetch should be skipped due to stale time 151 | client.prefetchQuery( 152 | key = key, 153 | queryFn = { 154 | callCount++ 155 | "user-$callCount" 156 | }, 157 | options = QueryOptions(staleTime = 10000) 158 | ) 159 | assertEquals(1, callCount) // Should not increase 160 | 161 | // Test with 0 stale time (always stale) 162 | client.prefetchQuery( 163 | key = key, 164 | queryFn = { 165 | callCount++ 166 | "user-$callCount" 167 | }, 168 | options = QueryOptions(staleTime = 0) // Always stale 169 | ) 170 | assertEquals(2, callCount) // Should increase 171 | } 172 | 173 | @Test 174 | fun prefetchQuery_handlesErrors() = runTest { 175 | val client = QueryClient() 176 | val key = UserKey(123L) 177 | 178 | var callCount = 0 179 | 180 | // Prefetch that throws error should not crash 181 | client.prefetchQuery(key, queryFn = { 182 | callCount++ 183 | throw RuntimeException("Network error") 184 | }) 185 | assertEquals(1, callCount) 186 | 187 | // Error should be cached, so getQuery should see it 188 | val entry = client.getQuery(key) { 189 | callCount++ 190 | "user-success" 191 | } 192 | assertEquals("Network error", entry.error?.message) 193 | assertEquals(1, callCount) // Should not call queryFn again 194 | } 195 | 196 | @Test 197 | fun getQueryData_returnsNullWhenNoData() = runTest { 198 | val client = QueryClient() 199 | val key = UserKey(123L) 200 | 201 | // Should return null when no data is cached 202 | val data = client.getQueryData(key) 203 | assertNull(data) 204 | } 205 | 206 | @Test 207 | fun getQueryData_returnsCachedData() = runTest { 208 | val client = QueryClient() 209 | val key = UserKey(123L) 210 | 211 | // Cache some data first 212 | client.getQuery(key) { 213 | "user-123" 214 | } 215 | 216 | // getQueryData should return the cached data 217 | val data = client.getQueryData(key) 218 | assertEquals("user-123", data) 219 | } 220 | 221 | @Test 222 | fun getQueryData_returnsNullWhenDataHasError() = runTest { 223 | val client = QueryClient() 224 | val key = UserKey(123L) 225 | 226 | // Cache data with error - this should throw 227 | assertFails { 228 | client.getQuery(key) { 229 | throw RuntimeException("Error") 230 | } 231 | } 232 | 233 | // getQueryData should return null when there's an error 234 | val data = client.getQueryData(key) 235 | assertNull(data) 236 | } 237 | 238 | @Test 239 | fun getQueryData_returnsNullWhenDataIsInvalidated() = runTest { 240 | val client = QueryClient() 241 | val key = UserKey(123L) 242 | 243 | // Cache some data first 244 | client.getQuery(key) { 245 | "user-123" 246 | } 247 | 248 | // Data should be available 249 | assertEquals("user-123", client.getQueryData(key)) 250 | 251 | // Invalidate the data 252 | client.invalidateQuery(key) 253 | 254 | // getQueryData should now return null 255 | val data = client.getQueryData(key) 256 | assertNull(data) 257 | } 258 | } -------------------------------------------------------------------------------- /shared/src/screens/BasicExample.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.demo.screens 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.lazy.LazyColumn 11 | import androidx.compose.foundation.lazy.items 12 | import androidx.compose.material.MaterialTheme 13 | import androidx.compose.material.Text 14 | import androidx.compose.material.TextButton 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.LaunchedEffect 17 | import androidx.compose.runtime.getValue 18 | import androidx.compose.runtime.mutableIntStateOf 19 | import androidx.compose.runtime.remember 20 | import androidx.compose.runtime.setValue 21 | import androidx.compose.ui.Alignment 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.graphics.Color 24 | import androidx.compose.ui.text.LinkAnnotation 25 | import androidx.compose.ui.text.SpanStyle 26 | import androidx.compose.ui.text.buildAnnotatedString 27 | import androidx.compose.ui.text.font.FontWeight 28 | import androidx.compose.ui.text.withLink 29 | import androidx.compose.ui.text.withStyle 30 | import androidx.compose.ui.unit.dp 31 | import com.pavi2410.useCompose.demo.common.httpClient 32 | import com.pavi2410.useCompose.query.DataState 33 | import com.pavi2410.useCompose.query.FetchStatus 34 | import com.pavi2410.useCompose.query.core.Key 35 | import com.pavi2410.useCompose.query.useQuery 36 | import io.ktor.client.call.body 37 | import io.ktor.client.request.get 38 | import kotlinx.serialization.Serializable 39 | 40 | @Serializable 41 | data class Post( 42 | val id: Int, 43 | val title: String, 44 | val body: String, 45 | ) 46 | 47 | data class PostsListKey(val type: String = "all") : Key 48 | data class PostDetailKey(val postId: Int) : Key 49 | 50 | @Composable 51 | fun BasicExample(modifier: Modifier = Modifier) { 52 | var postId by remember { mutableIntStateOf(-1) } 53 | val visitedPosts = remember { mutableSetOf() } 54 | 55 | Column(modifier = modifier.padding(16.dp)) { 56 | // Description text 57 | Text( 58 | text = "As you visit the posts below, you will notice them in a loading state " + 59 | "the first time you load them. However, after you return to this list and " + 60 | "click on any posts you have already visited again, you will see them " + 61 | "load instantly and background refresh right before your eyes! " + 62 | "(You may need to throttle your network speed to simulate longer loading sequences)", 63 | modifier = Modifier.padding(bottom = 16.dp) 64 | ) 65 | 66 | if (postId > -1) { 67 | PostDetail( 68 | postId = postId, 69 | onBack = { postId = -1 }, 70 | onVisited = { visitedPosts.add(it) } 71 | ) 72 | } else { 73 | PostsList( 74 | onPostClick = { id -> postId = id }, 75 | visitedPosts = visitedPosts 76 | ) 77 | } 78 | } 79 | } 80 | 81 | @Composable 82 | fun PostsList( 83 | onPostClick: (Int) -> Unit, 84 | visitedPosts: Set, 85 | ) { 86 | val queryState by useQuery( 87 | key = PostsListKey(), 88 | queryFn = { 89 | httpClient.get("https://jsonplaceholder.typicode.com/posts").body>() 90 | } 91 | ) 92 | 93 | Column { 94 | Text( 95 | text = "Posts", 96 | style = MaterialTheme.typography.h4, 97 | modifier = Modifier.padding(bottom = 16.dp) 98 | ) 99 | 100 | when (val state = queryState.dataState) { 101 | is DataState.Pending -> { 102 | Box( 103 | modifier = Modifier.fillMaxWidth(), 104 | contentAlignment = Alignment.Center 105 | ) { 106 | Text("Loading...") 107 | } 108 | } 109 | 110 | is DataState.Error -> { 111 | Text( 112 | text = "Error: ${state.message}", 113 | color = Color.Red 114 | ) 115 | } 116 | 117 | is DataState.Success -> { 118 | LazyColumn( 119 | verticalArrangement = Arrangement.spacedBy(8.dp) 120 | ) { 121 | items(state.data) { post -> 122 | PostItem( 123 | post = post, 124 | isVisited = visitedPosts.contains(post.id), 125 | onClick = { onPostClick(post.id) } 126 | ) 127 | } 128 | } 129 | 130 | if (queryState.fetchStatus == FetchStatus.Fetching) { 131 | Text( 132 | text = "Background Updating...", 133 | modifier = Modifier.padding(top = 8.dp) 134 | ) 135 | } else { 136 | Spacer(modifier = Modifier.height(24.dp)) 137 | } 138 | } 139 | } 140 | } 141 | } 142 | 143 | @Composable 144 | fun PostItem( 145 | post: Post, 146 | isVisited: Boolean, 147 | onClick: () -> Unit, 148 | ) { 149 | val annotatedString = buildAnnotatedString { 150 | withStyle( 151 | style = SpanStyle( 152 | color = if (isVisited) Color.Green else Color.Blue, 153 | fontWeight = if (isVisited) FontWeight.Bold else FontWeight.Normal 154 | ) 155 | ) { 156 | withLink( 157 | LinkAnnotation.Clickable( 158 | tag = "post_${post.id}", 159 | linkInteractionListener = { onClick() } 160 | ) 161 | ) { 162 | append(post.title) 163 | } 164 | } 165 | } 166 | 167 | Text( 168 | text = annotatedString, 169 | modifier = Modifier.padding(vertical = 4.dp) 170 | ) 171 | } 172 | 173 | @Composable 174 | fun PostDetail( 175 | postId: Int, 176 | onBack: () -> Unit, 177 | onVisited: (Int) -> Unit, 178 | ) { 179 | LaunchedEffect(postId) { 180 | onVisited(postId) 181 | } 182 | 183 | val queryState by useQuery( 184 | key = PostDetailKey(postId), 185 | queryFn = { 186 | httpClient.get("https://jsonplaceholder.typicode.com/posts/$postId").body() 187 | } 188 | ) 189 | 190 | Column { 191 | // Back button 192 | TextButton(onClick = onBack) { 193 | Text("← Back") 194 | } 195 | 196 | Spacer(modifier = Modifier.height(16.dp)) 197 | 198 | when (val state = queryState.dataState) { 199 | is DataState.Pending -> { 200 | Box( 201 | modifier = Modifier.fillMaxWidth(), 202 | contentAlignment = Alignment.Center 203 | ) { 204 | Text("Loading...") 205 | } 206 | } 207 | 208 | is DataState.Error -> { 209 | Text( 210 | text = "Error: ${state.message}", 211 | color = Color.Red 212 | ) 213 | } 214 | 215 | is DataState.Success -> { 216 | Column { 217 | Text( 218 | text = state.data.title, 219 | style = MaterialTheme.typography.h4, 220 | modifier = Modifier.padding(bottom = 16.dp) 221 | ) 222 | 223 | Text( 224 | text = state.data.body, 225 | style = MaterialTheme.typography.body1 226 | ) 227 | 228 | if (queryState.fetchStatus == FetchStatus.Fetching) { 229 | Text( 230 | text = "Background Updating...", 231 | modifier = Modifier.padding(top = 16.dp) 232 | ) 233 | } else { 234 | Spacer(modifier = Modifier.height(24.dp)) 235 | } 236 | } 237 | } 238 | } 239 | } 240 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # useCompose 2 | 3 | Headless @Composable hooks that drive UI logic. Inspired by React. 4 | 5 | **🚀 Now supports Kotlin/Compose Multiplatform!** Run on Android, Desktop, and more! 6 | 7 | > **📢 Looking for other hooks?** In v2.0, we focused on KMP support and kept only the `query` module. For other hooks like `useState`, `useEffect`, `useContext`, `useReducer`, `useToggle`, and `useConnectionStatus`, check out [**ComposeHooks**](https://github.com/junerver/ComposeHooks) - a comprehensive collection of Compose hooks! 8 | 9 | [![](https://jitpack.io/v/pavi2410/useCompose.svg)](https://jitpack.io/#pavi2410/useCompose) [![CI](https://github.com/pavi2410/useCompose/actions/workflows/ci.yml/badge.svg)](https://github.com/pavi2410/useCompose/actions/workflows/ci.yml) 10 | 11 | ## Quick Start 12 | 13 | ### Run the Demo 14 | 15 | **Desktop App:** 16 | ```bash 17 | ./gradlew :app:run 18 | ``` 19 | 20 | **Android App:** 21 | ```bash 22 | ./gradlew :app:assembleDebug 23 | ./gradlew :app:installDebug 24 | ``` 25 | 26 | **Run Tests:** 27 | ```bash 28 | # All tests 29 | ./gradlew test 30 | 31 | # Library tests only 32 | ./gradlew :query:test 33 | 34 | # App tests only 35 | ./gradlew :app:test 36 | ``` 37 | 38 | ## Modules 39 | 40 | ### ❓ query 41 | - `useQuery` - Type-safe async data fetching with caching and loading/error/success states 42 | - `useMutation` - Async mutations with callback handling 43 | - **Type-safe Keys** - Strongly-typed query keys using data classes 44 | - **QueryClient** - Centralized cache management with automatic deduplication 45 | - **Cache Invalidation** - Type-safe cache invalidation patterns 46 | 47 | **Platforms supported:** Android, Desktop (JVM), ready for iOS/Web 48 | 49 | ## Installation 50 | 51 | Add to your `libs.versions.toml`: 52 | ```toml 53 | [versions] 54 | useCompose = "2.0.0" # Use latest version 55 | 56 | [libraries] 57 | useCompose-query = { module = "com.github.pavi2410.useCompose:query", version.ref = "useCompose" } 58 | ``` 59 | 60 | Add to your project's `build.gradle.kts` (project level): 61 | ```kotlin 62 | allprojects { 63 | repositories { 64 | maven { url = uri("https://jitpack.io") } 65 | } 66 | } 67 | ``` 68 | 69 | Add to your module's `build.gradle.kts`: 70 | 71 | **For Kotlin Multiplatform:** 72 | ```kotlin 73 | kotlin { 74 | sourceSets { 75 | commonMain.dependencies { 76 | implementation(libs.useCompose.query) 77 | } 78 | } 79 | } 80 | ``` 81 | 82 | **For Android:** 83 | ```kotlin 84 | dependencies { 85 | implementation(libs.useCompose.query) 86 | } 87 | ``` 88 | 89 | ## Getting Started 90 | 91 | ### 1. Set up QueryClient 92 | 93 | First, create a `QueryClient` and wrap your app with `QueryClientProvider`: 94 | 95 | ```kotlin 96 | @Composable 97 | fun App() { 98 | QueryClientProvider(client = remember { QueryClient() }) { 99 | // Your app content 100 | MyAppContent() 101 | } 102 | } 103 | ``` 104 | 105 | ### 2. Define Type-Safe Keys 106 | 107 | Create data classes that implement the `Key` interface: 108 | 109 | ```kotlin 110 | data class UserKey(val userId: Long) : Key 111 | data class PostsKey(val userId: Long, val page: Int = 1) : Key 112 | data class RepoKey(val owner: String, val name: String) : Key 113 | 114 | // For singleton keys without parameters, use data object 115 | data object AllUsersKey : Key 116 | data object AppConfigKey : Key 117 | ``` 118 | 119 | ### 3. Use Queries 120 | 121 | Use the `useQuery` hook with your type-safe keys: 122 | 123 | ```kotlin 124 | @Composable 125 | fun UserProfile(userId: Long) { 126 | val queryState by useQuery( 127 | key = UserKey(userId), 128 | queryFn = { 129 | // Your async operation 130 | fetchUserFromApi(userId) 131 | } 132 | ) 133 | 134 | when (val state = queryState.dataState) { 135 | is DataState.Pending -> Text("Loading...") 136 | is DataState.Error -> Text("Error: ${state.message}") 137 | is DataState.Success -> Text("User: ${state.data.name}") 138 | } 139 | } 140 | ``` 141 | 142 | ## Features 143 | 144 | - **🔒 Type Safety**: Query keys are strongly-typed data classes, preventing typos and enabling IDE support 145 | - **🚀 Automatic Caching**: Queries are cached automatically and shared across components 146 | - **♻️ Smart Invalidation**: Type-safe cache invalidation with `invalidateQuery()` and `invalidateQueriesOfType()` 147 | - **⚡ Request Deduplication**: Multiple components requesting the same data share a single network request 148 | - **🎯 Compose Integration**: Built specifically for Jetpack Compose with reactive state updates 149 | 150 | ## Cache Management 151 | 152 | ### Invalidate Specific Query 153 | ```kotlin 154 | val queryClient = useQueryClient() 155 | 156 | // Invalidate a specific query 157 | queryClient.invalidateQuery(UserKey(123)) 158 | ``` 159 | 160 | ### Invalidate by Type 161 | ```kotlin 162 | // Invalidate all user queries 163 | queryClient.invalidateQueriesOfType() 164 | ``` 165 | 166 | ## Example Usage 167 | 168 | ### Basic Query with HTTP API 169 | 170 | ```kotlin 171 | data class RepoKey(val repoPath: String) : Key 172 | 173 | @Composable 174 | fun GitHubRepoExample() { 175 | val queryState by useQuery( 176 | key = RepoKey("pavi2410/useCompose"), 177 | queryFn = { 178 | // Your HTTP client call 179 | httpClient.get("https://api.github.com/repos/pavi2410/useCompose") 180 | .body() 181 | } 182 | ) 183 | 184 | when (val state = queryState.dataState) { 185 | is DataState.Pending -> Text("Loading repository...") 186 | is DataState.Error -> Text("Error: ${state.message}") 187 | is DataState.Success -> { 188 | val repo = state.data 189 | Column { 190 | Text("Name: ${repo.full_name}") 191 | Text("Stars: ${repo.stargazers_count}") 192 | } 193 | } 194 | } 195 | } 196 | ``` 197 | 198 | ### Multiple Queries with Different Keys 199 | 200 | ```kotlin 201 | @Composable 202 | fun PostsAndCommentsExample(postId: Int) { 203 | // Each query is cached independently 204 | val postsQuery by useQuery( 205 | key = PostsListKey(), 206 | queryFn = { fetchAllPosts() } 207 | ) 208 | 209 | val postDetailQuery by useQuery( 210 | key = PostDetailKey(postId), 211 | queryFn = { fetchPost(postId) } 212 | ) 213 | 214 | // UI renders both queries... 215 | } 216 | ``` 217 | 218 | ## API Reference 219 | 220 | ### Key Interface 221 | 222 | The foundation of type-safe queries. Implement this interface on data classes: 223 | 224 | ```kotlin 225 | interface Key 226 | 227 | // Examples: 228 | data class UserKey(val userId: Long) : Key 229 | data class PostKey(val postId: String) : Key 230 | object AllPostsKey : Key // For singleton keys 231 | ``` 232 | 233 | ### useQuery 234 | 235 | The main hook for data fetching with caching: 236 | 237 | ```kotlin 238 | @Composable 239 | fun useQuery( 240 | key: Key, 241 | queryFn: suspend CoroutineScope.() -> T, 242 | options: QueryOptions = QueryOptions.Default 243 | ): State> 244 | ``` 245 | 246 | **Parameters:** 247 | - `key` - Type-safe key for caching and identification 248 | - `queryFn` - Suspend function that fetches the data 249 | - `options` - Configuration options (e.g., `enabled`) 250 | 251 | ### QueryState 252 | 253 | The state returned by `useQuery`: 254 | 255 | ```kotlin 256 | data class QueryState( 257 | val fetchStatus: FetchStatus, // Idle, Fetching 258 | val dataState: DataState // Pending, Error, Success 259 | ) 260 | 261 | sealed interface DataState { 262 | object Pending : DataState 263 | data class Error(val message: String) : DataState 264 | data class Success(val data: T) : DataState 265 | } 266 | ``` 267 | 268 | ### QueryClient 269 | 270 | Central cache management: 271 | 272 | ```kotlin 273 | class QueryClient { 274 | suspend fun getQuery(key: Key, queryFn: suspend CoroutineScope.() -> T): CacheEntry 275 | suspend fun invalidateQuery(key: Key) 276 | suspend inline fun invalidateQueriesOfType() 277 | suspend fun clear() 278 | } 279 | ``` 280 | 281 | ### QueryClientProvider 282 | 283 | Compose provider for dependency injection: 284 | 285 | ```kotlin 286 | @Composable 287 | fun QueryClientProvider( 288 | client: QueryClient, 289 | content: @Composable () -> Unit 290 | ) 291 | 292 | @Composable 293 | fun useQueryClient(): QueryClient 294 | ``` 295 | 296 | ## Help Wanted 297 | 298 | This library now supports Kotlin Multiplatform! Help us extend it with more hooks and platform support (iOS, Web, etc.). 299 | 300 | ## License 301 | 302 | [MIT](https://choosealicense.com/licenses/mit/) 303 | -------------------------------------------------------------------------------- /shared/src/screens/PrefetchingExample.kt: -------------------------------------------------------------------------------- 1 | package com.pavi2410.useCompose.demo.screens 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.hoverable 6 | import androidx.compose.foundation.interaction.MutableInteractionSource 7 | import androidx.compose.foundation.interaction.collectIsHoveredAsState 8 | import androidx.compose.foundation.layout.Box 9 | import androidx.compose.foundation.layout.Column 10 | import androidx.compose.foundation.layout.Row 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.lazy.LazyColumn 14 | import androidx.compose.foundation.lazy.items 15 | import androidx.compose.foundation.shape.RoundedCornerShape 16 | import androidx.compose.material.MaterialTheme 17 | import androidx.compose.material.Text 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.LaunchedEffect 20 | import androidx.compose.runtime.getValue 21 | import androidx.compose.runtime.mutableIntStateOf 22 | import androidx.compose.runtime.mutableStateOf 23 | import androidx.compose.runtime.remember 24 | import androidx.compose.runtime.rememberCoroutineScope 25 | import androidx.compose.runtime.setValue 26 | import androidx.compose.ui.Modifier 27 | import androidx.compose.ui.graphics.Color 28 | import androidx.compose.ui.text.font.FontWeight 29 | import androidx.compose.ui.unit.dp 30 | import com.pavi2410.useCompose.demo.common.httpClient 31 | import com.pavi2410.useCompose.query.DataState 32 | import com.pavi2410.useCompose.query.core.Key 33 | import com.pavi2410.useCompose.query.core.QueryOptions 34 | import com.pavi2410.useCompose.query.useQuery 35 | import com.pavi2410.useCompose.query.useQueryClient 36 | import io.ktor.client.call.body 37 | import io.ktor.client.request.get 38 | import kotlinx.coroutines.delay 39 | import kotlinx.coroutines.launch 40 | import kotlinx.serialization.Serializable 41 | 42 | @Serializable 43 | data class CharactersResponse( 44 | val results: List, 45 | ) 46 | 47 | @Serializable 48 | data class Character( 49 | val id: Int, 50 | val name: String, 51 | val status: String, 52 | val species: String, 53 | val gender: String, 54 | val origin: CharacterLocation, 55 | val location: CharacterLocation, 56 | val image: String, 57 | ) 58 | 59 | @Serializable 60 | data class CharacterLocation( 61 | val name: String, 62 | val url: String, 63 | ) 64 | 65 | data class CharactersKey(val type: String = "all") : Key 66 | data class CharacterKey(val characterId: Int) : Key 67 | 68 | suspend fun getCharacters(): CharactersResponse { 69 | return httpClient.get("https://rickandmortyapi.com/api/character/").body() 70 | } 71 | 72 | suspend fun getCharacter(characterId: Int): Character { 73 | return httpClient.get("https://rickandmortyapi.com/api/character/$characterId").body() 74 | } 75 | 76 | @Composable 77 | fun PrefetchingExample(modifier: Modifier = Modifier) { 78 | val queryClient = useQueryClient() 79 | val coroutineScope = rememberCoroutineScope() 80 | var selectedCharId by remember { mutableIntStateOf(1) } 81 | var rerender by remember { mutableIntStateOf(0) } // Force recomposition trigger 82 | 83 | val charactersQuery by useQuery( 84 | key = CharactersKey(), 85 | queryFn = { getCharacters() } 86 | ) 87 | 88 | val characterQuery by useQuery( 89 | key = CharacterKey(selectedCharId), 90 | queryFn = { getCharacter(selectedCharId) } 91 | ) 92 | 93 | Column(modifier = modifier.padding(16.dp)) { 94 | Text( 95 | text = "Hovering over a character will prefetch it, and when it's been " + 96 | "prefetched it will turn bold. Clicking on a prefetched character " + 97 | "will show their stats on the right immediately.", 98 | modifier = Modifier.padding(bottom = 16.dp) 99 | ) 100 | 101 | Row(modifier = Modifier.fillMaxWidth()) { 102 | // Left half - Character List 103 | Column( 104 | modifier = Modifier 105 | .weight(1f) 106 | .padding(end = 16.dp) 107 | ) { 108 | Text( 109 | text = "Characters", 110 | style = MaterialTheme.typography.h4, 111 | modifier = Modifier.padding(bottom = 16.dp) 112 | ) 113 | 114 | when (val state = charactersQuery.dataState) { 115 | is DataState.Pending -> { 116 | Text("Loading...") 117 | } 118 | 119 | is DataState.Error -> { 120 | Text("Error: ${state.message}", color = Color.Red) 121 | } 122 | 123 | is DataState.Success -> { 124 | LazyColumn { 125 | items(state.data.results) { character -> 126 | CharacterItemWithCache( 127 | character = character, 128 | rerender = rerender, 129 | queryClient = queryClient, 130 | isSelected = character.id == selectedCharId, 131 | onHover = { 132 | coroutineScope.launch { 133 | queryClient.prefetchQuery( 134 | key = CharacterKey(character.id), 135 | queryFn = { getCharacter(character.id) }, 136 | options = QueryOptions(staleTime = 10 * 1000) // 10 seconds 137 | ) 138 | // Trigger recomposition to update bold styling 139 | delay(1) 140 | rerender++ 141 | } 142 | }, 143 | onClick = { 144 | selectedCharId = character.id 145 | } 146 | ) 147 | } 148 | } 149 | } 150 | } 151 | } 152 | 153 | // Right half - Character Details 154 | Column( 155 | modifier = Modifier 156 | .weight(1f) 157 | .padding(start = 16.dp) 158 | ) { 159 | Text( 160 | text = "Selected Character", 161 | style = MaterialTheme.typography.h4, 162 | modifier = Modifier.padding(bottom = 16.dp) 163 | ) 164 | 165 | when (val charState = characterQuery.dataState) { 166 | is DataState.Pending -> { 167 | Text("Loading...") 168 | } 169 | 170 | is DataState.Error -> { 171 | Text("Error: ${charState.message}", color = Color.Red) 172 | } 173 | 174 | is DataState.Success -> { 175 | CharacterDetails(charState.data) 176 | } 177 | } 178 | } 179 | } 180 | } 181 | } 182 | 183 | @Composable 184 | fun CharacterItemWithCache( 185 | character: Character, 186 | rerender: Int, // Used as a key to trigger cache re-checks 187 | queryClient: com.pavi2410.useCompose.query.core.QueryClient, 188 | isSelected: Boolean = false, 189 | onHover: () -> Unit, 190 | onClick: () -> Unit, 191 | ) { 192 | val interactionSource = remember { MutableInteractionSource() } 193 | val isHovered by interactionSource.collectIsHoveredAsState() 194 | var isPrefetched by remember { mutableStateOf(false) } 195 | 196 | // Check cache status when rerender changes 197 | LaunchedEffect(character.id, rerender) { 198 | val cachedData = queryClient.getQueryData(CharacterKey(character.id)) 199 | isPrefetched = cachedData != null 200 | } 201 | 202 | LaunchedEffect(isHovered) { 203 | if (isHovered) { 204 | onHover() 205 | } 206 | } 207 | 208 | Box( 209 | modifier = Modifier 210 | .fillMaxWidth() 211 | .padding(vertical = 4.dp) 212 | .background( 213 | color = when { 214 | isSelected -> MaterialTheme.colors.primary.copy(alpha = 0.2f) 215 | isHovered -> Color.Gray.copy(alpha = 0.1f) 216 | else -> Color.Transparent 217 | }, 218 | shape = RoundedCornerShape(4.dp) 219 | ) 220 | .hoverable(interactionSource = interactionSource) 221 | .clickable { onClick() } 222 | .padding(12.dp) 223 | ) { 224 | Text( 225 | text = "${character.id} - ${character.name}", 226 | fontWeight = if (isPrefetched) FontWeight.Bold else FontWeight.Normal, 227 | color = if (isSelected) MaterialTheme.colors.primary else Color.Unspecified 228 | ) 229 | } 230 | } 231 | 232 | @Composable 233 | fun CharacterDetails(character: Character) { 234 | Column { 235 | Text( 236 | text = "Name: ${character.name}", 237 | style = MaterialTheme.typography.body1, 238 | modifier = Modifier.padding(vertical = 2.dp) 239 | ) 240 | Text( 241 | text = "Status: ${character.status}", 242 | style = MaterialTheme.typography.body1, 243 | modifier = Modifier.padding(vertical = 2.dp) 244 | ) 245 | Text( 246 | text = "Species: ${character.species}", 247 | style = MaterialTheme.typography.body1, 248 | modifier = Modifier.padding(vertical = 2.dp) 249 | ) 250 | Text( 251 | text = "Gender: ${character.gender}", 252 | style = MaterialTheme.typography.body1, 253 | modifier = Modifier.padding(vertical = 2.dp) 254 | ) 255 | Text( 256 | text = "Origin: ${character.origin.name}", 257 | style = MaterialTheme.typography.body1, 258 | modifier = Modifier.padding(vertical = 2.dp) 259 | ) 260 | Text( 261 | text = "Location: ${character.location.name}", 262 | style = MaterialTheme.typography.body1, 263 | modifier = Modifier.padding(vertical = 2.dp) 264 | ) 265 | } 266 | } -------------------------------------------------------------------------------- /amper.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | @rem 4 | @rem Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. 5 | @rem 6 | 7 | @rem Possible environment variables: 8 | @rem AMPER_DOWNLOAD_ROOT Maven repository to download Amper dist from 9 | @rem default: https://packages.jetbrains.team/maven/p/amper/amper 10 | @rem AMPER_JRE_DOWNLOAD_ROOT Url prefix to download Amper JRE from. 11 | @rem default: https:/ 12 | @rem AMPER_BOOTSTRAP_CACHE_DIR Cache directory to store extracted JRE and Amper distribution 13 | @rem AMPER_JAVA_HOME JRE to run Amper itself (optional, does not affect compilation) 14 | @rem AMPER_JAVA_OPTIONS JVM options to pass to the JVM running Amper (does not affect the user's application) 15 | @rem AMPER_NO_WELCOME_BANNER Disables the first-run welcome message if set to a non-empty value 16 | 17 | setlocal 18 | 19 | @rem The version of the Amper distribution to provision and use 20 | set amper_version=0.9.2 21 | @rem Establish chain of trust from here by specifying exact checksum of Amper distribution to be run 22 | set amper_sha256=33304bb301d0c5276ad4aa718ce8a10486fda4be45179779497d2036666bb16f 23 | 24 | if not defined AMPER_DOWNLOAD_ROOT set AMPER_DOWNLOAD_ROOT=https://packages.jetbrains.team/maven/p/amper/amper 25 | if not defined AMPER_JRE_DOWNLOAD_ROOT set AMPER_JRE_DOWNLOAD_ROOT=https:/ 26 | if not defined AMPER_BOOTSTRAP_CACHE_DIR set AMPER_BOOTSTRAP_CACHE_DIR=%LOCALAPPDATA%\JetBrains\Amper 27 | @rem remove trailing \ if present 28 | if [%AMPER_BOOTSTRAP_CACHE_DIR:~-1%] EQU [\] set AMPER_BOOTSTRAP_CACHE_DIR=%AMPER_BOOTSTRAP_CACHE_DIR:~0,-1% 29 | 30 | goto :after_function_declarations 31 | 32 | REM ********** Download and extract any zip or .tar.gz archive ********** 33 | 34 | :download_and_extract 35 | setlocal 36 | 37 | set moniker=%~1 38 | set url=%~2 39 | set target_dir=%~3 40 | set sha=%~4 41 | set sha_size=%~5 42 | set show_banner_on_cache_miss=%~6 43 | 44 | set flag_file=%target_dir%\.flag 45 | if exist "%flag_file%" ( 46 | set /p current_flag=<"%flag_file%" 47 | if "%current_flag%" == "%sha%" exit /b 48 | ) 49 | 50 | @rem This multiline string is actually passed as a single line to powershell, meaning #-comments are not possible. 51 | @rem So here are a few comments about the code below: 52 | @rem - we need to support both .zip and .tar.gz archives (for the Amper distribution and the JRE) 53 | @rem - tar should be present in all Windows machines since 2018 (and usable from both cmd and powershell) 54 | @rem - tar requires the destination dir to exist 55 | @rem - We use (New-Object Net.WebClient).DownloadFile instead of Invoke-WebRequest for performance. See the issue 56 | @rem https://github.com/PowerShell/PowerShell/issues/16914, which is still not fixed in Windows PowerShell 5.1 57 | @rem - DownloadFile requires the directories in the destination file's path to exist 58 | set download_and_extract_ps1= ^ 59 | Set-StrictMode -Version 3.0; ^ 60 | $ErrorActionPreference = 'Stop'; ^ 61 | ^ 62 | $createdNew = $false; ^ 63 | $lock = New-Object System.Threading.Mutex($true, ('Global\amper-bootstrap.' + '%target_dir%'.GetHashCode().ToString()), [ref]$createdNew); ^ 64 | if (-not $createdNew) { ^ 65 | Write-Host 'Another Amper instance is bootstrapping. Waiting for our turn...'; ^ 66 | [void]$lock.WaitOne(); ^ 67 | } ^ 68 | ^ 69 | try { ^ 70 | if ((Get-Content '%flag_file%' -ErrorAction Ignore) -ne '%sha%') { ^ 71 | if (('%show_banner_on_cache_miss%' -eq 'true') -and [string]::IsNullOrEmpty('%AMPER_NO_WELCOME_BANNER%')) { ^ 72 | Write-Host '*** Welcome to Amper v.%amper_version%! ***'; ^ 73 | Write-Host ''; ^ 74 | Write-Host 'This is the first run of this version, so we need to download the actual Amper distribution.'; ^ 75 | Write-Host 'Please give us a few seconds now, subsequent runs will be faster.'; ^ 76 | Write-Host ''; ^ 77 | } ^ 78 | $temp_file = '%AMPER_BOOTSTRAP_CACHE_DIR%\' + [System.IO.Path]::GetRandomFileName(); ^ 79 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; ^ 80 | Write-Host 'Downloading %moniker%...'; ^ 81 | [void](New-Item '%AMPER_BOOTSTRAP_CACHE_DIR%' -ItemType Directory -Force); ^ 82 | if (Get-Command curl.exe -errorAction SilentlyContinue) { ^ 83 | curl.exe -L --silent --show-error --fail --output $temp_file '%url%'; ^ 84 | } else { ^ 85 | (New-Object Net.WebClient).DownloadFile('%url%', $temp_file); ^ 86 | } ^ 87 | ^ 88 | $actualSha = (Get-FileHash -Algorithm SHA%sha_size% -Path $temp_file).Hash.ToString(); ^ 89 | if ($actualSha -ne '%sha%') { ^ 90 | $writeErr = if ($Host.Name -eq 'ConsoleHost') { [Console]::Error.WriteLine } else { $host.ui.WriteErrorLine } ^ 91 | $writeErr.Invoke(\"ERROR: Checksum mismatch for $temp_file (downloaded from %url%): expected checksum %sha% but got $actualSha\"); ^ 92 | exit 1; ^ 93 | } ^ 94 | ^ 95 | if (Test-Path '%target_dir%') { ^ 96 | Remove-Item '%target_dir%' -Recurse; ^ 97 | } ^ 98 | if ($temp_file -like '*.zip') { ^ 99 | Add-Type -A 'System.IO.Compression.FileSystem'; ^ 100 | [IO.Compression.ZipFile]::ExtractToDirectory($temp_file, '%target_dir%'); ^ 101 | } else { ^ 102 | [void](New-Item '%target_dir%' -ItemType Directory -Force); ^ 103 | tar -xzf $temp_file -C '%target_dir%'; ^ 104 | } ^ 105 | Remove-Item $temp_file; ^ 106 | ^ 107 | Set-Content '%flag_file%' -Value '%sha%'; ^ 108 | Write-Host 'Download complete.'; ^ 109 | Write-Host ''; ^ 110 | } ^ 111 | } ^ 112 | finally { ^ 113 | $lock.ReleaseMutex(); ^ 114 | } 115 | 116 | rem We reset the PSModulePath in case this batch script was called from PowerShell Core 117 | rem See https://github.com/PowerShell/PowerShell/issues/18108#issuecomment-2269703022 118 | set PSModulePath= 119 | set powershell=%SystemRoot%\system32\WindowsPowerShell\v1.0\powershell.exe 120 | "%powershell%" -NonInteractive -NoProfile -NoLogo -Command %download_and_extract_ps1% 121 | if errorlevel 1 exit /b 1 122 | exit /b 0 123 | 124 | :fail 125 | echo ERROR: Amper bootstrap failed, see errors above 126 | exit /b 1 127 | 128 | :after_function_declarations 129 | 130 | REM ********** Provision Amper distribution ********** 131 | 132 | set amper_url=%AMPER_DOWNLOAD_ROOT%/org/jetbrains/amper/amper-cli/%amper_version%/amper-cli-%amper_version%-dist.tgz 133 | set amper_target_dir=%AMPER_BOOTSTRAP_CACHE_DIR%\amper-cli-%amper_version% 134 | call :download_and_extract "Amper distribution v%amper_version%" "%amper_url%" "%amper_target_dir%" "%amper_sha256%" "256" "true" 135 | if errorlevel 1 goto fail 136 | 137 | REM !! DO NOT REMOVE !! 138 | REM There is a command at the end of this line: exit /b %ERRORLEVEL% 139 | REM 140 | REM The above comment is strategically placed to compensate for a bug in the update command in Amper 0.5.0. 141 | REM During the update, the wrapper script is overwritten in-place while running. The problem is that cmd.exe doesn't 142 | REM buffer the original script as a whole, and instead reloads it after every command, and tries to resume at the same 143 | REM byte offset as before. 144 | REM In the 0.5.0 script, the java command running Amper is followed by the command 'exit /b %ERRORLEVEL%', which is 145 | REM exactly at the byte offset 6826. So, when the java command finishes, cmd.exe wants to run this exit command, but 146 | REM it first reloads the file and gets the new content (this one) before trying to run whatever is at offset 6826. 147 | REM We must place an exit command right at that offset to allow 0.5.0 to complete properly. 148 | REM Since there are version/checksum placeholders at the top of this template wrapper file, we need to dynamically 149 | REM adjust the position of the exit command, hence the padding placeholder. 150 | 151 | REM ********** Provision JRE for Amper ********** 152 | 153 | if defined AMPER_JAVA_HOME ( 154 | if not exist "%AMPER_JAVA_HOME%\bin\java.exe" ( 155 | echo Invalid AMPER_JAVA_HOME provided: cannot find %AMPER_JAVA_HOME%\bin\java.exe 156 | goto fail 157 | ) 158 | @rem If AMPER_JAVA_HOME contains "jbr-21", it means we're inheriting it from the old Amper's update command. 159 | @rem We must ignore it because Amper needs 25. 160 | if "%AMPER_JAVA_HOME%"=="%AMPER_JAVA_HOME:jbr-21=%" ( 161 | set effective_amper_java_home=%AMPER_JAVA_HOME% 162 | goto jre_provisioned 163 | ) else ( 164 | echo WARN: AMPER_JAVA_HOME will be ignored because it points to a JBR 21, which is not valid for Amper anymore. 165 | echo If you're updating from an Amper version older than 0.8.0, please ignore this message. 166 | ) 167 | ) 168 | 169 | @rem Auto-updated from syncVersions.main.kts, do not modify directly here 170 | set zulu_version=25.28.85 171 | set java_version=25.0.0 172 | if "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( 173 | set pkg_type=jdk 174 | set jre_arch=aarch64 175 | set jre_sha256=f5f6d8a913695649e8e2607fe0dc79c81953b2583013ac1fb977c63cb4935bfb 176 | ) else if "%PROCESSOR_ARCHITECTURE%"=="AMD64" ( 177 | set pkg_type=jre 178 | set jre_arch=x64 179 | set jre_sha256=d3c5db7864e6412ce3971c0b065def64942d7b0f3d02581f7f0472cac21fbba9 180 | ) else ( 181 | echo Unknown Windows architecture %PROCESSOR_ARCHITECTURE% >&2 182 | goto fail 183 | ) 184 | 185 | @rem URL for the JRE (see https://api.azul.com/metadata/v1/zulu/packages?release_status=ga&include_fields=java_package_features,os,arch,hw_bitness,abi,java_package_type,sha256_hash,size,archive_type,lib_c_type&java_version=25&os=macos,linux,win) 186 | @rem https://cdn.azul.com/zulu/bin/zulu25.28.85-ca-jre25.0.0-win_x64.zip 187 | @rem https://cdn.azul.com/zulu/bin/zulu25.28.85-ca-jdk25.0.0-win_aarch64.zip 188 | set jre_url=%AMPER_JRE_DOWNLOAD_ROOT%/cdn.azul.com/zulu/bin/zulu%zulu_version%-ca-%pkg_type%%java_version%-win_%jre_arch%.zip 189 | set jre_target_dir=%AMPER_BOOTSTRAP_CACHE_DIR%\zulu%zulu_version%-ca-%pkg_type%%java_version%-win_%jre_arch% 190 | call :download_and_extract "Amper runtime v%zulu_version%" "%jre_url%" "%jre_target_dir%" "%jre_sha256%" "256" "false" 191 | if errorlevel 1 goto fail 192 | 193 | set effective_amper_java_home= 194 | for /d %%d in ("%jre_target_dir%\*") do if exist "%%d\bin\java.exe" set effective_amper_java_home=%%d 195 | if not exist "%effective_amper_java_home%\bin\java.exe" ( 196 | echo Unable to find java.exe under %jre_target_dir% 197 | goto fail 198 | ) 199 | :jre_provisioned 200 | 201 | REM ********** Launch Amper ********** 202 | 203 | "%effective_amper_java_home%\bin\java.exe" ^ 204 | @"%amper_target_dir%\amper.args" ^ 205 | "-Damper.wrapper.dist.sha256=%amper_sha256%" ^ 206 | "-Damper.dist.path=%amper_target_dir%" ^ 207 | "-Damper.wrapper.path=%~f0" ^ 208 | %AMPER_JAVA_OPTIONS% ^ 209 | -cp "%amper_target_dir%\lib\*" ^ 210 | org.jetbrains.amper.cli.MainKt ^ 211 | %* 212 | exit /B %ERRORLEVEL% 213 | -------------------------------------------------------------------------------- /amper: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. 5 | # 6 | 7 | # Possible environment variables: 8 | # AMPER_DOWNLOAD_ROOT Maven repository to download Amper dist from. 9 | # default: https://packages.jetbrains.team/maven/p/amper/amper 10 | # AMPER_JRE_DOWNLOAD_ROOT Url prefix to download Amper JRE from. 11 | # default: https:/ 12 | # AMPER_BOOTSTRAP_CACHE_DIR Cache directory to store extracted JRE and Amper distribution 13 | # AMPER_JAVA_HOME JRE to run Amper itself (optional, does not affect compilation) 14 | # AMPER_JAVA_OPTIONS JVM options to pass to the JVM running Amper (does not affect the user's application) 15 | # AMPER_NO_WELCOME_BANNER Disables the first-run welcome message if set to a non-empty value 16 | 17 | set -e -u 18 | 19 | # The version of the Amper distribution to provision and use 20 | amper_version=0.9.2 21 | # Establish chain of trust from here by specifying exact checksum of Amper distribution to be run 22 | amper_sha256=33304bb301d0c5276ad4aa718ce8a10486fda4be45179779497d2036666bb16f 23 | 24 | AMPER_DOWNLOAD_ROOT="${AMPER_DOWNLOAD_ROOT:-https://packages.jetbrains.team/maven/p/amper/amper}" 25 | AMPER_JRE_DOWNLOAD_ROOT="${AMPER_JRE_DOWNLOAD_ROOT:-https:/}" 26 | 27 | die() { 28 | echo >&2 29 | echo "$@" >&2 30 | echo >&2 31 | exit 1 32 | } 33 | 34 | download_and_extract() { 35 | moniker="$1" 36 | file_url="$2" 37 | file_sha="$3" 38 | sha_size="$4" 39 | cache_dir="$5" 40 | extract_dir="$6" 41 | show_banner_on_cache_miss="$7" 42 | 43 | if [ -e "$extract_dir/.flag" ] && [ "$(cat "$extract_dir/.flag")" = "${file_sha}" ]; then 44 | # Everything is up-to-date in $extract_dir, do nothing 45 | return 0; 46 | fi 47 | 48 | mkdir -p "$cache_dir" 49 | 50 | # Take a lock for the download of this file 51 | short_sha=$(echo "$file_sha" | cut -c1-32) # cannot use the ${short_sha:0:32} syntax in regular /bin/sh 52 | download_lock_file="$cache_dir/download-${short_sha}.lock" 53 | process_lock_file="$cache_dir/download-${short_sha}.$$.lock" 54 | echo $$ >"$process_lock_file" 55 | while ! ln "$process_lock_file" "$download_lock_file" 2>/dev/null; do 56 | lock_owner=$(cat "$download_lock_file" 2>/dev/null || true) 57 | if [ -n "$lock_owner" ] && ps -p "$lock_owner" >/dev/null; then 58 | echo "Another Amper instance (pid $lock_owner) is downloading $moniker. Awaiting the result..." 59 | sleep 1 60 | elif [ -n "$lock_owner" ] && [ "$(cat "$download_lock_file" 2>/dev/null)" = "$lock_owner" ]; then 61 | rm -f "$download_lock_file" 62 | # We don't want to simply loop again here, because multiple concurrent processes may face this at the same time, 63 | # which means the 'rm' command above from another script could delete our new valid lock file. Instead, we just 64 | # ask the user to try again. This doesn't 100% eliminate the race, but the probability of issues is drastically 65 | # reduced because it would involve 4 processes with perfect timing. We can revisit this later. 66 | die "Another Amper instance (pid $lock_owner) locked the download of $moniker, but is no longer running. The lock file is now removed, please try again." 67 | fi 68 | done 69 | 70 | # shellcheck disable=SC2064 71 | trap "rm -f \"$download_lock_file\"" EXIT 72 | rm -f "$process_lock_file" 73 | 74 | unlock_and_cleanup() { 75 | rm -f "$download_lock_file" 76 | trap - EXIT 77 | return 0 78 | } 79 | 80 | if [ -e "$extract_dir/.flag" ] && [ "$(cat "$extract_dir/.flag")" = "${file_sha}" ]; then 81 | # Everything is up-to-date in $extract_dir, just release the lock 82 | unlock_and_cleanup 83 | return 0; 84 | fi 85 | 86 | if [ "$show_banner_on_cache_miss" = "true" ] && [ -z "${AMPER_NO_WELCOME_BANNER:-}" ]; then 87 | echo 88 | echo ' _____ Welcome to ' 89 | echo ' /:::::| ____ ___ ____ ____ __ ___ ' 90 | echo ' /::/|::| |::::\_|:::\ |:::::\ /::::\ |::|/:::| ' 91 | echo ' /::/ |::| |::|\:::|\::\ |::|\::\ /:/__\:\ |:::/ ' 92 | echo ' /::/__|::| |::| |::| |::| |::| |::|:::::::/ |::| ' 93 | echo ' /:::::::::| |::| |::| |::| |::|/::/ \::\__ |::| ' 94 | echo ' /::/ |::| |::| |::| |::| |:::::/ \::::| |::| ' 95 | echo ' |::| ' 96 | echo " |::| v.$amper_version " 97 | echo 98 | echo "This is the first run of this version, so we need to download the actual Amper distribution." 99 | echo "Please give us a few seconds, subsequent runs will be faster." 100 | echo 101 | fi 102 | 103 | echo "Downloading $moniker..." 104 | 105 | temp_file="$cache_dir/download-file-$$.bin" 106 | rm -f "$temp_file" 107 | if command -v curl >/dev/null 2>&1; then 108 | if [ -t 1 ]; then CURL_PROGRESS="--progress-bar"; else CURL_PROGRESS="--silent --show-error"; fi 109 | # shellcheck disable=SC2086 110 | curl $CURL_PROGRESS -L --fail --retry 5 --connect-timeout 30 --output "${temp_file}" "$file_url" 111 | elif command -v wget >/dev/null 2>&1; then 112 | if [ -t 1 ]; then WGET_PROGRESS=""; else WGET_PROGRESS="-nv"; fi 113 | wget $WGET_PROGRESS --tries=5 --connect-timeout=30 --read-timeout=120 -O "${temp_file}" "$file_url" 114 | else 115 | die "ERROR: Please install 'wget' or 'curl', as Amper needs one of them to download $moniker" 116 | fi 117 | 118 | check_sha "$file_url" "$temp_file" "$file_sha" "$sha_size" 119 | 120 | rm -rf "$extract_dir" 121 | mkdir -p "$extract_dir" 122 | 123 | case "$file_url" in 124 | *".zip") 125 | if command -v unzip >/dev/null 2>&1; then 126 | unzip -q "$temp_file" -d "$extract_dir" 127 | else 128 | die "ERROR: Please install 'unzip', as Amper needs it to extract $moniker" 129 | fi ;; 130 | *) 131 | if command -v tar >/dev/null 2>&1; then 132 | tar -x -f "$temp_file" -C "$extract_dir" 133 | else 134 | die "ERROR: Please install 'tar', as Amper needs it to extract $moniker" 135 | fi ;; 136 | esac 137 | 138 | rm -f "$temp_file" 139 | 140 | echo "$file_sha" >"$extract_dir/.flag" 141 | 142 | # Unlock and cleanup the lock file 143 | unlock_and_cleanup 144 | 145 | echo "Download complete." 146 | echo 147 | } 148 | 149 | # usage: check_sha SOURCE_MONIKER FILE SHA_CHECKSUM SHA_SIZE 150 | # $1 SOURCE_MONIKER (e.g. url) 151 | # $2 FILE 152 | # $3 SHA hex string 153 | # $4 SHA size in bits (256, 512, ...) 154 | check_sha() { 155 | sha_size=$4 156 | if command -v shasum >/dev/null 2>&1; then 157 | echo "$3 *$2" | shasum -a "$sha_size" --status -c || { 158 | die "ERROR: Checksum mismatch for $2 (downloaded from $1): expected checksum $3 but got $(shasum --binary -a "$sha_size" "$2" | awk '{print $1}')" 159 | } 160 | return 0 161 | fi 162 | 163 | shaNsumCommand="sha${sha_size}sum" 164 | if command -v "$shaNsumCommand" >/dev/null 2>&1; then 165 | echo "$3 *$2" | $shaNsumCommand -w -c || { 166 | die "ERROR: Checksum mismatch for $2 (downloaded from $1): expected checksum $3 but got $($shaNsumCommand "$2" | awk '{print $1}')" 167 | } 168 | return 0 169 | fi 170 | 171 | echo "Both 'shasum' and 'sha${sha_size}sum' utilities are missing. Please install one of them" 172 | return 1 173 | } 174 | 175 | # ********** System detection ********** 176 | 177 | kernelName=$(uname -s) 178 | arch=$(uname -m) 179 | case "$kernelName" in 180 | Darwin* ) 181 | simpleOs="macos" 182 | jre_os="macosx" 183 | jre_archive_type="tar.gz" 184 | default_amper_cache_dir="$HOME/Library/Caches/JetBrains/Amper" 185 | ;; 186 | Linux* ) 187 | simpleOs="linux" 188 | jre_os="linux" 189 | jre_archive_type="tar.gz" 190 | default_amper_cache_dir="$HOME/.cache/JetBrains/Amper" 191 | # If linux runs in 32-bit mode, we want the "fake" 32-bit architecture, not the real hardware, 192 | # because in this mode linux cannot run 64-bit binaries. 193 | # shellcheck disable=SC2046 194 | arch=$(linux$(getconf LONG_BIT) uname -m) 195 | ;; 196 | CYGWIN* | MSYS* | MINGW* ) 197 | simpleOs="windows" 198 | jre_os="win" 199 | jre_archive_type=zip 200 | if command -v cygpath >/dev/null 2>&1; then 201 | default_amper_cache_dir=$(cygpath -u "$LOCALAPPDATA\JetBrains\Amper") 202 | else 203 | die "The 'cypath' command is not available, but Amper needs it. Use amper.bat instead, or try a Cygwin or MSYS environment." 204 | fi 205 | ;; 206 | *) 207 | die "Unsupported platform $kernelName" 208 | ;; 209 | esac 210 | 211 | amper_cache_dir="${AMPER_BOOTSTRAP_CACHE_DIR:-$default_amper_cache_dir}" 212 | 213 | # ********** Provision Amper distribution ********** 214 | 215 | amper_url="$AMPER_DOWNLOAD_ROOT/org/jetbrains/amper/amper-cli/$amper_version/amper-cli-$amper_version-dist.tgz" 216 | amper_target_dir="$amper_cache_dir/amper-cli-$amper_version" 217 | download_and_extract "Amper distribution v$amper_version" "$amper_url" "$amper_sha256" 256 "$amper_cache_dir" "$amper_target_dir" "true" 218 | 219 | # ********** Provision JRE for Amper ********** 220 | 221 | if [ "x${AMPER_JAVA_HOME:-}" = "x" ]; then 222 | case $arch in 223 | x86_64 | x64) jre_arch="x64" ;; 224 | aarch64 | arm64) jre_arch="aarch64" ;; 225 | *) die "Unsupported architecture $arch" ;; 226 | esac 227 | 228 | # Auto-updated from syncVersions.main.kts, do not modify directly here 229 | zulu_version=25.28.85 230 | java_version=25.0.0 231 | 232 | pkg_type=jre 233 | platform="$jre_os $jre_arch" 234 | case $platform in 235 | "macosx x64") jre_sha256=a73455c80413daa31af6b09589e3655bb8b8b91e4aa884ca7c91dc5552b9e974 ;; 236 | "macosx aarch64") jre_sha256=37316ebea9709eb4f8bc58f0ddd2f58e53720d3e7df6f78c64125915b44d322d ;; 237 | "linux x64") jre_sha256=807e96e43db00af3390a591ed40f2c8c35f7f475fb14b6061dfb19db33702cba ;; 238 | "linux aarch64") jre_sha256=ad75e426e3f101cfa018f65fde07d82b10337d4f85250ca988474d59891c5f50 ;; 239 | "win x64") jre_sha256=d3c5db7864e6412ce3971c0b065def64942d7b0f3d02581f7f0472cac21fbba9 ;; 240 | "win aarch64") jre_sha256=f5f6d8a913695649e8e2607fe0dc79c81953b2583013ac1fb977c63cb4935bfb; pkg_type=jdk ;; 241 | *) die "Unsupported platform $platform" ;; 242 | esac 243 | 244 | # URL for the JRE (see https://api.azul.com/metadata/v1/zulu/packages?release_status=ga&include_fields=java_package_features,os,arch,hw_bitness,abi,java_package_type,sha256_hash,size,archive_type,lib_c_type&java_version=25&os=macos,linux,win) 245 | # https://cdn.azul.com/zulu/bin/zulu25.28.85-ca-jre25.0.0-macosx_aarch64.tar.gz 246 | # https://cdn.azul.com/zulu/bin/zulu25.28.85-ca-jre25.0.0-linux_x64.tar.gz 247 | jre_url="$AMPER_JRE_DOWNLOAD_ROOT/cdn.azul.com/zulu/bin/zulu$zulu_version-ca-$pkg_type$java_version-${jre_os}_$jre_arch.$jre_archive_type" 248 | jre_target_dir="$amper_cache_dir/zulu$zulu_version-ca-$pkg_type$java_version-${jre_os}_$jre_arch" 249 | 250 | download_and_extract "Amper runtime v$zulu_version" "$jre_url" "$jre_sha256" 256 "$amper_cache_dir" "$jre_target_dir" "false" 251 | 252 | effective_amper_java_home= 253 | for d in "$jre_target_dir" "$jre_target_dir"/* "$jre_target_dir"/Contents/Home "$jre_target_dir"/*/Contents/Home; do 254 | if [ -e "$d/bin/java" ]; then 255 | effective_amper_java_home="$d" 256 | fi 257 | done 258 | 259 | if [ "x${effective_amper_java_home:-}" = "x" ]; then 260 | die "Unable to find bin/java under $jre_target_dir" 261 | fi 262 | else 263 | effective_amper_java_home="$AMPER_JAVA_HOME" 264 | fi 265 | 266 | java_exe="$effective_amper_java_home/bin/java" 267 | if [ '!' -x "$java_exe" ]; then 268 | die "Unable to find bin/java executable at $java_exe" 269 | fi 270 | 271 | # ********** Script path detection ********** 272 | 273 | # We might need to resolve symbolic links here 274 | wrapper_path=$(realpath "$0") 275 | 276 | # ********** Launch Amper ********** 277 | 278 | # In this section we construct the command line by prepending arguments from biggest to lowest precedence: 279 | # 1. basic main class, CLI arguments, and classpath 280 | # 2. user JVM args (AMPER_JAVA_OPTIONS) 281 | # 3. default JVM args (prepended last, which means they appear first, so they are overridden by user args) 282 | 283 | # 1. Prepend basic launch arguments 284 | if [ "$simpleOs" = "windows" ]; then 285 | # Can't cygpath the '*' so it has to be outside 286 | classpath="$(cygpath -w "$amper_target_dir")\lib\*" 287 | else 288 | classpath="$amper_target_dir/lib/*" 289 | fi 290 | 291 | set -- -cp "$classpath" org.jetbrains.amper.cli.MainKt "$@" 292 | 293 | # 2. Prepend user JVM args from AMPER_JAVA_OPTS 294 | # 295 | # We use "xargs" to parse quoted JVM args from inside AMPER_JAVA_OPTS. 296 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 297 | # 298 | # In Bash we could simply go: 299 | # 300 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 301 | # set -- "${ARGS[@]}" "$@" 302 | # 303 | # but POSIX shell has neither arrays nor command substitution, so instead we 304 | # post-process each arg (as a line of input to sed) to backslash-escape any 305 | # character that might be a shell metacharacter, then use eval to reverse 306 | # that process (while maintaining the separation between arguments), and wrap 307 | # the whole thing up as a single "set" statement. 308 | # 309 | # This will of course break if any of these variables contains a newline or 310 | # an unmatched quote. 311 | if [ -n "${AMPER_JAVA_OPTIONS:-}" ]; then 312 | eval "set -- $( 313 | printf '%s\n' "$AMPER_JAVA_OPTIONS" | 314 | xargs -n1 | 315 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 316 | tr '\n' ' ' 317 | )" '"$@"' 318 | fi 319 | 320 | # 3. Prepend default JVM args 321 | set -- \ 322 | @"$amper_target_dir/amper.args" \ 323 | "-Damper.wrapper.dist.sha256=$amper_sha256" \ 324 | "-Damper.dist.path=$amper_target_dir" \ 325 | "-Damper.wrapper.path=$wrapper_path" \ 326 | "$@" 327 | 328 | # Then we can launch with the overridden $@ arguments 329 | exec "$java_exe" "$@" 330 | -------------------------------------------------------------------------------- /docs/query-dsl-api-design.md: -------------------------------------------------------------------------------- 1 | # Query DSL API Design 2 | 3 | ## Overview 4 | 5 | This document outlines the API design for a Query Definition DSL that provides clean separation of data fetching logic from UI components while maintaining all the power of React Query/TanStack Query. 6 | 7 | ## Core Principles 8 | 9 | 1. **Separation of Concerns** - Data fetching logic lives outside UI components 10 | 2. **Type Safety** - Strongly typed keys, query functions, and return types 11 | 3. **Developer Experience** - Natural Kotlin DSL with property setters 12 | 4. **Feature Parity** - Support all React Query features (caching, refetching, invalidation, optimistic updates) 13 | 14 | ## Query Definition API 15 | 16 | ### Basic Query Definition 17 | 18 | ```kotlin 19 | // Define a query using the DSL 20 | val getAllPosts = query> { 21 | key = PostsListKey() 22 | queryFn = { 23 | httpClient.get("/api/posts").body() 24 | } 25 | staleTime = 5.minutes 26 | cacheTime = 10.minutes 27 | refetchOnWindowFocus = true 28 | refetchOnReconnect = true 29 | retry = RetryConfig.Default 30 | } 31 | 32 | // Use in a Composable 33 | @Composable 34 | fun PostsList() { 35 | val queryState by useQuery(getAllPosts) 36 | 37 | when (val state = queryState.dataState) { 38 | is DataState.Pending -> LoadingIndicator() 39 | is DataState.Error -> ErrorMessage(state.message) 40 | is DataState.Success -> PostList(state.data) 41 | } 42 | } 43 | ``` 44 | 45 | ### Dynamic Query Definition 46 | 47 | ```kotlin 48 | // Query with parameters 49 | fun getPostById(postId: Int) = query { 50 | key = PostDetailKey(postId) 51 | queryFn = { 52 | httpClient.get("/api/posts/$postId").body() 53 | } 54 | staleTime = 30.seconds 55 | cacheTime = 5.minutes 56 | } 57 | 58 | // Use in a Composable 59 | @Composable 60 | fun PostDetail(postId: Int) { 61 | val queryState by useQuery(getPostById(postId)) 62 | // ... 63 | } 64 | ``` 65 | 66 | ### Query Builder Properties 67 | 68 | ```kotlin 69 | class QueryBuilder { 70 | // Required properties 71 | lateinit var key: Key 72 | lateinit var queryFn: suspend CoroutineScope.() -> T 73 | 74 | // Optional properties with defaults 75 | var enabled: Boolean = true 76 | var staleTime: Duration = Duration.ZERO 77 | var cacheTime: Duration = 5.minutes 78 | var refetchInterval: Duration? = null 79 | var refetchOnWindowFocus: Boolean = true 80 | var refetchOnReconnect: Boolean = true 81 | var refetchOnMount: Boolean = true 82 | var retry: RetryConfig = RetryConfig.Default 83 | var suspense: Boolean = false 84 | var keepPreviousData: Boolean = false 85 | var structuralSharing: Boolean = true 86 | } 87 | ``` 88 | 89 | ## Mutation Definition API 90 | 91 | ### Basic Mutation 92 | 93 | ```kotlin 94 | val createPost = mutation { 95 | mutationFn = { request -> 96 | httpClient.post("/api/posts") { 97 | contentType(ContentType.Application.Json) 98 | setBody(request) 99 | }.body() 100 | } 101 | retry = RetryConfig(maxAttempts = 2) 102 | } 103 | 104 | // Use in a Composable 105 | @Composable 106 | fun CreatePostForm() { 107 | val mutation = useMutation(createPost) 108 | 109 | Button( 110 | onClick = { 111 | mutation.mutate( 112 | CreatePostRequest(title = "New Post", body = "Content") 113 | ) 114 | }, 115 | enabled = !mutation.isLoading 116 | ) { 117 | Text("Create Post") 118 | } 119 | } 120 | ``` 121 | 122 | ### Mutation with Optimistic Updates 123 | 124 | ```kotlin 125 | val updatePost = mutation { 126 | mutationFn = { request -> 127 | httpClient.put("/api/posts/${request.id}") { 128 | contentType(ContentType.Application.Json) 129 | setBody(request) 130 | }.body() 131 | } 132 | 133 | onMutate = { request -> 134 | // Cancel any outgoing refetches 135 | queryClient.cancelQueries(PostDetailKey(request.id)) 136 | 137 | // Snapshot the previous value 138 | val previousPost = queryClient.getQueryData(PostDetailKey(request.id)) 139 | 140 | // Optimistically update to the new value 141 | queryClient.setQueryData(PostDetailKey(request.id), request.toPost()) 142 | 143 | // Return context for rollback 144 | PreviousData(previousPost) 145 | } 146 | 147 | onError = { error, request, context -> 148 | // Rollback on error 149 | context?.previousPost?.let { 150 | queryClient.setQueryData(PostDetailKey(request.id), it) 151 | } 152 | } 153 | 154 | onSuccess = { data, request, context -> 155 | // Update with server response 156 | queryClient.setQueryData(PostDetailKey(request.id), data) 157 | queryClient.invalidateQueries(PostsListKey()) 158 | } 159 | 160 | onSettled = { data, error, request, context -> 161 | // Always refetch after error or success 162 | queryClient.invalidateQueries(PostDetailKey(request.id)) 163 | } 164 | } 165 | ``` 166 | 167 | ### Mutation Builder Properties 168 | 169 | ```kotlin 170 | class MutationBuilder { 171 | // Required property 172 | lateinit var mutationFn: suspend CoroutineScope.(variables: TVariables) -> TData 173 | 174 | // Optional callbacks 175 | var onMutate: (suspend (variables: TVariables) -> TContext?)? = null 176 | var onSuccess: (suspend (data: TData, variables: TVariables, context: TContext?) -> Unit)? = null 177 | var onError: (suspend (error: Throwable, variables: TVariables, context: TContext?) -> Unit)? = null 178 | var onSettled: (suspend (data: TData?, error: Throwable?, variables: TVariables, context: TContext?) -> Unit)? = null 179 | 180 | // Options 181 | var retry: RetryConfig = RetryConfig.Default 182 | var throwOnError: Boolean = false 183 | } 184 | ``` 185 | 186 | ## Advanced Patterns 187 | 188 | ### Dependent Queries 189 | 190 | ```kotlin 191 | // User profile query 192 | val getUserProfile = query { 193 | key = UserKey(userId) 194 | queryFn = { 195 | httpClient.get("/api/users/$userId").body() 196 | } 197 | staleTime = 10.minutes 198 | } 199 | 200 | // Posts query that depends on user 201 | fun getUserPosts(userId: Int?) = query> { 202 | key = UserPostsKey(userId ?: -1) 203 | queryFn = { 204 | httpClient.get("/api/users/$userId/posts").body() 205 | } 206 | enabled = userId != null // Only run when userId is available 207 | staleTime = 2.minutes 208 | } 209 | 210 | // Usage 211 | @Composable 212 | fun UserDashboard(userId: Int) { 213 | val userQuery by useQuery(getUserProfile) 214 | val postsQuery by useQuery(getUserPosts(userQuery.data?.id)) 215 | 216 | // postsQuery only runs after userQuery succeeds 217 | } 218 | ``` 219 | 220 | ### Parallel Queries 221 | 222 | ```kotlin 223 | @Composable 224 | fun Dashboard() { 225 | val queries = useQueries( 226 | listOf( 227 | PostQueries.all, 228 | UserQueries.all, 229 | CommentQueries.recent, 230 | StatsQueries.overview 231 | ) 232 | ) 233 | 234 | val isLoading = queries.all { it.isLoading } 235 | val hasError = queries.any { it.isError } 236 | 237 | if (isLoading) { 238 | LoadingIndicator() 239 | } else if (hasError) { 240 | ErrorMessage("Failed to load dashboard data") 241 | } else { 242 | DashboardContent( 243 | posts = queries[0].data as List, 244 | users = queries[1].data as List, 245 | comments = queries[2].data as List, 246 | stats = queries[3].data as Stats 247 | ) 248 | } 249 | } 250 | ``` 251 | 252 | ### Infinite Queries 253 | 254 | ```kotlin 255 | val getInfinitePosts = infiniteQuery { 256 | key = InfinitePostsKey() 257 | queryFn = { pageParam: Int -> 258 | httpClient.get("/api/posts") { 259 | parameter("page", pageParam) 260 | parameter("limit", 10) 261 | }.body() 262 | } 263 | getNextPageParam = { lastPage -> 264 | if (lastPage.hasNext) lastPage.page + 1 else null 265 | } 266 | getPreviousPageParam = { firstPage -> 267 | if (firstPage.page > 1) firstPage.page - 1 else null 268 | } 269 | staleTime = 30.seconds 270 | } 271 | 272 | @Composable 273 | fun InfinitePostsList() { 274 | val query = useInfiniteQuery(getInfinitePosts) 275 | 276 | LazyColumn { 277 | items(query.data?.pages?.flatMap { it.posts } ?: emptyList()) { post -> 278 | PostItem(post) 279 | } 280 | 281 | if (query.hasNextPage) { 282 | item { 283 | Button( 284 | onClick = { query.fetchNextPage() }, 285 | enabled = !query.isFetchingNextPage 286 | ) { 287 | Text(if (query.isFetchingNextPage) "Loading..." else "Load More") 288 | } 289 | } 290 | } 291 | } 292 | } 293 | ``` 294 | 295 | ### Prefetching 296 | 297 | ```kotlin 298 | @Composable 299 | fun PostsListWithPrefetch() { 300 | val queryClient = useQueryClient() 301 | val postsQuery by useQuery(PostQueries.all) 302 | 303 | LazyColumn { 304 | items(postsQuery.data ?: emptyList()) { post -> 305 | PostItem( 306 | post = post, 307 | onHover = { 308 | // Prefetch post details on hover 309 | queryClient.prefetchQuery(PostQueries.byId(post.id)) 310 | } 311 | ) 312 | } 313 | } 314 | } 315 | ``` 316 | 317 | ## Key Types 318 | 319 | ### Key Interface 320 | 321 | ```kotlin 322 | interface Key { 323 | // Marker interface for type-safe keys 324 | } 325 | 326 | // Examples 327 | data class PostsListKey(val filter: String = "all") : Key 328 | data class PostDetailKey(val postId: Int) : Key 329 | data class UserPostsKey(val userId: Int, val page: Int = 1) : Key 330 | data object GlobalSettingsKey : Key 331 | ``` 332 | 333 | ### Query Options 334 | 335 | ```kotlin 336 | data class QueryOptions( 337 | val enabled: Boolean = true, 338 | val staleTime: Long = 0, 339 | val cacheTime: Long = 5 * 60 * 1000, 340 | val refetchInterval: Long? = null, 341 | val refetchOnWindowFocus: Boolean = true, 342 | val refetchOnReconnect: Boolean = true, 343 | val refetchOnMount: Boolean = true, 344 | val retry: RetryConfig = RetryConfig.Default, 345 | val suspense: Boolean = false, 346 | val keepPreviousData: Boolean = false, 347 | val structuralSharing: Boolean = true, 348 | ) 349 | ``` 350 | 351 | ### Retry Configuration 352 | 353 | ```kotlin 354 | data class RetryConfig( 355 | val maxAttempts: Int = 3, 356 | val delay: Long = 1000, 357 | val maxDelay: Long = 30000, 358 | val factor: Double = 2.0, 359 | val shouldRetry: (attempt: Int, error: Throwable) -> Boolean = { _, _ -> true } 360 | ) { 361 | companion object { 362 | val Default = RetryConfig() 363 | val None = RetryConfig(maxAttempts = 0) 364 | 365 | fun exponentialBackoff( 366 | maxAttempts: Int = 3, 367 | initialDelay: Long = 1000, 368 | maxDelay: Long = 30000, 369 | factor: Double = 2.0 370 | ) = RetryConfig( 371 | maxAttempts = maxAttempts, 372 | delay = initialDelay, 373 | maxDelay = maxDelay, 374 | factor = factor 375 | ) 376 | 377 | fun linearBackoff( 378 | maxAttempts: Int = 3, 379 | delay: Long = 1000 380 | ) = RetryConfig( 381 | maxAttempts = maxAttempts, 382 | delay = delay, 383 | factor = 1.0 384 | ) 385 | } 386 | } 387 | ``` 388 | 389 | ## Organization Pattern 390 | 391 | ### Recommended Project Structure 392 | 393 | ``` 394 | app/ 395 | ├── src/ 396 | │ └── commonMain/ 397 | │ └── kotlin/ 398 | │ └── com/example/app/ 399 | │ ├── ui/ 400 | │ │ ├── screens/ 401 | │ │ │ ├── PostsListScreen.kt 402 | │ │ │ ├── PostDetailScreen.kt 403 | │ │ │ └── CreatePostScreen.kt 404 | │ │ └── components/ 405 | │ │ ├── PostItem.kt 406 | │ │ └── LoadingIndicator.kt 407 | │ ├── queries/ 408 | │ │ ├── PostQueries.kt 409 | │ │ ├── PostMutations.kt 410 | │ │ ├── UserQueries.kt 411 | │ │ └── UserMutations.kt 412 | │ ├── models/ 413 | │ │ ├── Post.kt 414 | │ │ └── User.kt 415 | │ └── api/ 416 | │ └── HttpClient.kt 417 | ``` 418 | 419 | ### Query Organization Example 420 | 421 | ```kotlin 422 | // queries/PostQueries.kt 423 | object PostQueries { 424 | val all = query> { 425 | key = PostsListKey() 426 | queryFn = { api.getPosts() } 427 | staleTime = 5.minutes 428 | } 429 | 430 | fun byId(id: Int) = query { 431 | key = PostDetailKey(id) 432 | queryFn = { api.getPost(id) } 433 | staleTime = 30.seconds 434 | } 435 | 436 | fun byUser(userId: Int) = query> { 437 | key = UserPostsKey(userId) 438 | queryFn = { api.getUserPosts(userId) } 439 | staleTime = 2.minutes 440 | } 441 | } 442 | 443 | // queries/PostMutations.kt 444 | object PostMutations { 445 | val create = mutation { 446 | mutationFn = { request -> api.createPost(request) } 447 | onSuccess = { data, _, _ -> 448 | queryClient.invalidateQueries(PostsListKey()) 449 | } 450 | } 451 | 452 | fun update(id: Int) = mutation { 453 | mutationFn = { request -> api.updatePost(id, request) } 454 | onSuccess = { data, _, _ -> 455 | queryClient.setQueryData(PostDetailKey(id), data) 456 | queryClient.invalidateQueries(PostsListKey()) 457 | } 458 | } 459 | 460 | fun delete(id: Int) = mutation { 461 | mutationFn = { api.deletePost(id) } 462 | onSuccess = { _, _, _ -> 463 | queryClient.removeQueries(PostDetailKey(id)) 464 | queryClient.invalidateQueries(PostsListKey()) 465 | } 466 | } 467 | } 468 | ``` 469 | 470 | ## Testing 471 | 472 | ### Testing Query Definitions 473 | 474 | ```kotlin 475 | class PostQueriesTest { 476 | private val fakeHttpClient = FakeHttpClient() 477 | private val queryClient = QueryClient() 478 | 479 | @Test 480 | fun `getAllPosts query has correct configuration`() { 481 | val query = PostQueries.all 482 | 483 | assertEquals(PostsListKey(), query.key) 484 | assertEquals(5.minutes, query.options.staleTime) 485 | assertEquals(10.minutes, query.options.cacheTime) 486 | assertTrue(query.options.refetchOnWindowFocus) 487 | } 488 | 489 | @Test 490 | fun `create mutation handles optimistic updates`() = runTest { 491 | val mutation = PostMutations.create 492 | val request = CreatePostRequest("Title", "Body") 493 | 494 | // Set initial data 495 | queryClient.setQueryData( 496 | PostsListKey(), 497 | listOf(Post(1, "Existing", "Body")) 498 | ) 499 | 500 | // Execute onMutate 501 | val context = mutation.onMutate?.invoke(request) 502 | 503 | // Verify optimistic update 504 | val posts = queryClient.getQueryData>(PostsListKey()) 505 | assertEquals(2, posts?.size) 506 | 507 | // Simulate error and verify rollback 508 | mutation.onError?.invoke( 509 | Exception("Network error"), 510 | request, 511 | context 512 | ) 513 | 514 | val rolledBack = queryClient.getQueryData>(PostsListKey()) 515 | assertEquals(1, rolledBack?.size) 516 | } 517 | } 518 | ``` 519 | 520 | ### Testing Components with Queries 521 | 522 | ```kotlin 523 | @Test 524 | fun `PostsList displays loading state`() = runComposeTest { 525 | val fakeQuery = query> { 526 | key = PostsListKey() 527 | queryFn = { 528 | delay(1000) 529 | listOf(Post(1, "Test", "Body")) 530 | } 531 | } 532 | 533 | setContent { 534 | CompositionLocalProvider( 535 | LocalQueryClient provides testQueryClient 536 | ) { 537 | PostsList() 538 | } 539 | } 540 | 541 | // Verify loading state 542 | onNodeWithText("Loading...").assertExists() 543 | 544 | // Wait for data 545 | advanceTimeBy(1000) 546 | 547 | // Verify data is displayed 548 | onNodeWithText("Test").assertExists() 549 | } 550 | ``` 551 | 552 | ## Migration Guide 553 | 554 | ### From Inline Query Functions 555 | 556 | ```kotlin 557 | // Before: Query function inline in UI 558 | @Composable 559 | fun PostsList() { 560 | val queryState by useQuery( 561 | key = PostsListKey(), 562 | queryFn = { 563 | httpClient.get("/api/posts").body>() 564 | } 565 | ) 566 | } 567 | 568 | // After: Query definition separated 569 | @Composable 570 | fun PostsList() { 571 | val queryState by useQuery(PostQueries.all) 572 | } 573 | ``` 574 | 575 | ### From Repository Pattern 576 | 577 | ```kotlin 578 | // Before: Repository pattern 579 | class PostRepository { 580 | suspend fun getPosts(): List { 581 | return httpClient.get("/api/posts").body() 582 | } 583 | } 584 | 585 | @Composable 586 | fun PostsList(repository: PostRepository) { 587 | val queryState by useQuery( 588 | key = PostsListKey(), 589 | queryFn = { repository.getPosts() } 590 | ) 591 | } 592 | 593 | // After: Query definitions 594 | object PostQueries { 595 | val all = query> { 596 | key = PostsListKey() 597 | queryFn = { 598 | httpClient.get("/api/posts").body() 599 | } 600 | staleTime = 5.minutes 601 | } 602 | } 603 | 604 | @Composable 605 | fun PostsList() { 606 | val queryState by useQuery(PostQueries.all) 607 | } 608 | ``` 609 | 610 | ## Benefits 611 | 612 | 1. **Clean Separation** - UI components don't contain data fetching logic 613 | 2. **Reusability** - Query definitions can be used across multiple components 614 | 3. **Type Safety** - Strongly typed keys and return types 615 | 4. **Testability** - Easy to test queries in isolation 616 | 5. **Configuration** - All query options in one place 617 | 6. **Discoverability** - All queries for a domain grouped together 618 | 7. **Maintainability** - Single source of truth for data fetching 619 | 620 | ## Future Enhancements 621 | 622 | 1. **Query Composition** - Combine multiple queries into complex queries 623 | 2. **Query Middleware** - Add logging, metrics, or transformations 624 | 3. **Code Generation** - Generate query definitions from OpenAPI specs 625 | 4. **DevTools Integration** - Inspect queries in development 626 | 5. **Persistence** - Persist cache to disk for offline support --------------------------------------------------------------------------------