├── 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/#pavi2410/useCompose) [](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
--------------------------------------------------------------------------------