From 61c870c115bf8583639f65a50791ea6121a540f7 Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Fri, 13 Oct 2023 13:03:26 -0400 Subject: [PATCH 01/17] Add Molecule and Paging libraries, update Kotlin version and enable Compose UIKit --- build.gradle.kts | 1 + gradle.properties | 4 +- gradle/libs.versions.toml | 7 +- paging/build.gradle.kts | 46 +++ paging/src/androidMain/AndroidManifest.xml | 2 + .../store/paging5/paging3.kt | 93 ++++++ .../store/paging5/sample.kt | 50 +++ .../store/paging5/store.kt | 295 ++++++++++++++++++ settings.gradle | 1 + 9 files changed, 497 insertions(+), 2 deletions(-) create mode 100644 paging/build.gradle.kts create mode 100644 paging/src/androidMain/AndroidManifest.xml create mode 100644 paging/src/androidMain/kotlin/org/mobilenativefoundation/store/paging5/paging3.kt create mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/sample.kt create mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/store.kt diff --git a/build.gradle.kts b/build.gradle.kts index 6c7def3af..7c1c8b396 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,6 +20,7 @@ buildscript { classpath(libs.maven.publish.plugin) classpath(libs.kover.plugin) classpath(libs.atomic.fu.gradle.plugin) + classpath(libs.molecule.gradle.plugin) } } diff --git a/gradle.properties b/gradle.properties index 0d65fbabf..f1e664f94 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,4 +23,6 @@ POM_DEVELOPER_ID=dropbox POM_DEVELOPER_NAME=Dropbox kotlinx.atomicfu.enableJvmIrTransformation=false kotlinx.atomicfu.enableJsIrTransformation=false -kotlin.js.compiler=ir \ No newline at end of file +kotlin.js.compiler=ir + +org.jetbrains.compose.experimental.uikit.enabled=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b5cd5a9be..2edda6487 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,11 +4,13 @@ androidCompileSdk = "33" androidGradlePlugin = "7.4.2" androidTargetSdk = "33" atomicFu = "0.20.2" -baseKotlin = "1.8.21" +baseKotlin = "1.9.10" dokkaGradlePlugin = "1.6.0" ktlintGradle = "10.2.1" jacocoGradlePlugin = "0.8.7" mavenPublishPlugin = "0.22.0" +moleculeGradlePlugin = "1.2.1" +pagingRuntime = "3.2.1" spotlessPluginGradle = "6.4.1" junit = "4.13.2" kotlinxCoroutines = "1.7.1" @@ -23,6 +25,7 @@ truth = "1.1.3" [libraries] android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } +androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "pagingRuntime" } kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "baseKotlin" } kotlin-serialization-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-serialization", version.ref = "baseKotlin" } dokka-gradle-plugin = { group = "org.jetbrains.dokka", name = "dokka-gradle-plugin", version.ref = "dokkaGradlePlugin" } @@ -40,6 +43,8 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx- kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-rx2 = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-rx2", version.ref = "kotlinxCoroutines" } kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version = "0.4.0" } +molecule-gradle-plugin = { module = "app.cash.molecule:molecule-gradle-plugin", version.ref = "moleculeGradlePlugin" } +molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "moleculeGradlePlugin" } rxjava = { group = "io.reactivex.rxjava2", name = "rxjava", version = "2.2.21" } androidx-test-core = { group = "androidx.test", name = "core", version.ref = "testCore" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } diff --git a/paging/build.gradle.kts b/paging/build.gradle.kts new file mode 100644 index 000000000..d4122006b --- /dev/null +++ b/paging/build.gradle.kts @@ -0,0 +1,46 @@ + +plugins { + kotlin("multiplatform") + kotlin("plugin.serialization") + id("com.android.library") + id("com.vanniktech.maven.publish") + id("org.jetbrains.dokka") + id("org.jetbrains.kotlinx.kover") + `maven-publish` + id("kotlinx-atomicfu") + id("org.jetbrains.compose") version("1.5.1") +} + +kotlin { + android() + jvm() + + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.kotlin.stdlib) + implementation(project(":store")) + implementation(project(":cache")) + implementation(compose.runtime) + implementation(libs.molecule.runtime) + implementation(compose.ui) + implementation(compose.foundation) + + + } + } + + val androidMain by getting { + dependencies { + implementation(libs.androidx.paging.runtime) + } + } + } +} + +android { + + compileSdk = 33 + + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") +} diff --git a/paging/src/androidMain/AndroidManifest.xml b/paging/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..b617bd2bf --- /dev/null +++ b/paging/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/paging/src/androidMain/kotlin/org/mobilenativefoundation/store/paging5/paging3.kt b/paging/src/androidMain/kotlin/org/mobilenativefoundation/store/paging5/paging3.kt new file mode 100644 index 000000000..ddfc15dc2 --- /dev/null +++ b/paging/src/androidMain/kotlin/org/mobilenativefoundation/store/paging5/paging3.kt @@ -0,0 +1,93 @@ +package org.mobilenativefoundation.store.paging5 + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import org.mobilenativefoundation.store.store5.Store +import org.mobilenativefoundation.store.store5.impl.extensions.get + + +/** + * Converts the given [Store] into a [PagingSource] suitable for use with Paging3. + * + * @param keyProvider Provides methods to determine refresh and next keys for pagination. + * + * @return A [PagingSource] which can be used with a [Pager]. + */ +inline fun , + reified Value : Identifiable.Single> + Store>.asPagingSource( + keyProvider: PaginationKeyProvider>, +): PagingSource { + return object : PagingSource() { + override fun getRefreshKey(state: PagingState): Key? = + keyProvider.determineRefreshKey(state) + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val key = params.key ?: return LoadResult.Invalid() + val data = get(key) + + val items = data.items.mapNotNull { it as? Value } + if (items.size == data.items.size) { + LoadResult.Page(data = items, prevKey = key, nextKey = keyProvider.determineNextKey(key, data)) + } else { + LoadResult.Error(ClassCastException("Expected items of type PagingOutput")) + } + } catch (error: Exception) { + LoadResult.Error(error) + } + } + + } +} + +/** + * Interface to provide pagination keys for the [Pager]. + */ +interface PaginationKeyProvider, Value : Identifiable.Single, StoreOutput : Identifiable.Collection> { + fun determineRefreshKey(state: PagingState): Key? + fun determineNextKey(key: Key, output: StoreOutput): Key? +} + + +/** + * Creates a [Pager] backed by the given [Store]. + * + * @param config Configuration for the paging behavior. + * @param initialKey Initial key to be used when loading data for the first time. + * @param keyProvider Provides methods to determine refresh and next keys for pagination. + * + * @return A [Pager] which can be used to paginate through the data. + */ +inline fun , reified Value : Identifiable.Single> + Store>.pager( + config: PagingConfig, + initialKey: Key? = null, + keyProvider: PaginationKeyProvider> +): Pager { + return Pager( + config = config, + initialKey = initialKey, + pagingSourceFactory = { this.asPagingSource(keyProvider) } + ) +} + + +/** + * Creates a [Pager] backed by the given [Store]. + * + * @param config Configuration for the paging behavior. + * @param initialKey Initial key to be used when loading data for the first time. + * @param keyProvider Provides methods to determine refresh and next keys for pagination. + * + * @return A [Pager] which can be used to paginate through the data. + */ +inline fun , reified Value : Identifiable.Single> + Store>.pager( + config: PagingConfig, + initialKey: Key? = null, + keyProvider: PaginationKeyProvider> +): Pager = this.pager(config, initialKey, keyProvider) \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/sample.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/sample.kt new file mode 100644 index 000000000..42ee7c7ec --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/sample.kt @@ -0,0 +1,50 @@ +package org.mobilenativefoundation.store.paging5 + +import androidx.compose.runtime.Composable +import org.mobilenativefoundation.store.store5.Fetcher +import org.mobilenativefoundation.store.store5.Store + + +sealed class PostPagingData: Identifiable { + data class Post(val postId: String, val title: String) : Identifiable.Single, PostPagingData() { + override val id: String get() = postId + } + + data class Feed(val posts: List) : Identifiable.Collection, PostPagingData() { + override val items: List> get() = posts + } +} + + +sealed class PostPagingKey: StoreKey { + data class Key( + override val cursor: String, + override val size: Int, + override val sort: StoreKey.Sort?, + override val filters: List>? + ) : StoreKey.Collection.Cursor, PostPagingKey() + +} + + + +class PostPagingStoreFactory { + + private fun createFetcher(): Fetcher = TODO() + + fun create(): Store = TODO() +} + + +@Composable +fun FeedView(store: Store, Identifiable>) { + store.cursor( + key = PostPagingKey.Key("", 1, null, null), + initialContent = {}, + loadingContent = {}, + errorContent = {}, + onPrefetch = {_ -> PostPagingKey.Key("", 1, null, null)} + ) { + + } +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/store.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/store.kt new file mode 100644 index 000000000..95a92416d --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/store.kt @@ -0,0 +1,295 @@ +@file:Suppress("UNCHECKED_CAST") + +package org.mobilenativefoundation.store.paging5 + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import app.cash.molecule.RecompositionMode +import app.cash.molecule.launchMolecule +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import org.mobilenativefoundation.store.store5.Store +import org.mobilenativefoundation.store.store5.impl.extensions.get + + +/** + * Interface defining items that can be identified. + * The identifiable items can either be standalone entities or collections of entities. + * + * This structure is particularly useful in scenarios where data can be represented + * both as individual units or as groups (collections) of units. For example, in a data fetch + * scenario, an API could return a single item or a list of items. + */ +sealed interface Identifiable { + + /** + * Represents a single identifiable item. + * Each single item must have a unique identifier, represented by the 'id' property. + */ + interface Single : Identifiable { + val id: Id + } + + /** + * Represents a collection of identifiable items. + * The collection is essentially a list of single items. + */ + interface Collection : Identifiable { + val items: List> + } +} + + +/** + * Interface defining keys used by the Store for data fetch operations. + * + * The StoreKey allows the Store to fetch either individual items or collections of items. + * Depending on the use-case, the fetch can be a simple ID-based fetch, a page-based fetch, + * or a cursor-based fetch. Sorting and filtering options are also provided. + */ +sealed interface StoreKey { + + /** + * Represents a key for fetching a single item based on its ID. + */ + interface Single : StoreKey { + val id: Id + } + + /** + * Represents a key for fetching collections (lists) of items. + */ + sealed interface Collection : StoreKey { + + /** + * Represents a key for page-based fetching. + * This includes the page number, size of the page, sorting option, and filters. + */ + interface Page : Collection { + val page: Int + val size: Int + val sort: Sort? + val filters: List>? + } + + /** + * Represents a key for cursor-based fetching. + * This includes a cursor string, size of the fetch, sorting option, and filters. + */ + interface Cursor : Collection { + val cursor: Id + val size: Int + val sort: Sort? + val filters: List>? + } + } + + /** + * Enum defining sorting options that can be applied during fetching. + */ + enum class Sort { + NEWEST, + OLDEST, + ALPHABETICAL, + REVERSE_ALPHABETICAL + } + + /** + * Class defining filters that can be applied during fetching. + * Each filter consists of a list of items and a block that defines the filtering criteria. + */ + interface Filter { + operator fun invoke(items: List): List + } +} + + +/** + * Interface defining different states of data fetch operations. + */ +sealed interface StoreState> { + data object Initial : StoreState + data object Loading : StoreState + + sealed interface Loaded> : StoreState { + data class Single>(val data: Output) : Loaded + + data class Collection>(val data: Output) : Loaded + } + + sealed interface Error : StoreState { + data class Exception(val error: CustomException) : Error + data class Message(val error: String) : Error + } +} + + +/** + * Extension function on the Store class to provide a stateful composable. + * It manages the state of a fetch operation for a given key. + * + * @param key The key based on which data will be fetched. + * @return A composable function that returns the current state of the fetch operation. + */ +fun , Output : Identifiable> Store.stateful(key: Key): @Composable () -> StoreState { + + @Composable + fun launch(): StoreState { + // Remember and manage the fetch operation's state. + var state by rememberSaveable { mutableStateOf>(StoreState.Loading) } + + LaunchedEffect(key) { + state = try { + key as StoreKey.Collection + val output = this@stateful.get(key) as Identifiable.Collection + StoreState.Loaded.Collection(output) as StoreState + + } catch (error: Exception) { + // Handle and store exceptions in the state. + StoreState.Error.Exception(error) + } + } + + return state + } + + return ::launch +} + + +/** + * A custom LazyColumn that supports prefetching. + * Detects when the user is close to the end of the displayed items and triggers a fetch for subsequent data. + */ +@Composable +fun > PrefetchingLazyColumn( + items: List, + threshold: Int = 3, + onPrefetch: (nextCursor: Id) -> Unit, + content: @Composable LazyItemScope.(T) -> Unit +) { + LazyColumn { + itemsIndexed(items) { index, item -> + if (index >= items.size - threshold) { + onPrefetch(items.last().id) + } + content(item) + } + } +} + + +/** + * A custom LazyColumn that supports prefetching. + * Detects when the user is close to the end of the displayed items and triggers a fetch for subsequent data. + */ +@Composable +fun > PrefetchingLazyColumn( + items: List, + threshold: Int = 3, + onPrefetch: () -> Unit, + content: @Composable LazyItemScope.(Value) -> Unit +) { + LazyColumn { + itemsIndexed(items) { index, item -> + if (index >= items.size - threshold) { + onPrefetch() + } + content(item) + } + } +} + + +/** + * Extension function on the Store class to launch a paging store. + * For each key in the provided StateFlow, it maps to a flow that emits the corresponding store state. + * + * @param keys A StateFlow containing keys based on which data will be fetched. + * @param scope The coroutine scope in which the operations will be launched. + * @return A StateFlow that emits the store state corresponding to each key. + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun , Output : Identifiable> Store.launchPagingStore( + keys: StateFlow, + scope: CoroutineScope +): StateFlow> { + // For each key, create a flow that computes and emits the store state. + return keys.flatMapConcat { key -> + flow { + try { + // Launch a molecule to reactively compute the store state for the given key. + val storeState = scope.launchMolecule(mode = RecompositionMode.ContextClock) { + stateful(key) + } + // Emit the computed store state to the resulting flow. + emit(storeState.value.invoke()) + } catch (error: Exception) { + // Handle potential errors during state computation. + emit(StoreState.Error.Exception(error)) + } + } + }.stateIn( + scope, + SharingStarted.Lazily, + StoreState.Initial + ) // Convert the flow to a StateFlow with an initial state. +} + +/** + * A composable function designed for cursor-based pagination in the Store. + * It manages and displays data based on different states: initial, loading, loaded, and error. + * + * @param key The initial key for fetching data. + * @param initialContent Composable to display during the initial state. + * @param loadingContent Composable to display during the loading state. + * @param errorContent Composable to display when an error occurs. + * @param onPrefetch Function to determine the next key based on the current cursor. + * @param content Composable to display the fetched items. + */ +@Composable +inline fun > Store, Identifiable>.cursor( + key: StoreKey, + crossinline initialContent: @Composable () -> Unit = {}, + crossinline loadingContent: @Composable () -> Unit = {}, + crossinline errorContent: @Composable (error: StoreState.Error) -> Unit = {}, + crossinline onPrefetch: (nextCursor: Id) -> StoreKey, + crossinline content: @Composable (Value) -> Unit +) { + // MutableStateFlow to hold the current key for data fetch. + val keys = MutableStateFlow(key) + // Remembered coroutine scope for launching coroutines in Compose. + val scope = rememberCoroutineScope() + // Current state of the store fetched using the launchPagingStore function. + val state = launchPagingStore(keys, scope).collectAsState() + + // Render UI based on the current state. + when (val storeState = state.value) { + is StoreState.Error -> errorContent(storeState) + StoreState.Initial -> initialContent() + is StoreState.Loaded.Collection<*, *> -> { + val items = storeState.data.items as List> + + PrefetchingLazyColumn( + items = items, + onPrefetch = { nextCursor -> + val nextKey = onPrefetch(nextCursor) + keys.value = nextKey + } + ) { item -> + if (item is Value) { + content(item) + } else { + errorContent(StoreState.Error.Message("Unexpected item type: ${item::class.simpleName}")) + } + } + } + + is StoreState.Loaded.Single<*, *> -> errorContent(StoreState.Error.Message("Single item pagination not supported.")) + StoreState.Loading -> loadingContent() + } +} diff --git a/settings.gradle b/settings.gradle index cadabf8da..ca7e239ff 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,3 +2,4 @@ include ':store' include ':cache' include ':multicast' include ':rx2' +include ':paging' \ No newline at end of file From deea80be1fb5fe02cf2d48e91f6f3a5fb2715cc3 Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Tue, 17 Oct 2023 21:09:24 -0400 Subject: [PATCH 02/17] Second stab --- gradle/libs.versions.toml | 3 + paging/build.gradle.kts | 15 +- .../store/paging5/paging3.kt | 93 ------ .../store/paging5/Identifiable.kt | 35 +++ .../store/paging5/InitStoreStateFlow.kt | 65 ++++ .../store/paging5/KeyProvider.kt | 6 + .../store/paging5/PagingCache.kt | 146 +++++++++ .../store/paging5/PagingCacheAccessor.kt | 92 ++++++ .../store/paging5/StoreKey.kt | 66 ++++ .../store/paging5/StoreState.kt | 51 +++ .../store/paging5/UpdateStoreState.kt | 89 ++++++ .../store/paging5/sample.kt | 50 --- .../store/paging5/store.kt | 295 ------------------ .../store/paging5/InitStoreStateFlowTests.kt | 83 +++++ .../store/paging5/PagingTests.kt | 70 +++++ .../store/paging5/util/FakePostApi.kt | 39 +++ .../store/paging5/util/FakePostDatabase.kt | 38 +++ .../paging5/util/FeedGetRequestResult.kt | 10 + .../store/paging5/util/PostApi.kt | 7 + .../store/paging5/util/PostData.kt | 34 ++ .../store/paging5/util/PostDatabase.kt | 8 + .../paging5/util/PostGetRequestResult.kt | 10 + .../store/paging5/util/PostKey.kt | 18 ++ .../paging5/util/PostPutRequestResult.kt | 10 + .../store/paging5/util/PostStoreFactory.kt | 134 ++++++++ .../store/store5/impl/extensions/store.kt | 22 +- 26 files changed, 1043 insertions(+), 446 deletions(-) delete mode 100644 paging/src/androidMain/kotlin/org/mobilenativefoundation/store/paging5/paging3.kt create mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Identifiable.kt create mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlow.kt create mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyProvider.kt create mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCache.kt create mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCacheAccessor.kt create mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreKey.kt create mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreState.kt create mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/UpdateStoreState.kt delete mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/sample.kt delete mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/store.kt create mode 100644 paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlowTests.kt create mode 100644 paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt create mode 100644 paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt create mode 100644 paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt create mode 100644 paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FeedGetRequestResult.kt create mode 100644 paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostApi.kt create mode 100644 paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt create mode 100644 paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDatabase.kt create mode 100644 paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostGetRequestResult.kt create mode 100644 paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt create mode 100644 paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostPutRequestResult.kt create mode 100644 paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2edda6487..5187cf53d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ ktlintGradle = "10.2.1" jacocoGradlePlugin = "0.8.7" mavenPublishPlugin = "0.22.0" moleculeGradlePlugin = "1.2.1" +pagingCompose = "3.3.0-alpha02" pagingRuntime = "3.2.1" spotlessPluginGradle = "6.4.1" junit = "4.13.2" @@ -25,6 +26,7 @@ truth = "1.1.3" [libraries] android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } +androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pagingCompose" } androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "pagingRuntime" } kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "baseKotlin" } kotlin-serialization-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-serialization", version.ref = "baseKotlin" } @@ -51,3 +53,4 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor junit = { group = "junit", name = "junit", version.ref = "junit" } google-truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } touchlab-kermit = { group = "co.touchlab", name = "kermit", version.ref = "kermit" } +turbine = "app.cash.turbine:turbine:0.12.3" diff --git a/paging/build.gradle.kts b/paging/build.gradle.kts index d4122006b..06a0bd090 100644 --- a/paging/build.gradle.kts +++ b/paging/build.gradle.kts @@ -12,8 +12,7 @@ plugins { } kotlin { - android() - jvm() + androidTarget() sourceSets { val commonMain by getting { @@ -25,6 +24,7 @@ kotlin { implementation(libs.molecule.runtime) implementation(compose.ui) implementation(compose.foundation) + implementation(compose.material) } @@ -33,6 +33,17 @@ kotlin { val androidMain by getting { dependencies { implementation(libs.androidx.paging.runtime) + implementation(libs.androidx.paging.compose) + } + } + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.turbine) + implementation(libs.kotlinx.coroutines.test) + implementation(compose.uiTestJUnit4) + implementation(compose.ui) } } } diff --git a/paging/src/androidMain/kotlin/org/mobilenativefoundation/store/paging5/paging3.kt b/paging/src/androidMain/kotlin/org/mobilenativefoundation/store/paging5/paging3.kt deleted file mode 100644 index ddfc15dc2..000000000 --- a/paging/src/androidMain/kotlin/org/mobilenativefoundation/store/paging5/paging3.kt +++ /dev/null @@ -1,93 +0,0 @@ -package org.mobilenativefoundation.store.paging5 - -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.PagingSource -import androidx.paging.PagingState -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.impl.extensions.get - - -/** - * Converts the given [Store] into a [PagingSource] suitable for use with Paging3. - * - * @param keyProvider Provides methods to determine refresh and next keys for pagination. - * - * @return A [PagingSource] which can be used with a [Pager]. - */ -inline fun , - reified Value : Identifiable.Single> - Store>.asPagingSource( - keyProvider: PaginationKeyProvider>, -): PagingSource { - return object : PagingSource() { - override fun getRefreshKey(state: PagingState): Key? = - keyProvider.determineRefreshKey(state) - - override suspend fun load(params: LoadParams): LoadResult { - return try { - val key = params.key ?: return LoadResult.Invalid() - val data = get(key) - - val items = data.items.mapNotNull { it as? Value } - if (items.size == data.items.size) { - LoadResult.Page(data = items, prevKey = key, nextKey = keyProvider.determineNextKey(key, data)) - } else { - LoadResult.Error(ClassCastException("Expected items of type PagingOutput")) - } - } catch (error: Exception) { - LoadResult.Error(error) - } - } - - } -} - -/** - * Interface to provide pagination keys for the [Pager]. - */ -interface PaginationKeyProvider, Value : Identifiable.Single, StoreOutput : Identifiable.Collection> { - fun determineRefreshKey(state: PagingState): Key? - fun determineNextKey(key: Key, output: StoreOutput): Key? -} - - -/** - * Creates a [Pager] backed by the given [Store]. - * - * @param config Configuration for the paging behavior. - * @param initialKey Initial key to be used when loading data for the first time. - * @param keyProvider Provides methods to determine refresh and next keys for pagination. - * - * @return A [Pager] which can be used to paginate through the data. - */ -inline fun , reified Value : Identifiable.Single> - Store>.pager( - config: PagingConfig, - initialKey: Key? = null, - keyProvider: PaginationKeyProvider> -): Pager { - return Pager( - config = config, - initialKey = initialKey, - pagingSourceFactory = { this.asPagingSource(keyProvider) } - ) -} - - -/** - * Creates a [Pager] backed by the given [Store]. - * - * @param config Configuration for the paging behavior. - * @param initialKey Initial key to be used when loading data for the first time. - * @param keyProvider Provides methods to determine refresh and next keys for pagination. - * - * @return A [Pager] which can be used to paginate through the data. - */ -inline fun , reified Value : Identifiable.Single> - Store>.pager( - config: PagingConfig, - initialKey: Key? = null, - keyProvider: PaginationKeyProvider> -): Pager = this.pager(config, initialKey, keyProvider) \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Identifiable.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Identifiable.kt new file mode 100644 index 000000000..55871e763 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Identifiable.kt @@ -0,0 +1,35 @@ +package org.mobilenativefoundation.store.paging5 + + +/** + * An interface that defines items that can be uniquely identified. + * Every item that implements the [Identifiable] interface must have a means of identification. + * This is useful in scenarios when data can be represented as singles or collections. + */ + +interface Identifiable { + + /** + * Represents a single identifiable item. + */ + interface Single : Identifiable { + val id: Id + } + + /** + * Represents a collection of identifiable items. + */ + interface Collection> : Identifiable { + val items: List + + /** + * Returns a new collection with the updated items. + */ + fun copyWith(items: List): Collection + + /** + * Inserts items to the existing collection and returns the updated collection. + */ + fun insertItems(type: StoreKey.LoadType, items: List): Collection + } +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlow.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlow.kt new file mode 100644 index 000000000..cc64a442b --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlow.kt @@ -0,0 +1,65 @@ +package org.mobilenativefoundation.store.paging5 + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.mobilenativefoundation.store.store5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.MutableStore +import org.mobilenativefoundation.store.store5.Store + + +/** + * Initializes and returns a [StateFlow] that reflects the state of the Store, updating by a flow of provided keys. + * @param scope A [CoroutineScope]. + * @param keys A flow of keys that dictate how the Store should be updated. + * @param updateStoreState A lambda that defines how the Store's state should be updated based on the current state and a key. + * @return A read-only [StateFlow] reflecting the state of the Store. + */ +private fun , Output : Identifiable> initStoreStateFlow( + scope: CoroutineScope, + keys: Flow, + updateStoreState: suspend (currentState: StoreState, key: Key) -> StoreState +): StateFlow> { + val stateFlow = MutableStateFlow>(StoreState.Loading) + + scope.launch { + keys.collect { key -> + println("KEY = $key") + println("CURRENT STATE = ${stateFlow.value}") + val updatedState = updateStoreState(stateFlow.value, key) + stateFlow.emit(updatedState) + } + } + + return stateFlow.asStateFlow() +} + +/** + * Initializes and returns a [StateFlow] that reflects the state of the [Store], updating by a flow of provided keys. + * @see [initStoreStateFlow]. + */ +fun , Output : Identifiable> Store.initStoreStateFlow( + scope: CoroutineScope, + keys: Flow, +): StateFlow> { + return initStoreStateFlow(scope, keys) { currentState, key -> + this.updateStoreState(currentState, key) + } +} + +/** + * Initializes and returns a [StateFlow] that reflects the state of the [Store], updating by a flow of provided keys. + * @see [initStoreStateFlow]. + */ +@OptIn(ExperimentalStoreApi::class) +fun , Output : Identifiable> MutableStore.initStoreStateFlow( + scope: CoroutineScope, + keys: Flow, +): StateFlow> { + return initStoreStateFlow(scope, keys) { currentState, key -> + this.updateStoreState(currentState, key) + } +} diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyProvider.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyProvider.kt new file mode 100644 index 000000000..48f351748 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyProvider.kt @@ -0,0 +1,6 @@ +package org.mobilenativefoundation.store.paging5 + +interface KeyProvider> { + fun from(key: StoreKey.Collection, value: Single): StoreKey.Single + fun from(key: StoreKey.Single, value: Single): StoreKey.Collection +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCache.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCache.kt new file mode 100644 index 000000000..f9737a264 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCache.kt @@ -0,0 +1,146 @@ +@file:Suppress("UNCHECKED_CAST") + +package org.mobilenativefoundation.store.paging5 + +import org.mobilenativefoundation.store.cache5.Cache + +/** + * A class that represents a caching system for pagination. + * Manages data with utility functions to get, invalidate, and add items to the cache. + * Depends on [PagingCacheAccessor] for internal data management. + * @see [Cache]. + */ +class PagingCache, StoreOutput : Identifiable, Collection : Identifiable.Collection, Single : Identifiable.Single>( + private val keyProvider: KeyProvider, +) : Cache { + + private val accessor = PagingCacheAccessor() + + private fun Key.castSingle() = this as StoreKey.Single + private fun Key.castCollection() = this as StoreKey.Collection + + private fun StoreKey.Collection.cast() = this as Key + private fun StoreKey.Single.cast() = this as Key + + override fun getIfPresent(key: Key): StoreOutput? { + return when (key) { + is StoreKey.Single<*> -> accessor.getSingle(key.castSingle()) as? StoreOutput + is StoreKey.Collection<*> -> accessor.getCollection(key.castCollection()) as? StoreOutput + else -> { + throw UnsupportedOperationException(invalidKeyErrorMessage(key)) + } + } + } + + override fun getOrPut(key: Key, valueProducer: () -> StoreOutput): StoreOutput { + return when (key) { + is StoreKey.Single<*> -> { + val single = accessor.getSingle(key.castSingle()) as? StoreOutput + if (single != null) { + single + } else { + val producedSingle = valueProducer() + put(key, producedSingle) + producedSingle + } + } + + is StoreKey.Collection<*> -> { + val collection = accessor.getCollection(key.castCollection()) as? StoreOutput + if (collection != null) { + collection + } else { + val producedCollection = valueProducer() + put(key, producedCollection) + producedCollection + } + } + + else -> { + throw UnsupportedOperationException(invalidKeyErrorMessage(key)) + } + } + } + + override fun getAllPresent(keys: List<*>): Map { + val map = mutableMapOf() + keys.filterIsInstance>().forEach { key -> + when (key) { + is StoreKey.Collection -> { + val collection = accessor.getCollection(key) + collection?.let { map[key.cast()] = it as StoreOutput } + } + + is StoreKey.Single -> { + val single = accessor.getSingle(key) + single?.let { map[key.cast()] = it as StoreOutput } + } + } + } + + return map + } + + override fun invalidateAll(keys: List) { + keys.forEach { key -> invalidate(key) } + } + + override fun invalidate(key: Key) { + when (key) { + is StoreKey.Single<*> -> accessor.invalidateSingle(key.castSingle()) + is StoreKey.Collection<*> -> accessor.invalidateCollection(key.castCollection()) + } + } + + override fun putAll(map: Map) { + map.entries.forEach { (key, value) -> put(key, value) } + } + + override fun put(key: Key, value: StoreOutput) { + when (key) { + is StoreKey.Single<*> -> { + val single = value as Single + accessor.putSingle(key.castSingle(), single) + + val collectionKey = keyProvider.from(key.castSingle(), single) + val existingCollection = accessor.getCollection(collectionKey) + if (existingCollection != null) { + val updatedItems = existingCollection.items.toMutableList().map { + if (it.id == single.id) { + single + } else { + it + } + } + val updatedCollection = existingCollection.copyWith(items = updatedItems) as Collection + accessor.putCollection(collectionKey, updatedCollection) + } + } + + is StoreKey.Collection<*> -> { + val collection = value as Collection + accessor.putCollection(key.castCollection(), collection) + + collection.items.forEach { + val single = it as? Single + if (single != null) { + accessor.putSingle(keyProvider.from(key.castCollection(), single), single) + } + } + } + } + } + + override fun invalidateAll() { + accessor.invalidateAll() + } + + override fun size(): Long { + return accessor.size() + } + + companion object { + fun invalidKeyErrorMessage(key: Any) = + "Expected StoreKey.Single or StoreKey.Collection, but received ${key::class}" + } +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCacheAccessor.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCacheAccessor.kt new file mode 100644 index 000000000..83e6f67fb --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCacheAccessor.kt @@ -0,0 +1,92 @@ +package org.mobilenativefoundation.store.paging5 + +import org.mobilenativefoundation.store.cache5.CacheBuilder + +/** + * Intermediate data manager for a caching system supporting pagination. + * Tracks keys for rapid data retrieval and modification. + */ +class PagingCacheAccessor, Single : Identifiable.Single> { + private val collections = CacheBuilder, Collection>().build() + private val singles = CacheBuilder, Single>().build() + private val keys = mutableSetOf>() + + + /** + * Retrieves a collection of items from the cache using the provided key. + */ + fun getCollection(key: StoreKey.Collection): Collection? = collections.getIfPresent(key) + + /** + * Retrieves an individual item from the cache using the provided key. + */ + fun getSingle(key: StoreKey.Single): Single? = singles.getIfPresent(key) + + /** + * Stores a collection of items in the cache and updates the key set. + */ + fun putCollection(key: StoreKey.Collection, collection: Collection) { + collections.put(key, collection) + keys.add(key) + } + + /** + * Stores an individual item in the cache and updates the key set. + */ + fun putSingle(key: StoreKey.Single, single: Single) { + singles.put(key, single) + keys.add(key) + } + + /** + * Removes all cache entries and clears the key set. + */ + fun invalidateAll() { + collections.invalidateAll() + singles.invalidateAll() + keys.clear() + } + + /** + * Removes an individual item from the cache and updates the key set. + */ + fun invalidateSingle(key: StoreKey.Single) { + singles.invalidate(key) + keys.remove(key) + } + + /** + * Removes a collection of items from the cache and updates the key set. + */ + fun invalidateCollection(key: StoreKey.Collection) { + collections.invalidate(key) + keys.remove(key) + } + + /** + * Calculates the total count of items in the cache. + * Includes individual items as well as items in collections. + */ + + fun size(): Long { + var count = 0L + for (key in keys) { + when (key) { + is StoreKey.Single -> { + val single = singles.getIfPresent(key) + if (single != null) { + count++ + } + } + + is StoreKey.Collection -> { + val collection = collections.getIfPresent(key) + if (collection != null) { + count += collection.items.size + } + } + } + } + return count + } +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreKey.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreKey.kt new file mode 100644 index 000000000..00d8d5f94 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreKey.kt @@ -0,0 +1,66 @@ +package org.mobilenativefoundation.store.paging5 + +/** + * An interface that defines keys used by Store for data-fetching operations. + * Allows Store to fetch individual items and collections of items. + * Provides mechanisms for ID-based fetch, page-based fetch, and cursor-based fetch. + * Includes options for sorting and filtering. + */ +interface StoreKey { + + /** + * Represents a key for fetching an individual item. + */ + interface Single : StoreKey { + val id: Id + } + + /** + * Represents a key for fetching collections of items. + */ + interface Collection : StoreKey { + val loadType: LoadType + + /** + * Represents a key for page-based fetching. + */ + interface Page : Collection { + val page: Int + val size: Int + val sort: Sort? + val filters: List>? + } + + /** + * Represents a key for cursor-based fetching. + */ + interface Cursor : Collection { + val cursor: Id? + val size: Int + val sort: Sort? + val filters: List>? + } + } + + /** + * An enum defining sorting options that can be applied during fetching. + */ + enum class Sort { + NEWEST, + OLDEST, + ALPHABETICAL, + REVERSE_ALPHABETICAL + } + + /** + * Defines filters that can be applied during fetching. + */ + interface Filter { + operator fun invoke(items: List): List + } + + enum class LoadType { + APPEND, + PREPEND + } +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreState.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreState.kt new file mode 100644 index 000000000..9c321c490 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreState.kt @@ -0,0 +1,51 @@ +package org.mobilenativefoundation.store.paging5 + +/** + * An interface that defines various states of data-fetching operations. + */ +sealed interface StoreState> { + + /** + * Represents the initial state. + */ + data object Initial : StoreState + + /** + * Represents the loading state. + */ + data object Loading : StoreState + + + /** + * Represents successful fetch operations. + */ + sealed interface Loaded> : StoreState { + + /** + * Represents a successful fetch of an individual item. + */ + data class Single>(val data: Output) : Loaded + + /** + * Represents a successful fetch of a collection of items. + */ + data class Collection, CO : Identifiable.Collection>(val data: CO) : + Loaded + } + + /** + * Represents unsuccessful fetch operations. + */ + sealed interface Error : StoreState { + + /** + * Represents an unsuccessful fetch operation due to an exception. + */ + data class Exception(val error: CustomException) : Error + + /** + * Represents an unsuccessful fetch operation due to an error with a message. + */ + data class Message(val error: String) : Error + } +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/UpdateStoreState.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/UpdateStoreState.kt new file mode 100644 index 000000000..743cd4e54 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/UpdateStoreState.kt @@ -0,0 +1,89 @@ +@file:Suppress("UNCHECKED_CAST") + +package org.mobilenativefoundation.store.paging5 + +import org.mobilenativefoundation.store.store5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.MutableStore +import org.mobilenativefoundation.store.store5.Store +import org.mobilenativefoundation.store.store5.impl.extensions.fresh + + +typealias LoadedCollection = StoreState.Loaded.Collection, Identifiable.Collection>> + + +/** + * Updates the Store's state based on a provided key and a retrieval mechanism. + * @param currentState The current state of the Store. + * @param key The key that dictates how the state should be updated. + * @param get A lambda that defines how to retrieve data from the Store based on a key. + */ +private suspend fun , Output : Identifiable> updateStoreState( + currentState: StoreState, + key: Key, + get: suspend (key: Key) -> Output, +): StoreState { + return try { + if (key !is StoreKey.Collection<*>) throw IllegalArgumentException("Invalid key type") + + val lastOutput = when (currentState) { + is StoreState.Loaded.Collection<*, *, *> -> { + val data = (currentState as LoadedCollection).data + println("DATA = $data") + data + } + + else -> { + println("NULL") + null + } + } + + val nextOutput = get(key) as Identifiable.Collection> + + val output = (lastOutput?.insertItems(key.loadType, nextOutput.items) ?: nextOutput) + + println("OUTPUT * = $lastOutput $output") + StoreState.Loaded.Collection(output) as StoreState + + } catch (error: Exception) { + StoreState.Error.Exception(error) + } +} + +/** + * Updates the [Store]'s state based on a provided key. + * @see [updateStoreState]. + */ +suspend fun , Output : Identifiable> Store.updateStoreState( + currentState: StoreState, + key: Key +): StoreState { + return updateStoreState( + currentState, + key + ) { + this.fresh(it) + } +} + +/** + * Updates the [MutableStore]'s state based on a provided key. + * @see [updateStoreState]. + */ +@OptIn(ExperimentalStoreApi::class) +suspend fun , Output : Identifiable> MutableStore.updateStoreState( + currentState: StoreState, + key: Key +): StoreState { + return updateStoreState( + currentState, + key + ) { + val output = this.fresh(it) + println("KEY = $key") + println("OUTPUT = $output") + println("CURRENT STATE = $currentState") + output + } +} + diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/sample.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/sample.kt deleted file mode 100644 index 42ee7c7ec..000000000 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/sample.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.mobilenativefoundation.store.paging5 - -import androidx.compose.runtime.Composable -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.Store - - -sealed class PostPagingData: Identifiable { - data class Post(val postId: String, val title: String) : Identifiable.Single, PostPagingData() { - override val id: String get() = postId - } - - data class Feed(val posts: List) : Identifiable.Collection, PostPagingData() { - override val items: List> get() = posts - } -} - - -sealed class PostPagingKey: StoreKey { - data class Key( - override val cursor: String, - override val size: Int, - override val sort: StoreKey.Sort?, - override val filters: List>? - ) : StoreKey.Collection.Cursor, PostPagingKey() - -} - - - -class PostPagingStoreFactory { - - private fun createFetcher(): Fetcher = TODO() - - fun create(): Store = TODO() -} - - -@Composable -fun FeedView(store: Store, Identifiable>) { - store.cursor( - key = PostPagingKey.Key("", 1, null, null), - initialContent = {}, - loadingContent = {}, - errorContent = {}, - onPrefetch = {_ -> PostPagingKey.Key("", 1, null, null)} - ) { - - } -} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/store.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/store.kt deleted file mode 100644 index 95a92416d..000000000 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/store.kt +++ /dev/null @@ -1,295 +0,0 @@ -@file:Suppress("UNCHECKED_CAST") - -package org.mobilenativefoundation.store.paging5 - -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyItemScope -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import app.cash.molecule.RecompositionMode -import app.cash.molecule.launchMolecule -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.* -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.impl.extensions.get - - -/** - * Interface defining items that can be identified. - * The identifiable items can either be standalone entities or collections of entities. - * - * This structure is particularly useful in scenarios where data can be represented - * both as individual units or as groups (collections) of units. For example, in a data fetch - * scenario, an API could return a single item or a list of items. - */ -sealed interface Identifiable { - - /** - * Represents a single identifiable item. - * Each single item must have a unique identifier, represented by the 'id' property. - */ - interface Single : Identifiable { - val id: Id - } - - /** - * Represents a collection of identifiable items. - * The collection is essentially a list of single items. - */ - interface Collection : Identifiable { - val items: List> - } -} - - -/** - * Interface defining keys used by the Store for data fetch operations. - * - * The StoreKey allows the Store to fetch either individual items or collections of items. - * Depending on the use-case, the fetch can be a simple ID-based fetch, a page-based fetch, - * or a cursor-based fetch. Sorting and filtering options are also provided. - */ -sealed interface StoreKey { - - /** - * Represents a key for fetching a single item based on its ID. - */ - interface Single : StoreKey { - val id: Id - } - - /** - * Represents a key for fetching collections (lists) of items. - */ - sealed interface Collection : StoreKey { - - /** - * Represents a key for page-based fetching. - * This includes the page number, size of the page, sorting option, and filters. - */ - interface Page : Collection { - val page: Int - val size: Int - val sort: Sort? - val filters: List>? - } - - /** - * Represents a key for cursor-based fetching. - * This includes a cursor string, size of the fetch, sorting option, and filters. - */ - interface Cursor : Collection { - val cursor: Id - val size: Int - val sort: Sort? - val filters: List>? - } - } - - /** - * Enum defining sorting options that can be applied during fetching. - */ - enum class Sort { - NEWEST, - OLDEST, - ALPHABETICAL, - REVERSE_ALPHABETICAL - } - - /** - * Class defining filters that can be applied during fetching. - * Each filter consists of a list of items and a block that defines the filtering criteria. - */ - interface Filter { - operator fun invoke(items: List): List - } -} - - -/** - * Interface defining different states of data fetch operations. - */ -sealed interface StoreState> { - data object Initial : StoreState - data object Loading : StoreState - - sealed interface Loaded> : StoreState { - data class Single>(val data: Output) : Loaded - - data class Collection>(val data: Output) : Loaded - } - - sealed interface Error : StoreState { - data class Exception(val error: CustomException) : Error - data class Message(val error: String) : Error - } -} - - -/** - * Extension function on the Store class to provide a stateful composable. - * It manages the state of a fetch operation for a given key. - * - * @param key The key based on which data will be fetched. - * @return A composable function that returns the current state of the fetch operation. - */ -fun , Output : Identifiable> Store.stateful(key: Key): @Composable () -> StoreState { - - @Composable - fun launch(): StoreState { - // Remember and manage the fetch operation's state. - var state by rememberSaveable { mutableStateOf>(StoreState.Loading) } - - LaunchedEffect(key) { - state = try { - key as StoreKey.Collection - val output = this@stateful.get(key) as Identifiable.Collection - StoreState.Loaded.Collection(output) as StoreState - - } catch (error: Exception) { - // Handle and store exceptions in the state. - StoreState.Error.Exception(error) - } - } - - return state - } - - return ::launch -} - - -/** - * A custom LazyColumn that supports prefetching. - * Detects when the user is close to the end of the displayed items and triggers a fetch for subsequent data. - */ -@Composable -fun > PrefetchingLazyColumn( - items: List, - threshold: Int = 3, - onPrefetch: (nextCursor: Id) -> Unit, - content: @Composable LazyItemScope.(T) -> Unit -) { - LazyColumn { - itemsIndexed(items) { index, item -> - if (index >= items.size - threshold) { - onPrefetch(items.last().id) - } - content(item) - } - } -} - - -/** - * A custom LazyColumn that supports prefetching. - * Detects when the user is close to the end of the displayed items and triggers a fetch for subsequent data. - */ -@Composable -fun > PrefetchingLazyColumn( - items: List, - threshold: Int = 3, - onPrefetch: () -> Unit, - content: @Composable LazyItemScope.(Value) -> Unit -) { - LazyColumn { - itemsIndexed(items) { index, item -> - if (index >= items.size - threshold) { - onPrefetch() - } - content(item) - } - } -} - - -/** - * Extension function on the Store class to launch a paging store. - * For each key in the provided StateFlow, it maps to a flow that emits the corresponding store state. - * - * @param keys A StateFlow containing keys based on which data will be fetched. - * @param scope The coroutine scope in which the operations will be launched. - * @return A StateFlow that emits the store state corresponding to each key. - */ -@OptIn(ExperimentalCoroutinesApi::class) -fun , Output : Identifiable> Store.launchPagingStore( - keys: StateFlow, - scope: CoroutineScope -): StateFlow> { - // For each key, create a flow that computes and emits the store state. - return keys.flatMapConcat { key -> - flow { - try { - // Launch a molecule to reactively compute the store state for the given key. - val storeState = scope.launchMolecule(mode = RecompositionMode.ContextClock) { - stateful(key) - } - // Emit the computed store state to the resulting flow. - emit(storeState.value.invoke()) - } catch (error: Exception) { - // Handle potential errors during state computation. - emit(StoreState.Error.Exception(error)) - } - } - }.stateIn( - scope, - SharingStarted.Lazily, - StoreState.Initial - ) // Convert the flow to a StateFlow with an initial state. -} - -/** - * A composable function designed for cursor-based pagination in the Store. - * It manages and displays data based on different states: initial, loading, loaded, and error. - * - * @param key The initial key for fetching data. - * @param initialContent Composable to display during the initial state. - * @param loadingContent Composable to display during the loading state. - * @param errorContent Composable to display when an error occurs. - * @param onPrefetch Function to determine the next key based on the current cursor. - * @param content Composable to display the fetched items. - */ -@Composable -inline fun > Store, Identifiable>.cursor( - key: StoreKey, - crossinline initialContent: @Composable () -> Unit = {}, - crossinline loadingContent: @Composable () -> Unit = {}, - crossinline errorContent: @Composable (error: StoreState.Error) -> Unit = {}, - crossinline onPrefetch: (nextCursor: Id) -> StoreKey, - crossinline content: @Composable (Value) -> Unit -) { - // MutableStateFlow to hold the current key for data fetch. - val keys = MutableStateFlow(key) - // Remembered coroutine scope for launching coroutines in Compose. - val scope = rememberCoroutineScope() - // Current state of the store fetched using the launchPagingStore function. - val state = launchPagingStore(keys, scope).collectAsState() - - // Render UI based on the current state. - when (val storeState = state.value) { - is StoreState.Error -> errorContent(storeState) - StoreState.Initial -> initialContent() - is StoreState.Loaded.Collection<*, *> -> { - val items = storeState.data.items as List> - - PrefetchingLazyColumn( - items = items, - onPrefetch = { nextCursor -> - val nextKey = onPrefetch(nextCursor) - keys.value = nextKey - } - ) { item -> - if (item is Value) { - content(item) - } else { - errorContent(StoreState.Error.Message("Unexpected item type: ${item::class.simpleName}")) - } - } - } - - is StoreState.Loaded.Single<*, *> -> errorContent(StoreState.Error.Message("Single item pagination not supported.")) - StoreState.Loading -> loadingContent() - } -} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlowTests.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlowTests.kt new file mode 100644 index 000000000..208e5443f --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlowTests.kt @@ -0,0 +1,83 @@ +package org.mobilenativefoundation.store.paging5 + +import app.cash.turbine.test +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.mobilenativefoundation.store.paging5.util.* +import org.mobilenativefoundation.store.store5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.MutableStore +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@OptIn(ExperimentalStoreApi::class) +class InitStoreStateFlowTests { + private val testScope = TestScope() + + private val userId = "123" + private lateinit var api: PostApi + private lateinit var db: PostDatabase + private lateinit var store: MutableStore + + @Before + fun setup() { + api = FakePostApi() + db = FakePostDatabase(userId) + val factory = PostStoreFactory(api, db) + store = factory.create() + } + + @Test + fun `state transitions from Loading to Loaded Collection for valid Cursor key`() = testScope.runTest { + val key = PostKey.Cursor("1", 10) + val keys = flowOf(key) + val stateFlow = store.initStoreStateFlow(this, keys) + + stateFlow.test { + val state1 = awaitItem() + assertIs(state1) + val state2 = awaitItem() + assertIs>(state2) + expectNoEvents() + } + } + + @Test + fun `state transitions appropriately for multiple valid keys emitted in succession`() = testScope.runTest { + val key1 = PostKey.Cursor("1", 10) + val key2 = PostKey.Cursor("11", 10) + val keys = flowOf(key1, key2) + val stateFlow = store.initStoreStateFlow(this, keys) + + stateFlow.test { + val state1 = awaitItem() + assertIs(state1) + val state2 = awaitItem() + assertIs>(state2) + + val state3 = awaitItem() + assertIs>(state3) + assertEquals(20, state3.data.items.size) + + expectNoEvents() + } + } + + @Test + fun `state remains consistent if the same key is emitted multiple times`() = testScope.runTest { + val key = PostKey.Cursor("1", 10) + val keys = flowOf(key, key) + val stateFlow = store.initStoreStateFlow(this, keys) + + stateFlow.test { + val state1 = awaitItem() + assertIs(state1) + val state2 = awaitItem() + assertIs>(state2) + + expectNoEvents() + } + } +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt new file mode 100644 index 000000000..9622795b9 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt @@ -0,0 +1,70 @@ +package org.mobilenativefoundation.store.paging5 + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store.paging5.util.* +import org.mobilenativefoundation.store.store5.* +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@OptIn(ExperimentalStoreApi::class) +class PagingTests { + private val testScope = TestScope() + private val userId = "123" + + @Test + fun happyPath() = testScope.runTest { + val api = FakePostApi() + val db = FakePostDatabase(userId) + val factory = PostStoreFactory(api = api, db = db) + val store = factory.create() + + val key1 = PostKey.Cursor("1", 10) + val key2 = PostKey.Cursor("11", 10) + + flowOf(key1, key2).collect { key -> + val state = store.updateStoreState(StoreState.Initial, key) + assertIs>(state) + assertEquals(10, state.data.posts.size) + } + + val cached = store.stream(StoreReadRequest.cached(key1, refresh = false)) + .first { it.dataOrNull() != null } + assertIs>(cached) + assertEquals(StoreReadResponseOrigin.Cache, cached.origin) + val data = cached.requireData() + assertIs(data) + assertEquals(10, data.posts.size) + + val cached2 = store.stream(StoreReadRequest.cached(PostKey.Single("2"), refresh = false)) + .first { it.dataOrNull() != null } + assertIs>(cached2) + assertEquals(StoreReadResponseOrigin.Cache, cached2.origin) + val data2 = cached2.requireData() + assertIs(data2) + assertEquals("2", data2.title) + + + store.write(StoreWriteRequest.of(PostKey.Single("2"), PostData.Post("2", "2-modified"))) + + val cached3 = store.stream(StoreReadRequest.cached(PostKey.Single("2"), refresh = false)) + .first { it.dataOrNull() != null } + assertIs>(cached3) + assertEquals(StoreReadResponseOrigin.Cache, cached3.origin) + val data3 = cached3.requireData() + assertIs(data3) + assertEquals("2-modified", data3.title) + + val cached4 = + store.stream(StoreReadRequest.cached(PostKey.Cursor("1", 10), refresh = false)) + .first { it.dataOrNull() != null } + assertIs>(cached4) + assertEquals(StoreReadResponseOrigin.Cache, cached4.origin) + val data4 = cached4.requireData() + assertIs(data4) + assertEquals("2-modified", data4.posts[1].title) + } +} \ No newline at end of file diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt new file mode 100644 index 000000000..6751790f3 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt @@ -0,0 +1,39 @@ +package org.mobilenativefoundation.store.paging5.util + +class FakePostApi : PostApi { + + private val posts = mutableMapOf() + private val postsList = mutableListOf() + + init { + (1..100).forEach { + val id = it.toString() + posts[id] = PostData.Post(id, id) + postsList.add(PostData.Post(id, id)) + } + } + + override suspend fun get(postId: String): PostGetRequestResult { + val post = posts[postId] + return if (post != null) { + println("HITTING 2 :)") + PostGetRequestResult.Data(post) + } else { + println("HITTING 2 :(") + PostGetRequestResult.Error.Message("Post $postId was not found") + } + } + + override suspend fun get(cursor: String?, size: Int): FeedGetRequestResult { + val firstIndexInclusive = postsList.indexOfFirst { it.postId == cursor } + val lastIndexExclusive = firstIndexInclusive + size + val posts = postsList.subList(firstIndexInclusive, lastIndexExclusive) + return FeedGetRequestResult.Data(PostData.Feed(posts = posts)) + } + + override suspend fun put(post: PostData.Post): PostPutRequestResult { + posts.put(post.id, post) + return PostPutRequestResult.Data(post) + } + +} \ No newline at end of file diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt new file mode 100644 index 000000000..6795acd48 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt @@ -0,0 +1,38 @@ +package org.mobilenativefoundation.store.paging5.util + +class FakePostDatabase(private val userId: String) : PostDatabase { + private val posts = mutableMapOf() + private val feeds = mutableMapOf() + override fun add(post: PostData.Post) { + posts[post.id] = post + + val nextFeed = feeds[userId]?.posts?.map { + if (it.postId == post.postId) { + post + } else { + it + } + } + + nextFeed?.let { + feeds[userId] = PostData.Feed(nextFeed) + println("UPDATED FEED $it") + } + } + + override fun add(feed: PostData.Feed) { + feeds[userId] = feed + } + + override fun findPostByPostId(postId: String): PostData.Post? { + return posts[postId] + } + + override fun findFeedByUserId(cursor: String?, size: Int): PostData.Feed? { + val feed = feeds[userId] + println("FEED RETURNING = $feed") + return feed + + } + +} \ No newline at end of file diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FeedGetRequestResult.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FeedGetRequestResult.kt new file mode 100644 index 000000000..8e6081a8d --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FeedGetRequestResult.kt @@ -0,0 +1,10 @@ +package org.mobilenativefoundation.store.paging5.util + +sealed class FeedGetRequestResult { + + data class Data(val data: PostData.Feed) : FeedGetRequestResult() + sealed class Error : FeedGetRequestResult() { + data class Message(val error: String) : Error() + data class Exception(val error: kotlin.Exception) : Error() + } +} \ No newline at end of file diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostApi.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostApi.kt new file mode 100644 index 000000000..fd733e301 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostApi.kt @@ -0,0 +1,7 @@ +package org.mobilenativefoundation.store.paging5.util + +interface PostApi { + suspend fun get(postId: String): PostGetRequestResult + suspend fun get(cursor: String?, size: Int): FeedGetRequestResult + suspend fun put(post: PostData.Post): PostPutRequestResult +} \ No newline at end of file diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt new file mode 100644 index 000000000..4a12bad09 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt @@ -0,0 +1,34 @@ +package org.mobilenativefoundation.store.paging5.util + +import org.mobilenativefoundation.store.paging5.Identifiable +import org.mobilenativefoundation.store.paging5.StoreKey + +sealed class PostData : Identifiable { + data class Post(val postId: String, val title: String) : Identifiable.Single, PostData() { + override val id: String get() = postId + } + + data class Feed(val posts: List) : Identifiable.Collection, PostData() { + override val items: List get() = posts + override fun copyWith(items: List): Identifiable.Collection = copy(posts = items) + override fun insertItems(type: StoreKey.LoadType, items: List): Identifiable.Collection { + + return when (type) { + StoreKey.LoadType.APPEND -> { + val updatedItems = items.toMutableList() + updatedItems.addAll(posts) + copyWith(items = updatedItems) + } + + StoreKey.LoadType.PREPEND -> { + val updatedItems = posts.toMutableList() + updatedItems.addAll(items) + copyWith(items = updatedItems) + } + } + } + } +} + + + diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDatabase.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDatabase.kt new file mode 100644 index 000000000..38b3d40b0 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDatabase.kt @@ -0,0 +1,8 @@ +package org.mobilenativefoundation.store.paging5.util + +interface PostDatabase { + fun add(post: PostData.Post) + fun add(feed: PostData.Feed) + fun findPostByPostId(postId: String): PostData.Post? + fun findFeedByUserId(cursor: String?, size: Int): PostData.Feed? +} \ No newline at end of file diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostGetRequestResult.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostGetRequestResult.kt new file mode 100644 index 000000000..f8f3e31ac --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostGetRequestResult.kt @@ -0,0 +1,10 @@ +package org.mobilenativefoundation.store.paging5.util + +sealed class PostGetRequestResult { + + data class Data(val data: PostData.Post) : PostGetRequestResult() + sealed class Error : PostGetRequestResult() { + data class Message(val error: String) : Error() + data class Exception(val error: kotlin.Exception) : Error() + } +} \ No newline at end of file diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt new file mode 100644 index 000000000..8feba6865 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt @@ -0,0 +1,18 @@ +package org.mobilenativefoundation.store.paging5.util + +import org.mobilenativefoundation.store.paging5.StoreKey + +sealed class PostKey : StoreKey { + data class Cursor( + override val cursor: String?, + override val size: Int, + override val sort: StoreKey.Sort? = null, + override val filters: List>? = null, + override val loadType: StoreKey.LoadType = StoreKey.LoadType.PREPEND + ) : StoreKey.Collection.Cursor, PostKey() + + data class Single( + override val id: String + ) : StoreKey.Single, PostKey() + +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostPutRequestResult.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostPutRequestResult.kt new file mode 100644 index 000000000..8fd415099 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostPutRequestResult.kt @@ -0,0 +1,10 @@ +package org.mobilenativefoundation.store.paging5.util + +sealed class PostPutRequestResult { + + data class Data(val data: PostData.Post) : PostPutRequestResult() + sealed class Error : PostPutRequestResult() { + data class Message(val error: String) : Error() + data class Exception(val error: kotlin.Exception) : Error() + } +} \ No newline at end of file diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt new file mode 100644 index 000000000..51b368048 --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt @@ -0,0 +1,134 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store.paging5.util + +import kotlinx.coroutines.flow.flow +import org.mobilenativefoundation.store.cache5.Cache +import org.mobilenativefoundation.store.paging5.KeyProvider +import org.mobilenativefoundation.store.paging5.PagingCache +import org.mobilenativefoundation.store.paging5.StoreKey +import org.mobilenativefoundation.store.store5.* +import kotlin.math.floor + +typealias PostStore = MutableStore + +class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { + + private fun createFetcher(): Fetcher = Fetcher.of { key -> + println("HITTING IN FETCHER") + when (key) { + is PostKey.Single -> { + when (val result = api.get(key.id)) { + is PostGetRequestResult.Data -> { + result.data + } + + is PostGetRequestResult.Error.Exception -> { + throw Throwable(result.error) + } + + is PostGetRequestResult.Error.Message -> { + throw Throwable(result.error) + } + } + + } + + is PostKey.Cursor -> { + when (val result = api.get(key.cursor, key.size)) { + is FeedGetRequestResult.Data -> { + result.data + } + + is FeedGetRequestResult.Error.Exception -> { + throw Throwable(result.error) + } + + is FeedGetRequestResult.Error.Message -> { + throw Throwable(result.error) + } + } + } + } + } + + private fun createSourceOfTruth(): SourceOfTruth = SourceOfTruth.of( + reader = { key -> + println("HITTING IN SOT") + flow { + when (key) { + is PostKey.Single -> { + val post = db.findPostByPostId(key.id) + emit(post) + } + + is PostKey.Cursor -> { + val feed = db.findFeedByUserId(key.cursor, key.size) + emit(feed) + } + } + } + }, + writer = { key, data -> + when { + key is PostKey.Single && data is PostData.Post -> { + db.add(data) + } + + key is PostKey.Cursor && data is PostData.Feed -> { + db.add(data) + } + } + } + ) + + private fun createConverter(): Converter = + Converter.Builder() + .fromNetworkToLocal { it } + .fromOutputToLocal { it } + .build() + + private fun createUpdater(): Updater = Updater.by( + post = { key, data -> + when { + key is PostKey.Single && data is PostData.Post -> { + when (val result = api.put(data)) { + is PostPutRequestResult.Data -> UpdaterResult.Success.Typed(result) + is PostPutRequestResult.Error.Exception -> UpdaterResult.Error.Exception(result.error) + is PostPutRequestResult.Error.Message -> UpdaterResult.Error.Message(result.error) + } + } + + else -> UpdaterResult.Error.Message("Unsupported: key: ${key::class}, data: ${data::class}") + } + } + ) + + private fun createPagingCacheKeyProvider(): KeyProvider = + object : KeyProvider { + override fun from(key: StoreKey.Collection, value: PostData.Post): StoreKey.Single { + return PostKey.Single(value.postId) + } + + override fun from(key: StoreKey.Single, value: PostData.Post): StoreKey.Collection { + val id = value.postId.toInt() + val cursor = (floor(id.toDouble() / 10) * 10) + 1 + return PostKey.Cursor(cursor.toInt().toString(), 10) + } + + } + + private fun createMemoryCache(): Cache = + PagingCache(createPagingCacheKeyProvider()) + + fun create(): MutableStore = StoreBuilder.from( + fetcher = createFetcher(), + sourceOfTruth = createSourceOfTruth(), + memoryCache = createMemoryCache() + ).toMutableStoreBuilder( + converter = createConverter() + ).build( + updater = createUpdater(), + bookkeeper = null + ) +} \ No newline at end of file diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt index 29f41984d..d72a62859 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt @@ -2,12 +2,7 @@ package org.mobilenativefoundation.store.store5.impl.extensions import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.first -import org.mobilenativefoundation.store.store5.Bookkeeper -import org.mobilenativefoundation.store.store5.MutableStore -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.Updater +import org.mobilenativefoundation.store.store5.* import org.mobilenativefoundation.store.store5.impl.RealMutableStore import org.mobilenativefoundation.store.store5.impl.RealStore @@ -49,3 +44,18 @@ fun Store< bookkeeper = bookkeeper ) } + + +@OptIn(ExperimentalStoreApi::class) +suspend fun MutableStore.get(key: Key) = + stream(StoreReadRequest.cached(key, refresh = false)) + .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } + .first() + .requireData() + +@OptIn(ExperimentalStoreApi::class) +suspend fun MutableStore.fresh(key: Key) = + stream(StoreReadRequest.fresh(key)) + .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } + .first() + .requireData() \ No newline at end of file From df423105586a487c4c6dc0164ed6bcdee5254485 Mon Sep 17 00:00:00 2001 From: mramotar Date: Wed, 18 Oct 2023 10:10:53 -0400 Subject: [PATCH 03/17] Clean up Signed-off-by: mramotar_dbx --- .../store/paging5/InitStoreStateFlow.kt | 65 --------- .../store/paging5/KeyProvider.kt | 2 +- .../store/paging5/LaunchStore.kt | 135 ++++++++++++++++++ .../store/paging5/PagingCache.kt | 2 +- .../store/paging5/PagingCacheAccessor.kt | 2 +- .../paging5/{Identifiable.kt => StoreData.kt} | 8 +- .../store/paging5/StoreState.kt | 8 +- .../store/paging5/UpdateStoreState.kt | 89 ------------ ...eStateFlowTests.kt => LaunchStoreTests.kt} | 8 +- .../store/paging5/PagingTests.kt | 16 ++- .../store/paging5/util/PostData.kt | 12 +- 11 files changed, 168 insertions(+), 179 deletions(-) delete mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlow.kt create mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchStore.kt rename paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/{Identifiable.kt => StoreData.kt} (74%) delete mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/UpdateStoreState.kt rename paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/{InitStoreStateFlowTests.kt => LaunchStoreTests.kt} (92%) diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlow.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlow.kt deleted file mode 100644 index cc64a442b..000000000 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlow.kt +++ /dev/null @@ -1,65 +0,0 @@ -package org.mobilenativefoundation.store.paging5 - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import org.mobilenativefoundation.store.store5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.MutableStore -import org.mobilenativefoundation.store.store5.Store - - -/** - * Initializes and returns a [StateFlow] that reflects the state of the Store, updating by a flow of provided keys. - * @param scope A [CoroutineScope]. - * @param keys A flow of keys that dictate how the Store should be updated. - * @param updateStoreState A lambda that defines how the Store's state should be updated based on the current state and a key. - * @return A read-only [StateFlow] reflecting the state of the Store. - */ -private fun , Output : Identifiable> initStoreStateFlow( - scope: CoroutineScope, - keys: Flow, - updateStoreState: suspend (currentState: StoreState, key: Key) -> StoreState -): StateFlow> { - val stateFlow = MutableStateFlow>(StoreState.Loading) - - scope.launch { - keys.collect { key -> - println("KEY = $key") - println("CURRENT STATE = ${stateFlow.value}") - val updatedState = updateStoreState(stateFlow.value, key) - stateFlow.emit(updatedState) - } - } - - return stateFlow.asStateFlow() -} - -/** - * Initializes and returns a [StateFlow] that reflects the state of the [Store], updating by a flow of provided keys. - * @see [initStoreStateFlow]. - */ -fun , Output : Identifiable> Store.initStoreStateFlow( - scope: CoroutineScope, - keys: Flow, -): StateFlow> { - return initStoreStateFlow(scope, keys) { currentState, key -> - this.updateStoreState(currentState, key) - } -} - -/** - * Initializes and returns a [StateFlow] that reflects the state of the [Store], updating by a flow of provided keys. - * @see [initStoreStateFlow]. - */ -@OptIn(ExperimentalStoreApi::class) -fun , Output : Identifiable> MutableStore.initStoreStateFlow( - scope: CoroutineScope, - keys: Flow, -): StateFlow> { - return initStoreStateFlow(scope, keys) { currentState, key -> - this.updateStoreState(currentState, key) - } -} diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyProvider.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyProvider.kt index 48f351748..36c7602d9 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyProvider.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyProvider.kt @@ -1,6 +1,6 @@ package org.mobilenativefoundation.store.paging5 -interface KeyProvider> { +interface KeyProvider> { fun from(key: StoreKey.Collection, value: Single): StoreKey.Single fun from(key: StoreKey.Single, value: Single): StoreKey.Collection } \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchStore.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchStore.kt new file mode 100644 index 000000000..8b7d302e6 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchStore.kt @@ -0,0 +1,135 @@ +@file:Suppress("UNCHECKED_CAST") + + +package org.mobilenativefoundation.store.paging5 + + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.mobilenativefoundation.store.store5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.MutableStore +import org.mobilenativefoundation.store.store5.Store +import org.mobilenativefoundation.store.store5.impl.extensions.fresh + + +typealias LoadedCollection = StoreState.Loaded.Collection, StoreData.Collection>> + + +/** + * Initializes and returns a [StateFlow] that reflects the state of the Store, updating by a flow of provided keys. + * @param scope A [CoroutineScope]. + * @param keys A flow of keys that dictate how the Store should be updated. + * @param fresh A lambda that invokes [Store.fresh]. + * @return A read-only [StateFlow] reflecting the state of the Store. + */ +private fun , Output : StoreData> launchStore( + scope: CoroutineScope, + keys: Flow, + fresh: suspend (currentState: StoreState, key: Key) -> StoreState +): StateFlow> { + val stateFlow = MutableStateFlow>(StoreState.Loading) + + scope.launch { + keys.collect { key -> + val nextState = fresh(stateFlow.value, key) + stateFlow.emit(nextState) + } + } + + return stateFlow.asStateFlow() +} + +/** + * Initializes and returns a [StateFlow] that reflects the state of the [Store], updating by a flow of provided keys. + * @see [launchStore]. + */ +fun , Output : StoreData> Store.launchStore( + scope: CoroutineScope, + keys: Flow, +): StateFlow> { + return launchStore(scope, keys) { currentState, key -> + this.freshAndInsertUpdatedItems(currentState, key) + } +} + +/** + * Initializes and returns a [StateFlow] that reflects the state of the [Store], updating by a flow of provided keys. + * @see [launchStore]. + */ +@OptIn(ExperimentalStoreApi::class) +fun , Output : StoreData> MutableStore.launchStore( + scope: CoroutineScope, + keys: Flow, +): StateFlow> { + return launchStore(scope, keys) { currentState, key -> + this.freshAndInsertUpdatedItems(currentState, key) + } +} + + +/** + * Updates the Store's state based on a provided key and a retrieval mechanism. + * @param currentState The current state of the Store. + * @param key The key that dictates how the state should be updated. + * @param get A lambda that defines how to retrieve data from the Store based on a key. + */ +private suspend fun , Output : StoreData> freshAndInsertUpdatedItems( + currentState: StoreState, + key: Key, + get: suspend (key: Key) -> Output, +): StoreState { + return try { + if (key !is StoreKey.Collection<*>) throw IllegalArgumentException("Invalid key type") + + val lastOutput = when (currentState) { + is StoreState.Loaded.Collection<*, *, *> -> (currentState as LoadedCollection).data + else -> null + } + + val nextOutput = get(key) as StoreData.Collection> + + val output = (lastOutput?.insertItems(key.loadType, nextOutput.items) ?: nextOutput) + StoreState.Loaded.Collection(output) as StoreState + + } catch (error: Exception) { + StoreState.Error.Exception(error) + } +} + +/** + * Updates the [Store]'s state based on a provided key. + * @see [freshAndInsertUpdatedItems]. + */ +private suspend fun , Output : StoreData> Store.freshAndInsertUpdatedItems( + currentState: StoreState, + key: Key +): StoreState { + return freshAndInsertUpdatedItems( + currentState, + key + ) { + this.fresh(it) + } +} + +/** + * Updates the [MutableStore]'s state based on a provided key. + * @see [freshAndInsertUpdatedItems]. + */ +@OptIn(ExperimentalStoreApi::class) +private suspend fun , Output : StoreData> MutableStore.freshAndInsertUpdatedItems( + currentState: StoreState, + key: Key +): StoreState { + return freshAndInsertUpdatedItems( + currentState, + key + ) { + this.fresh(it) + } +} + diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCache.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCache.kt index f9737a264..5455e525f 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCache.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCache.kt @@ -10,7 +10,7 @@ import org.mobilenativefoundation.store.cache5.Cache * Depends on [PagingCacheAccessor] for internal data management. * @see [Cache]. */ -class PagingCache, StoreOutput : Identifiable, Collection : Identifiable.Collection, Single : Identifiable.Single>( +class PagingCache, StoreOutput : StoreData, Collection : StoreData.Collection, Single : StoreData.Single>( private val keyProvider: KeyProvider, ) : Cache { diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCacheAccessor.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCacheAccessor.kt index 83e6f67fb..23b531211 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCacheAccessor.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCacheAccessor.kt @@ -6,7 +6,7 @@ import org.mobilenativefoundation.store.cache5.CacheBuilder * Intermediate data manager for a caching system supporting pagination. * Tracks keys for rapid data retrieval and modification. */ -class PagingCacheAccessor, Single : Identifiable.Single> { +class PagingCacheAccessor, Single : StoreData.Single> { private val collections = CacheBuilder, Collection>().build() private val singles = CacheBuilder, Single>().build() private val keys = mutableSetOf>() diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Identifiable.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreData.kt similarity index 74% rename from paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Identifiable.kt rename to paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreData.kt index 55871e763..bc42ee553 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Identifiable.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreData.kt @@ -3,23 +3,23 @@ package org.mobilenativefoundation.store.paging5 /** * An interface that defines items that can be uniquely identified. - * Every item that implements the [Identifiable] interface must have a means of identification. + * Every item that implements the [StoreData] interface must have a means of identification. * This is useful in scenarios when data can be represented as singles or collections. */ -interface Identifiable { +interface StoreData { /** * Represents a single identifiable item. */ - interface Single : Identifiable { + interface Single : StoreData { val id: Id } /** * Represents a collection of identifiable items. */ - interface Collection> : Identifiable { + interface Collection> : StoreData { val items: List /** diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreState.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreState.kt index 9c321c490..b8d1943d5 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreState.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreState.kt @@ -3,7 +3,7 @@ package org.mobilenativefoundation.store.paging5 /** * An interface that defines various states of data-fetching operations. */ -sealed interface StoreState> { +sealed interface StoreState> { /** * Represents the initial state. @@ -19,17 +19,17 @@ sealed interface StoreState> { /** * Represents successful fetch operations. */ - sealed interface Loaded> : StoreState { + sealed interface Loaded> : StoreState { /** * Represents a successful fetch of an individual item. */ - data class Single>(val data: Output) : Loaded + data class Single>(val data: Output) : Loaded /** * Represents a successful fetch of a collection of items. */ - data class Collection, CO : Identifiable.Collection>(val data: CO) : + data class Collection, CO : StoreData.Collection>(val data: CO) : Loaded } diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/UpdateStoreState.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/UpdateStoreState.kt deleted file mode 100644 index 743cd4e54..000000000 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/UpdateStoreState.kt +++ /dev/null @@ -1,89 +0,0 @@ -@file:Suppress("UNCHECKED_CAST") - -package org.mobilenativefoundation.store.paging5 - -import org.mobilenativefoundation.store.store5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.MutableStore -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.impl.extensions.fresh - - -typealias LoadedCollection = StoreState.Loaded.Collection, Identifiable.Collection>> - - -/** - * Updates the Store's state based on a provided key and a retrieval mechanism. - * @param currentState The current state of the Store. - * @param key The key that dictates how the state should be updated. - * @param get A lambda that defines how to retrieve data from the Store based on a key. - */ -private suspend fun , Output : Identifiable> updateStoreState( - currentState: StoreState, - key: Key, - get: suspend (key: Key) -> Output, -): StoreState { - return try { - if (key !is StoreKey.Collection<*>) throw IllegalArgumentException("Invalid key type") - - val lastOutput = when (currentState) { - is StoreState.Loaded.Collection<*, *, *> -> { - val data = (currentState as LoadedCollection).data - println("DATA = $data") - data - } - - else -> { - println("NULL") - null - } - } - - val nextOutput = get(key) as Identifiable.Collection> - - val output = (lastOutput?.insertItems(key.loadType, nextOutput.items) ?: nextOutput) - - println("OUTPUT * = $lastOutput $output") - StoreState.Loaded.Collection(output) as StoreState - - } catch (error: Exception) { - StoreState.Error.Exception(error) - } -} - -/** - * Updates the [Store]'s state based on a provided key. - * @see [updateStoreState]. - */ -suspend fun , Output : Identifiable> Store.updateStoreState( - currentState: StoreState, - key: Key -): StoreState { - return updateStoreState( - currentState, - key - ) { - this.fresh(it) - } -} - -/** - * Updates the [MutableStore]'s state based on a provided key. - * @see [updateStoreState]. - */ -@OptIn(ExperimentalStoreApi::class) -suspend fun , Output : Identifiable> MutableStore.updateStoreState( - currentState: StoreState, - key: Key -): StoreState { - return updateStoreState( - currentState, - key - ) { - val output = this.fresh(it) - println("KEY = $key") - println("OUTPUT = $output") - println("CURRENT STATE = $currentState") - output - } -} - diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlowTests.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchStoreTests.kt similarity index 92% rename from paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlowTests.kt rename to paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchStoreTests.kt index 208e5443f..484016ae6 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/InitStoreStateFlowTests.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchStoreTests.kt @@ -13,7 +13,7 @@ import kotlin.test.assertEquals import kotlin.test.assertIs @OptIn(ExperimentalStoreApi::class) -class InitStoreStateFlowTests { +class LaunchStoreTests { private val testScope = TestScope() private val userId = "123" @@ -33,7 +33,7 @@ class InitStoreStateFlowTests { fun `state transitions from Loading to Loaded Collection for valid Cursor key`() = testScope.runTest { val key = PostKey.Cursor("1", 10) val keys = flowOf(key) - val stateFlow = store.initStoreStateFlow(this, keys) + val stateFlow = store.launchStore(this, keys) stateFlow.test { val state1 = awaitItem() @@ -49,7 +49,7 @@ class InitStoreStateFlowTests { val key1 = PostKey.Cursor("1", 10) val key2 = PostKey.Cursor("11", 10) val keys = flowOf(key1, key2) - val stateFlow = store.initStoreStateFlow(this, keys) + val stateFlow = store.launchStore(this, keys) stateFlow.test { val state1 = awaitItem() @@ -69,7 +69,7 @@ class InitStoreStateFlowTests { fun `state remains consistent if the same key is emitted multiple times`() = testScope.runTest { val key = PostKey.Cursor("1", 10) val keys = flowOf(key, key) - val stateFlow = store.initStoreStateFlow(this, keys) + val stateFlow = store.launchStore(this, keys) stateFlow.test { val state1 = awaitItem() diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt index 9622795b9..1731940cc 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt @@ -1,5 +1,6 @@ package org.mobilenativefoundation.store.paging5 +import app.cash.turbine.test import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope @@ -24,11 +25,18 @@ class PagingTests { val key1 = PostKey.Cursor("1", 10) val key2 = PostKey.Cursor("11", 10) + val keys = flowOf(key1, key2) - flowOf(key1, key2).collect { key -> - val state = store.updateStoreState(StoreState.Initial, key) - assertIs>(state) - assertEquals(10, state.data.posts.size) + val stateFlow = store.launchStore(this, keys) + stateFlow.test { + val loadingState = awaitItem() + assertIs(loadingState) + val loadedState1 = awaitItem() + assertIs>(loadedState1) + assertEquals(10, loadedState1.data.posts.size) + val loadedState2 = awaitItem() + assertIs>(loadedState2) + assertEquals(20, loadedState2.data.posts.size) } val cached = store.stream(StoreReadRequest.cached(key1, refresh = false)) diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt index 4a12bad09..db4e2dd34 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt @@ -1,17 +1,17 @@ package org.mobilenativefoundation.store.paging5.util -import org.mobilenativefoundation.store.paging5.Identifiable +import org.mobilenativefoundation.store.paging5.StoreData import org.mobilenativefoundation.store.paging5.StoreKey -sealed class PostData : Identifiable { - data class Post(val postId: String, val title: String) : Identifiable.Single, PostData() { +sealed class PostData : StoreData { + data class Post(val postId: String, val title: String) : StoreData.Single, PostData() { override val id: String get() = postId } - data class Feed(val posts: List) : Identifiable.Collection, PostData() { + data class Feed(val posts: List) : StoreData.Collection, PostData() { override val items: List get() = posts - override fun copyWith(items: List): Identifiable.Collection = copy(posts = items) - override fun insertItems(type: StoreKey.LoadType, items: List): Identifiable.Collection { + override fun copyWith(items: List): StoreData.Collection = copy(posts = items) + override fun insertItems(type: StoreKey.LoadType, items: List): StoreData.Collection { return when (type) { StoreKey.LoadType.APPEND -> { From 5cf81534e993724b5e8af05a41094563d509df5d Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Wed, 18 Oct 2023 11:40:45 -0400 Subject: [PATCH 04/17] Remove StoreState and use StoreReadResponse Signed-off-by: mramotar_dbx --- .../store/paging5/LaunchStore.kt | 133 +++++++----------- .../store/paging5/StoreState.kt | 51 ------- .../store/paging5/LaunchStoreTests.kt | 29 ++-- .../store/paging5/PagingTests.kt | 14 +- .../store/store5/StoreReadResponse.kt | 7 + .../store/store5/impl/RealStore.kt | 1 + 6 files changed, 90 insertions(+), 145 deletions(-) delete mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreState.kt diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchStore.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchStore.kt index 8b7d302e6..1b8d7f647 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchStore.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchStore.kt @@ -5,38 +5,59 @@ package org.mobilenativefoundation.store.paging5 import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import org.mobilenativefoundation.store.store5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.MutableStore -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.impl.extensions.fresh +import org.mobilenativefoundation.store.store5.* - -typealias LoadedCollection = StoreState.Loaded.Collection, StoreData.Collection>> +private class StopProcessingException : Exception() /** * Initializes and returns a [StateFlow] that reflects the state of the Store, updating by a flow of provided keys. * @param scope A [CoroutineScope]. * @param keys A flow of keys that dictate how the Store should be updated. - * @param fresh A lambda that invokes [Store.fresh]. + * @param stream A lambda that invokes [Store.stream]. * @return A read-only [StateFlow] reflecting the state of the Store. */ private fun , Output : StoreData> launchStore( scope: CoroutineScope, keys: Flow, - fresh: suspend (currentState: StoreState, key: Key) -> StoreState -): StateFlow> { - val stateFlow = MutableStateFlow>(StoreState.Loading) + stream: (key: Key) -> Flow>, +): StateFlow> { + val stateFlow = MutableStateFlow>(StoreReadResponse.Initial) + scope.launch { - keys.collect { key -> - val nextState = fresh(stateFlow.value, key) - stateFlow.emit(nextState) + + try { + val firstKey = keys.first() + if (firstKey !is StoreKey.Collection<*>) throw IllegalArgumentException("Invalid key type") + + stream(firstKey).collect { response -> + if (response is StoreReadResponse.Data) { + val joinedDataResponse = joinData(firstKey, stateFlow.value, response) + stateFlow.emit(joinedDataResponse) + } else { + stateFlow.emit(response) + } + + if (response is StoreReadResponse.Data || + response is StoreReadResponse.Error || + response is StoreReadResponse.NoNewData + ) { + throw StopProcessingException() + } + } + + } catch (_: StopProcessingException) { + + } + + keys.drop(1).collect { key -> + if (key !is StoreKey.Collection<*>) throw IllegalArgumentException("Invalid key type") + val firstDataResponse = stream(key).first { it.dataOrNull() != null } as StoreReadResponse.Data + val joinedDataResponse = joinData(key, stateFlow.value, firstDataResponse) + stateFlow.emit(joinedDataResponse) } } @@ -50,9 +71,9 @@ private fun , Output : StoreData> launchStore( fun , Output : StoreData> Store.launchStore( scope: CoroutineScope, keys: Flow, -): StateFlow> { - return launchStore(scope, keys) { currentState, key -> - this.freshAndInsertUpdatedItems(currentState, key) +): StateFlow> { + return launchStore(scope, keys) { key -> + this.stream(StoreReadRequest.fresh(key)) } } @@ -64,72 +85,26 @@ fun , Output : StoreData> Store.la fun , Output : StoreData> MutableStore.launchStore( scope: CoroutineScope, keys: Flow, -): StateFlow> { - return launchStore(scope, keys) { currentState, key -> - this.freshAndInsertUpdatedItems(currentState, key) +): StateFlow> { + return launchStore(scope, keys) { key -> + this.stream(StoreReadRequest.fresh(key)) } } -/** - * Updates the Store's state based on a provided key and a retrieval mechanism. - * @param currentState The current state of the Store. - * @param key The key that dictates how the state should be updated. - * @param get A lambda that defines how to retrieve data from the Store based on a key. - */ -private suspend fun , Output : StoreData> freshAndInsertUpdatedItems( - currentState: StoreState, +private fun , Output : StoreData> joinData( key: Key, - get: suspend (key: Key) -> Output, -): StoreState { - return try { - if (key !is StoreKey.Collection<*>) throw IllegalArgumentException("Invalid key type") - - val lastOutput = when (currentState) { - is StoreState.Loaded.Collection<*, *, *> -> (currentState as LoadedCollection).data - else -> null - } - - val nextOutput = get(key) as StoreData.Collection> - - val output = (lastOutput?.insertItems(key.loadType, nextOutput.items) ?: nextOutput) - StoreState.Loaded.Collection(output) as StoreState - - } catch (error: Exception) { - StoreState.Error.Exception(error) + prevResponse: StoreReadResponse, + currentResponse: StoreReadResponse.Data +): StoreReadResponse.Data { + val lastOutput = when (prevResponse) { + is StoreReadResponse.Data -> prevResponse.value as? StoreData.Collection> + else -> null } -} -/** - * Updates the [Store]'s state based on a provided key. - * @see [freshAndInsertUpdatedItems]. - */ -private suspend fun , Output : StoreData> Store.freshAndInsertUpdatedItems( - currentState: StoreState, - key: Key -): StoreState { - return freshAndInsertUpdatedItems( - currentState, - key - ) { - this.fresh(it) - } -} + val currentData = currentResponse.value as StoreData.Collection> -/** - * Updates the [MutableStore]'s state based on a provided key. - * @see [freshAndInsertUpdatedItems]. - */ -@OptIn(ExperimentalStoreApi::class) -private suspend fun , Output : StoreData> MutableStore.freshAndInsertUpdatedItems( - currentState: StoreState, - key: Key -): StoreState { - return freshAndInsertUpdatedItems( - currentState, - key - ) { - this.fresh(it) - } + val joinedOutput = (lastOutput?.insertItems(key.loadType, currentData.items) ?: currentData) as Output + return StoreReadResponse.Data(joinedOutput, currentResponse.origin) } diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreState.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreState.kt deleted file mode 100644 index b8d1943d5..000000000 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreState.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.mobilenativefoundation.store.paging5 - -/** - * An interface that defines various states of data-fetching operations. - */ -sealed interface StoreState> { - - /** - * Represents the initial state. - */ - data object Initial : StoreState - - /** - * Represents the loading state. - */ - data object Loading : StoreState - - - /** - * Represents successful fetch operations. - */ - sealed interface Loaded> : StoreState { - - /** - * Represents a successful fetch of an individual item. - */ - data class Single>(val data: Output) : Loaded - - /** - * Represents a successful fetch of a collection of items. - */ - data class Collection, CO : StoreData.Collection>(val data: CO) : - Loaded - } - - /** - * Represents unsuccessful fetch operations. - */ - sealed interface Error : StoreState { - - /** - * Represents an unsuccessful fetch operation due to an exception. - */ - data class Exception(val error: CustomException) : Error - - /** - * Represents an unsuccessful fetch operation due to an error with a message. - */ - data class Message(val error: String) : Error - } -} \ No newline at end of file diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchStoreTests.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchStoreTests.kt index 484016ae6..eb8358e58 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchStoreTests.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchStoreTests.kt @@ -9,6 +9,7 @@ import org.junit.Test import org.mobilenativefoundation.store.paging5.util.* import org.mobilenativefoundation.store.store5.ExperimentalStoreApi import org.mobilenativefoundation.store.store5.MutableStore +import org.mobilenativefoundation.store.store5.StoreReadResponse import kotlin.test.assertEquals import kotlin.test.assertIs @@ -37,9 +38,11 @@ class LaunchStoreTests { stateFlow.test { val state1 = awaitItem() - assertIs(state1) + assertIs(state1) val state2 = awaitItem() - assertIs>(state2) + assertIs(state2) + val state3 = awaitItem() + assertIs>(state3) expectNoEvents() } } @@ -53,13 +56,18 @@ class LaunchStoreTests { stateFlow.test { val state1 = awaitItem() - assertIs(state1) + assertIs(state1) val state2 = awaitItem() - assertIs>(state2) - + assertIs(state2) val state3 = awaitItem() - assertIs>(state3) - assertEquals(20, state3.data.items.size) + assertIs>(state3) + expectNoEvents() + + val state4 = awaitItem() + assertIs>(state4) + val data4 = state4.value + assertIs(data4) + assertEquals(20, data4.items.size) expectNoEvents() } @@ -73,10 +81,11 @@ class LaunchStoreTests { stateFlow.test { val state1 = awaitItem() - assertIs(state1) + assertIs(state1) val state2 = awaitItem() - assertIs>(state2) - + assertIs(state2) + val state3 = awaitItem() + assertIs>(state3) expectNoEvents() } } diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt index 1731940cc..7bbf8f56f 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt @@ -29,14 +29,18 @@ class PagingTests { val stateFlow = store.launchStore(this, keys) stateFlow.test { + val initialState = awaitItem() + assertIs(initialState) val loadingState = awaitItem() - assertIs(loadingState) + assertIs(loadingState) val loadedState1 = awaitItem() - assertIs>(loadedState1) - assertEquals(10, loadedState1.data.posts.size) + assertIs>(loadedState1) + val data1 = loadedState1.value + assertEquals(10, data1.posts.size) val loadedState2 = awaitItem() - assertIs>(loadedState2) - assertEquals(20, loadedState2.data.posts.size) + assertIs>(loadedState2) + val data2 = loadedState2.value + assertEquals(20, data2.posts.size) } val cached = store.stream(StoreReadRequest.cached(key1, refresh = false)) diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponse.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponse.kt index e71dd93a3..658baa216 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponse.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponse.kt @@ -28,6 +28,10 @@ sealed class StoreReadResponse { */ abstract val origin: StoreReadResponseOrigin + object Initial : StoreReadResponse() { + override val origin: StoreReadResponseOrigin = StoreReadResponseOrigin.Initial + } + /** * Loading event dispatched by [Store] to signal the [Fetcher] is in progress. */ @@ -107,6 +111,7 @@ sealed class StoreReadResponse { is Loading -> this is NoNewData -> this is Data -> throw RuntimeException("cannot swap type for StoreResponse.Data") + is Initial -> this } } @@ -129,6 +134,8 @@ sealed class StoreReadResponseOrigin { * @property name Unique name to enable differentiation when [org.mobilenativefoundation.store.store5.Fetcher.fallback] exists */ data class Fetcher(val name: String? = null) : StoreReadResponseOrigin() + + object Initial : StoreReadResponseOrigin() } fun StoreReadResponse.Error.doThrow(): Nothing = when (this) { diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt index 878d22c72..cc835132b 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt @@ -294,6 +294,7 @@ internal class RealStore( // for other errors, don't do anything, wait for the read attempt } + is StoreReadResponse.Initial, is StoreReadResponse.Loading, is StoreReadResponse.NoNewData -> { } From a50456a4ebcb834260acb687aad8c43dd9b26e42 Mon Sep 17 00:00:00 2001 From: mramotar Date: Wed, 18 Oct 2023 11:45:05 -0400 Subject: [PATCH 05/17] Rename to launchPagingStore Signed-off-by: mramotar_dbx --- .../{LaunchStore.kt => LaunchPagingStore.kt} | 14 +++++++------- ...unchStoreTests.kt => LaunchPagingStoreTests.kt} | 8 ++++---- .../store/paging5/PagingTests.kt | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) rename paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/{LaunchStore.kt => LaunchPagingStore.kt} (93%) rename paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/{LaunchStoreTests.kt => LaunchPagingStoreTests.kt} (93%) diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchStore.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt similarity index 93% rename from paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchStore.kt rename to paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt index 1b8d7f647..c58c7afcc 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchStore.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt @@ -19,7 +19,7 @@ private class StopProcessingException : Exception() * @param stream A lambda that invokes [Store.stream]. * @return A read-only [StateFlow] reflecting the state of the Store. */ -private fun , Output : StoreData> launchStore( +private fun , Output : StoreData> launchPagingStore( scope: CoroutineScope, keys: Flow, stream: (key: Key) -> Flow>, @@ -66,27 +66,27 @@ private fun , Output : StoreData> launchStore( /** * Initializes and returns a [StateFlow] that reflects the state of the [Store], updating by a flow of provided keys. - * @see [launchStore]. + * @see [launchPagingStore]. */ -fun , Output : StoreData> Store.launchStore( +fun , Output : StoreData> Store.launchPagingStore( scope: CoroutineScope, keys: Flow, ): StateFlow> { - return launchStore(scope, keys) { key -> + return launchPagingStore(scope, keys) { key -> this.stream(StoreReadRequest.fresh(key)) } } /** * Initializes and returns a [StateFlow] that reflects the state of the [Store], updating by a flow of provided keys. - * @see [launchStore]. + * @see [launchPagingStore]. */ @OptIn(ExperimentalStoreApi::class) -fun , Output : StoreData> MutableStore.launchStore( +fun , Output : StoreData> MutableStore.launchPagingStore( scope: CoroutineScope, keys: Flow, ): StateFlow> { - return launchStore(scope, keys) { key -> + return launchPagingStore(scope, keys) { key -> this.stream(StoreReadRequest.fresh(key)) } } diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchStoreTests.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt similarity index 93% rename from paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchStoreTests.kt rename to paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt index eb8358e58..2de2aa0e4 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchStoreTests.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt @@ -14,7 +14,7 @@ import kotlin.test.assertEquals import kotlin.test.assertIs @OptIn(ExperimentalStoreApi::class) -class LaunchStoreTests { +class LaunchPagingStoreTests { private val testScope = TestScope() private val userId = "123" @@ -34,7 +34,7 @@ class LaunchStoreTests { fun `state transitions from Loading to Loaded Collection for valid Cursor key`() = testScope.runTest { val key = PostKey.Cursor("1", 10) val keys = flowOf(key) - val stateFlow = store.launchStore(this, keys) + val stateFlow = store.launchPagingStore(this, keys) stateFlow.test { val state1 = awaitItem() @@ -52,7 +52,7 @@ class LaunchStoreTests { val key1 = PostKey.Cursor("1", 10) val key2 = PostKey.Cursor("11", 10) val keys = flowOf(key1, key2) - val stateFlow = store.launchStore(this, keys) + val stateFlow = store.launchPagingStore(this, keys) stateFlow.test { val state1 = awaitItem() @@ -77,7 +77,7 @@ class LaunchStoreTests { fun `state remains consistent if the same key is emitted multiple times`() = testScope.runTest { val key = PostKey.Cursor("1", 10) val keys = flowOf(key, key) - val stateFlow = store.launchStore(this, keys) + val stateFlow = store.launchPagingStore(this, keys) stateFlow.test { val state1 = awaitItem() diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt index 7bbf8f56f..e4555e665 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt @@ -27,7 +27,7 @@ class PagingTests { val key2 = PostKey.Cursor("11", 10) val keys = flowOf(key1, key2) - val stateFlow = store.launchStore(this, keys) + val stateFlow = store.launchPagingStore(this, keys) stateFlow.test { val initialState = awaitItem() assertIs(initialState) From 889fe49ba55d6688a3bdd7f12120e3b57f0a40ba Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Wed, 18 Oct 2023 11:46:50 -0400 Subject: [PATCH 06/17] Remove log statements Signed-off-by: mramotar_dbx --- .../mobilenativefoundation/store/paging5/util/FakePostApi.kt | 2 -- .../store/paging5/util/FakePostDatabase.kt | 2 -- .../store/paging5/util/PostStoreFactory.kt | 2 -- 3 files changed, 6 deletions(-) diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt index 6751790f3..be6ae1076 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt @@ -16,10 +16,8 @@ class FakePostApi : PostApi { override suspend fun get(postId: String): PostGetRequestResult { val post = posts[postId] return if (post != null) { - println("HITTING 2 :)") PostGetRequestResult.Data(post) } else { - println("HITTING 2 :(") PostGetRequestResult.Error.Message("Post $postId was not found") } } diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt index 6795acd48..6eab782c0 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt @@ -16,7 +16,6 @@ class FakePostDatabase(private val userId: String) : PostDatabase { nextFeed?.let { feeds[userId] = PostData.Feed(nextFeed) - println("UPDATED FEED $it") } } @@ -30,7 +29,6 @@ class FakePostDatabase(private val userId: String) : PostDatabase { override fun findFeedByUserId(cursor: String?, size: Int): PostData.Feed? { val feed = feeds[userId] - println("FEED RETURNING = $feed") return feed } diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt index 51b368048..7dba1cc4d 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt @@ -15,7 +15,6 @@ typealias PostStore = MutableStore class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { private fun createFetcher(): Fetcher = Fetcher.of { key -> - println("HITTING IN FETCHER") when (key) { is PostKey.Single -> { when (val result = api.get(key.id)) { @@ -54,7 +53,6 @@ class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { private fun createSourceOfTruth(): SourceOfTruth = SourceOfTruth.of( reader = { key -> - println("HITTING IN SOT") flow { when (key) { is PostKey.Single -> { From 000c87ba0ace20a2af88a11e5a91340834633b9a Mon Sep 17 00:00:00 2001 From: Matt Ramotar Date: Wed, 18 Oct 2023 12:00:02 -0400 Subject: [PATCH 07/17] Rename to InsertionStrategy Signed-off-by: mramotar_dbx --- .../store/paging5/InsertionStrategy.kt | 6 ++ .../store/paging5/LaunchPagingStore.kt | 2 +- .../store/paging5/StoreData.kt | 2 +- .../store/paging5/StoreKey.kt | 7 +- .../store/paging5/LaunchPagingStoreTests.kt | 75 +++++++++++++++-- .../store/paging5/PagingTests.kt | 82 ------------------- .../store/paging5/util/PostData.kt | 10 +-- .../store/paging5/util/PostKey.kt | 3 +- .../store/paging5/util/PostStoreFactory.kt | 2 - 9 files changed, 85 insertions(+), 104 deletions(-) create mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/InsertionStrategy.kt delete mode 100644 paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/InsertionStrategy.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/InsertionStrategy.kt new file mode 100644 index 000000000..c3b58617c --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/InsertionStrategy.kt @@ -0,0 +1,6 @@ +package org.mobilenativefoundation.store.paging5 + +enum class InsertionStrategy { + APPEND, + PREPEND +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt index c58c7afcc..22bd4af1e 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt @@ -104,7 +104,7 @@ private fun , Output : StoreData> jo val currentData = currentResponse.value as StoreData.Collection> - val joinedOutput = (lastOutput?.insertItems(key.loadType, currentData.items) ?: currentData) as Output + val joinedOutput = (lastOutput?.insertItems(key.insertionStrategy, currentData.items) ?: currentData) as Output return StoreReadResponse.Data(joinedOutput, currentResponse.origin) } diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreData.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreData.kt index bc42ee553..8e8e7d55b 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreData.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreData.kt @@ -30,6 +30,6 @@ interface StoreData { /** * Inserts items to the existing collection and returns the updated collection. */ - fun insertItems(type: StoreKey.LoadType, items: List): Collection + fun insertItems(strategy: InsertionStrategy, items: List): Collection } } \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreKey.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreKey.kt index 00d8d5f94..07997a2df 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreKey.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreKey.kt @@ -19,7 +19,7 @@ interface StoreKey { * Represents a key for fetching collections of items. */ interface Collection : StoreKey { - val loadType: LoadType + val insertionStrategy: InsertionStrategy /** * Represents a key for page-based fetching. @@ -58,9 +58,4 @@ interface StoreKey { interface Filter { operator fun invoke(items: List): List } - - enum class LoadType { - APPEND, - PREPEND - } } \ No newline at end of file diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt index 2de2aa0e4..8fbcb903d 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt @@ -1,15 +1,14 @@ package org.mobilenativefoundation.store.paging5 import app.cash.turbine.test +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.mobilenativefoundation.store.paging5.util.* -import org.mobilenativefoundation.store.store5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.MutableStore -import org.mobilenativefoundation.store.store5.StoreReadResponse +import org.mobilenativefoundation.store.store5.* import kotlin.test.assertEquals import kotlin.test.assertIs @@ -31,7 +30,7 @@ class LaunchPagingStoreTests { } @Test - fun `state transitions from Loading to Loaded Collection for valid Cursor key`() = testScope.runTest { + fun transitionFromInitialToData() = testScope.runTest { val key = PostKey.Cursor("1", 10) val keys = flowOf(key) val stateFlow = store.launchPagingStore(this, keys) @@ -48,7 +47,7 @@ class LaunchPagingStoreTests { } @Test - fun `state transitions appropriately for multiple valid keys emitted in succession`() = testScope.runTest { + fun multipleValidKeysEmittedInSuccession() = testScope.runTest { val key1 = PostKey.Cursor("1", 10) val key2 = PostKey.Cursor("11", 10) val keys = flowOf(key1, key2) @@ -74,7 +73,7 @@ class LaunchPagingStoreTests { } @Test - fun `state remains consistent if the same key is emitted multiple times`() = testScope.runTest { + fun sameKeyEmittedMultipleTimes() = testScope.runTest { val key = PostKey.Cursor("1", 10) val keys = flowOf(key, key) val stateFlow = store.launchPagingStore(this, keys) @@ -89,4 +88,68 @@ class LaunchPagingStoreTests { expectNoEvents() } } + + @Test + fun multipleKeysWithReadsAndWrites() = testScope.runTest { + val api = FakePostApi() + val db = FakePostDatabase(userId) + val factory = PostStoreFactory(api = api, db = db) + val store = factory.create() + + val key1 = PostKey.Cursor("1", 10) + val key2 = PostKey.Cursor("11", 10) + val keys = flowOf(key1, key2) + + val stateFlow = store.launchPagingStore(this, keys) + stateFlow.test { + val initialState = awaitItem() + assertIs(initialState) + val loadingState = awaitItem() + assertIs(loadingState) + val loadedState1 = awaitItem() + assertIs>(loadedState1) + val data1 = loadedState1.value + assertEquals(10, data1.posts.size) + val loadedState2 = awaitItem() + assertIs>(loadedState2) + val data2 = loadedState2.value + assertEquals(20, data2.posts.size) + } + + val cached = store.stream(StoreReadRequest.cached(key1, refresh = false)) + .first { it.dataOrNull() != null } + assertIs>(cached) + assertEquals(StoreReadResponseOrigin.Cache, cached.origin) + val data = cached.requireData() + assertIs(data) + assertEquals(10, data.posts.size) + + val cached2 = store.stream(StoreReadRequest.cached(PostKey.Single("2"), refresh = false)) + .first { it.dataOrNull() != null } + assertIs>(cached2) + assertEquals(StoreReadResponseOrigin.Cache, cached2.origin) + val data2 = cached2.requireData() + assertIs(data2) + assertEquals("2", data2.title) + + + store.write(StoreWriteRequest.of(PostKey.Single("2"), PostData.Post("2", "2-modified"))) + + val cached3 = store.stream(StoreReadRequest.cached(PostKey.Single("2"), refresh = false)) + .first { it.dataOrNull() != null } + assertIs>(cached3) + assertEquals(StoreReadResponseOrigin.Cache, cached3.origin) + val data3 = cached3.requireData() + assertIs(data3) + assertEquals("2-modified", data3.title) + + val cached4 = + store.stream(StoreReadRequest.cached(PostKey.Cursor("1", 10), refresh = false)) + .first { it.dataOrNull() != null } + assertIs>(cached4) + assertEquals(StoreReadResponseOrigin.Cache, cached4.origin) + val data4 = cached4.requireData() + assertIs(data4) + assertEquals("2-modified", data4.posts[1].title) + } } diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt deleted file mode 100644 index e4555e665..000000000 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/PagingTests.kt +++ /dev/null @@ -1,82 +0,0 @@ -package org.mobilenativefoundation.store.paging5 - -import app.cash.turbine.test -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.paging5.util.* -import org.mobilenativefoundation.store.store5.* -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs - -@OptIn(ExperimentalStoreApi::class) -class PagingTests { - private val testScope = TestScope() - private val userId = "123" - - @Test - fun happyPath() = testScope.runTest { - val api = FakePostApi() - val db = FakePostDatabase(userId) - val factory = PostStoreFactory(api = api, db = db) - val store = factory.create() - - val key1 = PostKey.Cursor("1", 10) - val key2 = PostKey.Cursor("11", 10) - val keys = flowOf(key1, key2) - - val stateFlow = store.launchPagingStore(this, keys) - stateFlow.test { - val initialState = awaitItem() - assertIs(initialState) - val loadingState = awaitItem() - assertIs(loadingState) - val loadedState1 = awaitItem() - assertIs>(loadedState1) - val data1 = loadedState1.value - assertEquals(10, data1.posts.size) - val loadedState2 = awaitItem() - assertIs>(loadedState2) - val data2 = loadedState2.value - assertEquals(20, data2.posts.size) - } - - val cached = store.stream(StoreReadRequest.cached(key1, refresh = false)) - .first { it.dataOrNull() != null } - assertIs>(cached) - assertEquals(StoreReadResponseOrigin.Cache, cached.origin) - val data = cached.requireData() - assertIs(data) - assertEquals(10, data.posts.size) - - val cached2 = store.stream(StoreReadRequest.cached(PostKey.Single("2"), refresh = false)) - .first { it.dataOrNull() != null } - assertIs>(cached2) - assertEquals(StoreReadResponseOrigin.Cache, cached2.origin) - val data2 = cached2.requireData() - assertIs(data2) - assertEquals("2", data2.title) - - - store.write(StoreWriteRequest.of(PostKey.Single("2"), PostData.Post("2", "2-modified"))) - - val cached3 = store.stream(StoreReadRequest.cached(PostKey.Single("2"), refresh = false)) - .first { it.dataOrNull() != null } - assertIs>(cached3) - assertEquals(StoreReadResponseOrigin.Cache, cached3.origin) - val data3 = cached3.requireData() - assertIs(data3) - assertEquals("2-modified", data3.title) - - val cached4 = - store.stream(StoreReadRequest.cached(PostKey.Cursor("1", 10), refresh = false)) - .first { it.dataOrNull() != null } - assertIs>(cached4) - assertEquals(StoreReadResponseOrigin.Cache, cached4.origin) - val data4 = cached4.requireData() - assertIs(data4) - assertEquals("2-modified", data4.posts[1].title) - } -} \ No newline at end of file diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt index db4e2dd34..7c51fb208 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt @@ -1,7 +1,7 @@ package org.mobilenativefoundation.store.paging5.util +import org.mobilenativefoundation.store.paging5.InsertionStrategy import org.mobilenativefoundation.store.paging5.StoreData -import org.mobilenativefoundation.store.paging5.StoreKey sealed class PostData : StoreData { data class Post(val postId: String, val title: String) : StoreData.Single, PostData() { @@ -11,16 +11,16 @@ sealed class PostData : StoreData { data class Feed(val posts: List) : StoreData.Collection, PostData() { override val items: List get() = posts override fun copyWith(items: List): StoreData.Collection = copy(posts = items) - override fun insertItems(type: StoreKey.LoadType, items: List): StoreData.Collection { + override fun insertItems(strategy: InsertionStrategy, items: List): StoreData.Collection { - return when (type) { - StoreKey.LoadType.APPEND -> { + return when (strategy) { + InsertionStrategy.APPEND -> { val updatedItems = items.toMutableList() updatedItems.addAll(posts) copyWith(items = updatedItems) } - StoreKey.LoadType.PREPEND -> { + InsertionStrategy.PREPEND -> { val updatedItems = posts.toMutableList() updatedItems.addAll(items) copyWith(items = updatedItems) diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt index 8feba6865..48e52726e 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt @@ -1,5 +1,6 @@ package org.mobilenativefoundation.store.paging5.util +import org.mobilenativefoundation.store.paging5.InsertionStrategy import org.mobilenativefoundation.store.paging5.StoreKey sealed class PostKey : StoreKey { @@ -8,7 +9,7 @@ sealed class PostKey : StoreKey { override val size: Int, override val sort: StoreKey.Sort? = null, override val filters: List>? = null, - override val loadType: StoreKey.LoadType = StoreKey.LoadType.PREPEND + override val insertionStrategy: InsertionStrategy = InsertionStrategy.APPEND ) : StoreKey.Collection.Cursor, PostKey() data class Single( diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt index 7dba1cc4d..7c9210ba1 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt @@ -10,8 +10,6 @@ import org.mobilenativefoundation.store.paging5.StoreKey import org.mobilenativefoundation.store.store5.* import kotlin.math.floor -typealias PostStore = MutableStore - class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { private fun createFetcher(): Fetcher = Fetcher.of { key -> From a6ce68ea74f58f4df406a769d47ecb3ae3597abf Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Wed, 18 Oct 2023 18:24:09 -0400 Subject: [PATCH 08/17] Move StoreKey and StoreData to core Signed-off-by: mramotar_dbx --- cache/build.gradle.kts | 1 + .../store/cache5/Identifiable.kt | 1 + .../store/cache5/MultiCache.kt | 1 + .../store/cache5/StoreMultiCache.kt | 45 +++++++++++-------- .../store/cache5/StoreMultiCacheAccessor.kt | 34 +++++++------- core/build.gradle.kts | 40 +++++++++++++++++ .../store/core5}/InsertionStrategy.kt | 2 +- .../store/core5}/KeyProvider.kt | 2 +- .../store/core5}/StoreData.kt | 2 +- .../store/core5}/StoreKey.kt | 2 +- paging/build.gradle.kts | 3 +- .../store/paging5/LaunchPagingStore.kt | 2 + .../store/paging5/util/PostData.kt | 4 +- .../store/paging5/util/PostKey.kt | 4 +- .../store/paging5/util/PostStoreFactory.kt | 8 ++-- settings.gradle | 3 +- store/build.gradle.kts | 1 + 17 files changed, 105 insertions(+), 50 deletions(-) rename paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCache.kt => cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt (73%) rename paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCacheAccessor.kt => cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt (65%) create mode 100644 core/build.gradle.kts rename {paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5 => core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5}/InsertionStrategy.kt (53%) rename {paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5 => core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5}/KeyProvider.kt (82%) rename {paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5 => core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5}/StoreData.kt (95%) rename {paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5 => core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5}/StoreKey.kt (96%) diff --git a/cache/build.gradle.kts b/cache/build.gradle.kts index 15527d9dd..b5d635478 100644 --- a/cache/build.gradle.kts +++ b/cache/build.gradle.kts @@ -44,6 +44,7 @@ kotlin { val commonMain by getting { dependencies { api(libs.kotlinx.atomic.fu) + api(project(":core")) } } val jvmMain by getting diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Identifiable.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Identifiable.kt index d523bfb67..457a3ce9f 100644 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Identifiable.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Identifiable.kt @@ -1,5 +1,6 @@ package org.mobilenativefoundation.store.cache5 +@Deprecated("Use StoreMultiCache instead of MultiCache") interface Identifiable { val id: Id } diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MultiCache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MultiCache.kt index 3dfc0b561..c3dd43562 100644 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MultiCache.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MultiCache.kt @@ -7,6 +7,7 @@ package org.mobilenativefoundation.store.cache5 * Stores and manages the relationship among single items and collections. * Delegates cache storage and behavior to Guava caches. */ +@Deprecated("Use StoreMultiCache") class MultiCache>( cacheBuilder: CacheBuilder ) { diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt similarity index 73% rename from paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCache.kt rename to cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt index 5455e525f..b334d8ec4 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCache.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt @@ -1,20 +1,27 @@ @file:Suppress("UNCHECKED_CAST") -package org.mobilenativefoundation.store.paging5 +package org.mobilenativefoundation.store.cache5 -import org.mobilenativefoundation.store.cache5.Cache +import org.mobilenativefoundation.store.core5.KeyProvider +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey /** - * A class that represents a caching system for pagination. + * A class that represents a caching system with collection decomposition. * Manages data with utility functions to get, invalidate, and add items to the cache. - * Depends on [PagingCacheAccessor] for internal data management. + * Depends on [StoreMultiCacheAccessor] for internal data management. * @see [Cache]. */ -class PagingCache, StoreOutput : StoreData, Collection : StoreData.Collection, Single : StoreData.Single>( +class StoreMultiCache, Single : StoreData.Single, Collection : StoreData.Collection, Output : StoreData>( private val keyProvider: KeyProvider, -) : Cache { + singlesCache: Cache, Single> = CacheBuilder, Single>().build(), + collectionsCache: Cache, Collection> = CacheBuilder, Collection>().build(), +) : Cache { - private val accessor = PagingCacheAccessor() + private val accessor = StoreMultiCacheAccessor( + singlesCache = singlesCache, + collectionsCache = collectionsCache, + ) private fun Key.castSingle() = this as StoreKey.Single private fun Key.castCollection() = this as StoreKey.Collection @@ -22,20 +29,20 @@ class PagingCache, StoreOutput : StoreData, Col private fun StoreKey.Collection.cast() = this as Key private fun StoreKey.Single.cast() = this as Key - override fun getIfPresent(key: Key): StoreOutput? { + override fun getIfPresent(key: Key): Output? { return when (key) { - is StoreKey.Single<*> -> accessor.getSingle(key.castSingle()) as? StoreOutput - is StoreKey.Collection<*> -> accessor.getCollection(key.castCollection()) as? StoreOutput + is StoreKey.Single<*> -> accessor.getSingle(key.castSingle()) as? Output + is StoreKey.Collection<*> -> accessor.getCollection(key.castCollection()) as? Output else -> { throw UnsupportedOperationException(invalidKeyErrorMessage(key)) } } } - override fun getOrPut(key: Key, valueProducer: () -> StoreOutput): StoreOutput { + override fun getOrPut(key: Key, valueProducer: () -> Output): Output { return when (key) { is StoreKey.Single<*> -> { - val single = accessor.getSingle(key.castSingle()) as? StoreOutput + val single = accessor.getSingle(key.castSingle()) as? Output if (single != null) { single } else { @@ -46,7 +53,7 @@ class PagingCache, StoreOutput : StoreData, Col } is StoreKey.Collection<*> -> { - val collection = accessor.getCollection(key.castCollection()) as? StoreOutput + val collection = accessor.getCollection(key.castCollection()) as? Output if (collection != null) { collection } else { @@ -62,18 +69,18 @@ class PagingCache, StoreOutput : StoreData, Col } } - override fun getAllPresent(keys: List<*>): Map { - val map = mutableMapOf() + override fun getAllPresent(keys: List<*>): Map { + val map = mutableMapOf() keys.filterIsInstance>().forEach { key -> when (key) { is StoreKey.Collection -> { val collection = accessor.getCollection(key) - collection?.let { map[key.cast()] = it as StoreOutput } + collection?.let { map[key.cast()] = it as Output } } is StoreKey.Single -> { val single = accessor.getSingle(key) - single?.let { map[key.cast()] = it as StoreOutput } + single?.let { map[key.cast()] = it as Output } } } } @@ -92,11 +99,11 @@ class PagingCache, StoreOutput : StoreData, Col } } - override fun putAll(map: Map) { + override fun putAll(map: Map) { map.entries.forEach { (key, value) -> put(key, value) } } - override fun put(key: Key, value: StoreOutput) { + override fun put(key: Key, value: Output) { when (key) { is StoreKey.Single<*> -> { val single = value as Single diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCacheAccessor.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt similarity index 65% rename from paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCacheAccessor.kt rename to cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt index 23b531211..4d948c037 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingCacheAccessor.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt @@ -1,32 +1,34 @@ -package org.mobilenativefoundation.store.paging5 +package org.mobilenativefoundation.store.cache5 -import org.mobilenativefoundation.store.cache5.CacheBuilder +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey /** - * Intermediate data manager for a caching system supporting pagination. + * Intermediate data manager for a caching system supporting list decomposition. * Tracks keys for rapid data retrieval and modification. */ -class PagingCacheAccessor, Single : StoreData.Single> { - private val collections = CacheBuilder, Collection>().build() - private val singles = CacheBuilder, Single>().build() +class StoreMultiCacheAccessor, Single : StoreData.Single>( + private val singlesCache: Cache, Single>, + private val collectionsCache: Cache, Collection>, +) { private val keys = mutableSetOf>() /** * Retrieves a collection of items from the cache using the provided key. */ - fun getCollection(key: StoreKey.Collection): Collection? = collections.getIfPresent(key) + fun getCollection(key: StoreKey.Collection): Collection? = collectionsCache.getIfPresent(key) /** * Retrieves an individual item from the cache using the provided key. */ - fun getSingle(key: StoreKey.Single): Single? = singles.getIfPresent(key) + fun getSingle(key: StoreKey.Single): Single? = singlesCache.getIfPresent(key) /** * Stores a collection of items in the cache and updates the key set. */ fun putCollection(key: StoreKey.Collection, collection: Collection) { - collections.put(key, collection) + collectionsCache.put(key, collection) keys.add(key) } @@ -34,7 +36,7 @@ class PagingCacheAccessor, single: Single) { - singles.put(key, single) + singlesCache.put(key, single) keys.add(key) } @@ -42,8 +44,8 @@ class PagingCacheAccessor) { - singles.invalidate(key) + singlesCache.invalidate(key) keys.remove(key) } @@ -59,7 +61,7 @@ class PagingCacheAccessor) { - collections.invalidate(key) + collectionsCache.invalidate(key) keys.remove(key) } @@ -73,14 +75,14 @@ class PagingCacheAccessor -> { - val single = singles.getIfPresent(key) + val single = singlesCache.getIfPresent(key) if (single != null) { count++ } } is StoreKey.Collection -> { - val collection = collections.getIfPresent(key) + val collection = collectionsCache.getIfPresent(key) if (collection != null) { count += collection.items.size } diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 000000000..b55cc5c72 --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,40 @@ + +plugins { + kotlin("multiplatform") + kotlin("plugin.serialization") + id("com.android.library") + id("com.vanniktech.maven.publish") + id("org.jetbrains.dokka") + id("org.jetbrains.kotlinx.kover") + `maven-publish` + id("kotlinx-atomicfu") + id("org.jetbrains.compose") version("1.5.1") +} + +kotlin { + androidTarget() + jvm() + iosArm64() + iosX64() + linuxX64() + iosSimulatorArm64() + js { + browser() + nodejs() + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.kotlin.stdlib) + } + } + } +} + +android { + + compileSdk = 33 + + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") +} diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/InsertionStrategy.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt similarity index 53% rename from paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/InsertionStrategy.kt rename to core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt index c3b58617c..9be64a478 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/InsertionStrategy.kt +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt @@ -1,4 +1,4 @@ -package org.mobilenativefoundation.store.paging5 +package org.mobilenativefoundation.store.core5 enum class InsertionStrategy { APPEND, diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyProvider.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/KeyProvider.kt similarity index 82% rename from paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyProvider.kt rename to core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/KeyProvider.kt index 36c7602d9..255abd9ec 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyProvider.kt +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/KeyProvider.kt @@ -1,4 +1,4 @@ -package org.mobilenativefoundation.store.paging5 +package org.mobilenativefoundation.store.core5 interface KeyProvider> { fun from(key: StoreKey.Collection, value: Single): StoreKey.Single diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreData.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt similarity index 95% rename from paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreData.kt rename to core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt index 8e8e7d55b..e163605bb 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreData.kt +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt @@ -1,4 +1,4 @@ -package org.mobilenativefoundation.store.paging5 +package org.mobilenativefoundation.store.core5 /** diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreKey.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt similarity index 96% rename from paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreKey.kt rename to core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt index 07997a2df..245ce05a9 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/StoreKey.kt +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt @@ -1,4 +1,4 @@ -package org.mobilenativefoundation.store.paging5 +package org.mobilenativefoundation.store.core5 /** * An interface that defines keys used by Store for data-fetching operations. diff --git a/paging/build.gradle.kts b/paging/build.gradle.kts index 06a0bd090..981273851 100644 --- a/paging/build.gradle.kts +++ b/paging/build.gradle.kts @@ -25,8 +25,7 @@ kotlin { implementation(compose.ui) implementation(compose.foundation) implementation(compose.material) - - + api(project(":core")) } } diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt index 22bd4af1e..ceb8b3619 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt @@ -7,6 +7,8 @@ package org.mobilenativefoundation.store.paging5 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey import org.mobilenativefoundation.store.store5.* private class StopProcessingException : Exception() diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt index 7c51fb208..e0f6da992 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt @@ -1,7 +1,7 @@ package org.mobilenativefoundation.store.paging5.util -import org.mobilenativefoundation.store.paging5.InsertionStrategy -import org.mobilenativefoundation.store.paging5.StoreData +import org.mobilenativefoundation.store.core5.InsertionStrategy +import org.mobilenativefoundation.store.core5.StoreData sealed class PostData : StoreData { data class Post(val postId: String, val title: String) : StoreData.Single, PostData() { diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt index 48e52726e..df5f0254c 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt @@ -1,7 +1,7 @@ package org.mobilenativefoundation.store.paging5.util -import org.mobilenativefoundation.store.paging5.InsertionStrategy -import org.mobilenativefoundation.store.paging5.StoreKey +import org.mobilenativefoundation.store.core5.InsertionStrategy +import org.mobilenativefoundation.store.core5.StoreKey sealed class PostKey : StoreKey { data class Cursor( diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt index 7c9210ba1..d8ed2dbeb 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt @@ -4,9 +4,9 @@ package org.mobilenativefoundation.store.paging5.util import kotlinx.coroutines.flow.flow import org.mobilenativefoundation.store.cache5.Cache -import org.mobilenativefoundation.store.paging5.KeyProvider -import org.mobilenativefoundation.store.paging5.PagingCache -import org.mobilenativefoundation.store.paging5.StoreKey +import org.mobilenativefoundation.store.core5.KeyProvider +import org.mobilenativefoundation.store.cache5.StoreMultiCache +import org.mobilenativefoundation.store.core5.StoreKey import org.mobilenativefoundation.store.store5.* import kotlin.math.floor @@ -115,7 +115,7 @@ class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { } private fun createMemoryCache(): Cache = - PagingCache(createPagingCacheKeyProvider()) + StoreMultiCache(createPagingCacheKeyProvider()) fun create(): MutableStore = StoreBuilder.from( fetcher = createFetcher(), diff --git a/settings.gradle b/settings.gradle index ca7e239ff..8c532892f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,4 +2,5 @@ include ':store' include ':cache' include ':multicast' include ':rx2' -include ':paging' \ No newline at end of file +include ':paging' +include ':core' \ No newline at end of file diff --git a/store/build.gradle.kts b/store/build.gradle.kts index 68813dacc..e9a9d1f8d 100644 --- a/store/build.gradle.kts +++ b/store/build.gradle.kts @@ -56,6 +56,7 @@ kotlin { implementation(libs.touchlab.kermit) implementation(project(":multicast")) implementation(project(":cache")) + api(project(":core")) } } From a8901b0a7daacb1788bb21b413601edbec9f2dde Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Tue, 31 Oct 2023 12:43:12 -0400 Subject: [PATCH 09/17] Make StoreMultiCacheAccessor thread safe Signed-off-by: mramotar_dbx --- cache/build.gradle.kts | 1 + .../store/cache5/StoreMultiCacheAccessor.kt | 82 +++++++++++++++---- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/cache/build.gradle.kts b/cache/build.gradle.kts index b5d635478..b13569c13 100644 --- a/cache/build.gradle.kts +++ b/cache/build.gradle.kts @@ -45,6 +45,7 @@ kotlin { dependencies { api(libs.kotlinx.atomic.fu) api(project(":core")) + implementation(libs.kotlinx.coroutines.core) } } val jvmMain by getting diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt index 4d948c037..04e990569 100644 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt @@ -1,49 +1,87 @@ package org.mobilenativefoundation.store.cache5 +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized import org.mobilenativefoundation.store.core5.StoreData import org.mobilenativefoundation.store.core5.StoreKey /** - * Intermediate data manager for a caching system supporting list decomposition. - * Tracks keys for rapid data retrieval and modification. + * Responsible for managing and accessing cached data. + * Provides functionality to retrieve, store, and invalidate single items and collections of items. + * All operations are thread-safe, ensuring safe usage across multiple threads. + * + * The thread safety of this class is ensured through the use of synchronized blocks. + * Synchronized blocks guarantee only one thread can execute any of the methods at a time. + * This prevents concurrent modifications and ensures consistency of the data. + * + * @param Id The type of the identifier used for the data. + * @param Collection The type of the data collection. + * @param Single The type of the single data item. + * @property singlesCache The cache used to store single data items. + * @property collectionsCache The cache used to store collections of data items. */ class StoreMultiCacheAccessor, Single : StoreData.Single>( private val singlesCache: Cache, Single>, private val collectionsCache: Cache, Collection>, -) { +) : SynchronizedObject() { private val keys = mutableSetOf>() - /** * Retrieves a collection of items from the cache using the provided key. + * + * This operation is thread-safe. + * + * @param key The key used to retrieve the collection. + * @return The cached collection or null if it's not present. */ - fun getCollection(key: StoreKey.Collection): Collection? = collectionsCache.getIfPresent(key) + fun getCollection(key: StoreKey.Collection): Collection? = synchronized(this) { + collectionsCache.getIfPresent(key) + } /** * Retrieves an individual item from the cache using the provided key. + * + * This operation is thread-safe. + * + * @param key The key used to retrieve the single item. + * @return The cached single item or null if it's not present. */ - fun getSingle(key: StoreKey.Single): Single? = singlesCache.getIfPresent(key) + fun getSingle(key: StoreKey.Single): Single? = synchronized(this) { + singlesCache.getIfPresent(key) + } /** * Stores a collection of items in the cache and updates the key set. + * + * This operation is thread-safe. + * + * @param key The key associated with the collection. + * @param collection The collection to be stored in the cache. */ - fun putCollection(key: StoreKey.Collection, collection: Collection) { + fun putCollection(key: StoreKey.Collection, collection: Collection) = synchronized(this) { collectionsCache.put(key, collection) keys.add(key) } /** * Stores an individual item in the cache and updates the key set. + * + * This operation is thread-safe. + * + * @param key The key associated with the single item. + * @param single The single item to be stored in the cache. */ - fun putSingle(key: StoreKey.Single, single: Single) { + fun putSingle(key: StoreKey.Single, single: Single) = synchronized(this) { singlesCache.put(key, single) keys.add(key) } /** * Removes all cache entries and clears the key set. + * + * This operation is thread-safe. */ - fun invalidateAll() { + fun invalidateAll() = synchronized(this) { collectionsCache.invalidateAll() singlesCache.invalidateAll() keys.clear() @@ -51,26 +89,36 @@ class StoreMultiCacheAccessor) { + fun invalidateSingle(key: StoreKey.Single) = synchronized(this) { singlesCache.invalidate(key) keys.remove(key) } /** * Removes a collection of items from the cache and updates the key set. + * + * This operation is thread-safe. + * + * @param key The key associated with the collection to be invalidated. */ - fun invalidateCollection(key: StoreKey.Collection) { + fun invalidateCollection(key: StoreKey.Collection) = synchronized(this) { collectionsCache.invalidate(key) keys.remove(key) } /** - * Calculates the total count of items in the cache. - * Includes individual items as well as items in collections. + * Calculates the total count of items in the cache, including both single items and items in collections. + * + * This operation is thread-safe. + * + * @return The total count of items in the cache. */ - - fun size(): Long { + fun size(): Long = synchronized(this) { var count = 0L for (key in keys) { when (key) { @@ -89,6 +137,6 @@ class StoreMultiCacheAccessor Date: Thu, 7 Dec 2023 22:23:29 -0500 Subject: [PATCH 10/17] Rename KeyProvider methods --- .../store/cache5/StoreMultiCache.kt | 4 ++-- .../store/core5/KeyProvider.kt | 4 ++-- .../store/paging5/util/PostStoreFactory.kt | 18 ++++++++++++++---- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt index b334d8ec4..baa7db834 100644 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt @@ -109,7 +109,7 @@ class StoreMultiCache, Single : StoreData.Single, Single : StoreData.Single> { - fun from(key: StoreKey.Collection, value: Single): StoreKey.Single - fun from(key: StoreKey.Single, value: Single): StoreKey.Collection + fun fromCollection(key: StoreKey.Collection, value: Single): StoreKey.Single + fun fromSingle(key: StoreKey.Single, value: Single): StoreKey.Collection } \ No newline at end of file diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt index d8ed2dbeb..f6d201a56 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt @@ -4,10 +4,17 @@ package org.mobilenativefoundation.store.paging5.util import kotlinx.coroutines.flow.flow import org.mobilenativefoundation.store.cache5.Cache -import org.mobilenativefoundation.store.core5.KeyProvider import org.mobilenativefoundation.store.cache5.StoreMultiCache +import org.mobilenativefoundation.store.core5.KeyProvider import org.mobilenativefoundation.store.core5.StoreKey -import org.mobilenativefoundation.store.store5.* +import org.mobilenativefoundation.store.store5.Converter +import org.mobilenativefoundation.store.store5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.Fetcher +import org.mobilenativefoundation.store.store5.MutableStore +import org.mobilenativefoundation.store.store5.SourceOfTruth +import org.mobilenativefoundation.store.store5.StoreBuilder +import org.mobilenativefoundation.store.store5.Updater +import org.mobilenativefoundation.store.store5.UpdaterResult import kotlin.math.floor class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { @@ -102,11 +109,14 @@ class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { private fun createPagingCacheKeyProvider(): KeyProvider = object : KeyProvider { - override fun from(key: StoreKey.Collection, value: PostData.Post): StoreKey.Single { + override fun fromCollection( + key: StoreKey.Collection, + value: PostData.Post + ): StoreKey.Single { return PostKey.Single(value.postId) } - override fun from(key: StoreKey.Single, value: PostData.Post): StoreKey.Collection { + override fun fromSingle(key: StoreKey.Single, value: PostData.Post): StoreKey.Collection { val id = value.postId.toInt() val cursor = (floor(id.toDouble() / 10) * 10) + 1 return PostKey.Cursor(cursor.toInt().toString(), 10) From f4b7cca7dbd023eff8545f670fe908d79bbcfaf7 Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Thu, 7 Dec 2023 22:26:24 -0500 Subject: [PATCH 11/17] Remove MultiCache and Identifiable --- .../store/cache5/Identifiable.kt | 6 -- .../store/cache5/MultiCache.kt | 78 ---------------- .../store5/MutableStoreWithMultiCacheTests.kt | 88 ------------------ .../store5/util/fake/NotesMemoryCache.kt | 89 ------------------- .../store/store5/util/model/NoteData.kt | 5 +- 5 files changed, 2 insertions(+), 264 deletions(-) delete mode 100644 cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Identifiable.kt delete mode 100644 cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MultiCache.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MutableStoreWithMultiCacheTests.kt delete mode 100644 store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesMemoryCache.kt diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Identifiable.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Identifiable.kt deleted file mode 100644 index 457a3ce9f..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Identifiable.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.mobilenativefoundation.store.cache5 - -@Deprecated("Use StoreMultiCache instead of MultiCache") -interface Identifiable { - val id: Id -} diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MultiCache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MultiCache.kt deleted file mode 100644 index c3dd43562..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MultiCache.kt +++ /dev/null @@ -1,78 +0,0 @@ -@file:Suppress("UNCHECKED_CAST") - -package org.mobilenativefoundation.store.cache5 - -/** - * Implementation of a cache with collection decomposition. - * Stores and manages the relationship among single items and collections. - * Delegates cache storage and behavior to Guava caches. - */ -@Deprecated("Use StoreMultiCache") -class MultiCache>( - cacheBuilder: CacheBuilder -) { - private val collectionCacheBuilder = CacheBuilder>().apply { - expireAfterAccess(cacheBuilder.expireAfterAccess) - expireAfterWrite(cacheBuilder.expireAfterWrite) - - if (cacheBuilder.maximumSize > 0) { - maximumSize(cacheBuilder.maximumSize) - } - // TODO(): Support weigher - } - - private val itemKeyToCollectionKey = mutableMapOf() - - private val itemCache: Cache = cacheBuilder.build() - - private val collectionCache: Cache> = collectionCacheBuilder.build() - - fun getItem(key: Key): Output? { - return itemCache.getIfPresent(key) - } - - fun putItem(key: Key, item: Output) { - itemCache.put(key, item) - - val collectionKey = itemKeyToCollectionKey[key] - if (collectionKey != null) { - val updatedCollection = collectionCache.getIfPresent(collectionKey)?.map { if (it.id == key) item else it } - if (updatedCollection != null) { - collectionCache.put(collectionKey, updatedCollection) - } - } - } - - fun > getCollection(key: Key): T? { - return collectionCache.getIfPresent(key) as? T - } - - fun putCollection(key: Key, items: Collection) { - collectionCache.put(key, items) - items.forEach { item -> - itemCache.put(item.id, item) - itemKeyToCollectionKey[item.id] = key - } - } - - fun invalidateItem(key: Key) { - itemCache.invalidate(key) - } - - fun invalidateCollection(key: Key) { - val collection = collectionCache.getIfPresent(key) - collection?.forEach { item -> - invalidateItem(item.id) - } - collectionCache.invalidate(key) - } - - fun invalidateAll() { - collectionCache.invalidateAll() - itemCache.invalidateAll() - } - - fun size(): Long { - return itemCache.size() + collectionCache.size() - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MutableStoreWithMultiCacheTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MutableStoreWithMultiCacheTests.kt deleted file mode 100644 index 3b213e9e6..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MutableStoreWithMultiCacheTests.kt +++ /dev/null @@ -1,88 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.cache5.CacheBuilder -import org.mobilenativefoundation.store.cache5.MultiCache -import org.mobilenativefoundation.store.store5.util.fake.NoteCollections -import org.mobilenativefoundation.store.store5.util.fake.Notes -import org.mobilenativefoundation.store.store5.util.fake.NotesApi -import org.mobilenativefoundation.store.store5.util.fake.NotesDatabase -import org.mobilenativefoundation.store.store5.util.fake.NotesKey -import org.mobilenativefoundation.store.store5.util.fake.NotesMemoryCache -import org.mobilenativefoundation.store.store5.util.model.InputNote -import org.mobilenativefoundation.store.store5.util.model.NetworkNote -import org.mobilenativefoundation.store.store5.util.model.NoteData -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs - -@OptIn(ExperimentalStoreApi::class) -class MutableStoreWithMultiCacheTests { - private val testScope = TestScope() - private lateinit var api: NotesApi - private lateinit var database: NotesDatabase - - @BeforeTest - fun before() { - api = NotesApi() - database = NotesDatabase() - } - - @Test - fun givenEmptyStoreWhenListFromFetcherThenListIsDecomposed() = testScope.runTest { - val memoryCache = - NotesMemoryCache(MultiCache(CacheBuilder())) - - val converter: Converter = - Converter.Builder() - .fromNetworkToLocal { network: NetworkNote -> network } - .fromOutputToLocal { output: NoteData -> NetworkNote(output, Long.MAX_VALUE) } - .build() - val store = StoreBuilder.from( - fetcher = Fetcher.of { key -> api.get(key) }, - sourceOfTruth = SourceOfTruth.of( - nonFlowReader = { key -> database.get(key)!!.data }, - writer = { key, note -> database.put(key, InputNote(note.data, Long.MAX_VALUE)) }, - delete = null, - deleteAll = null - ), - memoryCache = memoryCache - ).toMutableStoreBuilder( - converter - ).build( - updater = Updater.by( - post = { _, _ -> UpdaterResult.Error.Exception(Exception()) } - ) - ) - - val freshRequest = - StoreReadRequest.fresh(NotesKey.Collection(NoteCollections.Keys.OneAndTwo)) - - val freshStream = store.stream(freshRequest) - - val actualResultFromFreshStream = freshStream.take(2).toList() - val expectedResultFromFreshStream = listOf( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data(NoteCollections.OneAndTwo, StoreReadResponseOrigin.Fetcher()) - ) - - assertEquals(expectedResultFromFreshStream, actualResultFromFreshStream) - - val singleFromMemoryCache = memoryCache.getIfPresent(NotesKey.Single(Notes.One.id)) - assertIs(singleFromMemoryCache) - assertEquals(singleFromMemoryCache.item, Notes.One) - - val cachedRequest = StoreReadRequest.cached(NotesKey.Single(Notes.One.id), refresh = true) - val cachedStream = store.stream(cachedRequest) - val actualResultFromCachedStream = cachedStream.take(1).toList() - val expectedResultFromCachedStream = listOf( - StoreReadResponse.Data(NoteData.Single(Notes.One), StoreReadResponseOrigin.Cache) - ) - - assertEquals(expectedResultFromCachedStream, actualResultFromCachedStream) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesMemoryCache.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesMemoryCache.kt deleted file mode 100644 index 98bf49c51..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesMemoryCache.kt +++ /dev/null @@ -1,89 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -import org.mobilenativefoundation.store.cache5.Cache -import org.mobilenativefoundation.store.cache5.MultiCache -import org.mobilenativefoundation.store.store5.util.model.Note -import org.mobilenativefoundation.store.store5.util.model.NoteData - -internal class NotesMemoryCache(private val delegate: MultiCache) : Cache { - override fun getIfPresent(key: NotesKey): NoteData? = when (key) { - is NotesKey.Collection -> { - val items = delegate.getCollection>(key.id) - if (items != null) { - NoteData.Collection(items) - } else { - null - } - } - - is NotesKey.Single -> { - val item = delegate.getItem(key.id) - if (item != null) { - NoteData.Single(item) - } else { - null - } - } - } - - override fun getOrPut(key: NotesKey, valueProducer: () -> NoteData): NoteData { - val collection = getIfPresent(key) - return if (collection == null) { - val noteData = valueProducer() - put(key, noteData) - noteData - } else { - collection - } - } - - override fun getAllPresent(keys: List<*>): Map { - val map = mutableMapOf() - - keys.filterIsInstance().forEach { key -> - val noteData = getIfPresent(key) - if (noteData != null) { - map[key] = noteData - } - } - - return map - } - - override fun invalidateAll() { - delegate.invalidateAll() - } - - override fun size(): Long { - return delegate.size() - } - - override fun invalidateAll(keys: List) { - keys.forEach { key -> - invalidate(key) - } - } - - override fun invalidate(key: NotesKey) = when (key) { - is NotesKey.Collection -> delegate.invalidateCollection(key.id) - is NotesKey.Single -> delegate.invalidateItem(key.id) - } - - override fun putAll(map: Map) { - map.entries.forEach { (key, noteData) -> - put(key, noteData) - } - } - - override fun put(key: NotesKey, value: NoteData) = when (key) { - is NotesKey.Collection -> { - require(value is NoteData.Collection) - delegate.putCollection(key.id, value.items) - } - - is NotesKey.Single -> { - require(value is NoteData.Single) - delegate.putItem(key.id, value.item) - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt index 799506b68..dc0808c1b 100644 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt +++ b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt @@ -1,6 +1,5 @@ package org.mobilenativefoundation.store.store5.util.model -import org.mobilenativefoundation.store.cache5.Identifiable import org.mobilenativefoundation.store.store5.util.fake.NotesKey internal sealed class NoteData { @@ -29,7 +28,7 @@ internal data class OutputNote( ) internal data class Note( - override val id: String, + val id: String, val title: String, val content: String -) : Identifiable +) From 141519c178358e08ee37cfcb2c234002abac301d Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Thu, 7 Dec 2023 22:27:39 -0500 Subject: [PATCH 12/17] Add REPLACE InsertionStrategy --- .../mobilenativefoundation/store/core5/InsertionStrategy.kt | 3 ++- .../org/mobilenativefoundation/store/paging5/util/PostData.kt | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt index 9be64a478..0ebfc68cf 100644 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt @@ -2,5 +2,6 @@ package org.mobilenativefoundation.store.core5 enum class InsertionStrategy { APPEND, - PREPEND + PREPEND, + REPLACE } \ No newline at end of file diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt index e0f6da992..be2a911c9 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt @@ -25,6 +25,10 @@ sealed class PostData : StoreData { updatedItems.addAll(items) copyWith(items = updatedItems) } + + InsertionStrategy.REPLACE -> { + copyWith(items = posts) + } } } } From 1e20c3d22faf43a16321872e548994f7740bc773 Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Thu, 7 Dec 2023 22:33:13 -0500 Subject: [PATCH 13/17] Add AndroidManifest.xml --- core/src/androidMain/AndroidManifest.xml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 core/src/androidMain/AndroidManifest.xml diff --git a/core/src/androidMain/AndroidManifest.xml b/core/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..463e3657a --- /dev/null +++ b/core/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file From a77319cdfc8a90f700f55f3adcee4898289075ba Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Thu, 7 Dec 2023 22:36:29 -0500 Subject: [PATCH 14/17] Fix formatting errors --- .../store/cache5/StoreMultiCache.kt | 2 +- .../store/core5/InsertionStrategy.kt | 2 +- .../store/core5/KeyProvider.kt | 2 +- .../store/core5/StoreData.kt | 3 +-- .../store/core5/StoreKey.kt | 2 +- .../store/paging5/LaunchPagingStore.kt | 21 ++++++++++--------- .../store/paging5/LaunchPagingStoreTests.kt | 17 ++++++++++++--- .../store/paging5/util/FakePostApi.kt | 3 +-- .../store/paging5/util/FakePostDatabase.kt | 4 +--- .../paging5/util/FeedGetRequestResult.kt | 2 +- .../store/paging5/util/PostApi.kt | 2 +- .../store/paging5/util/PostData.kt | 3 --- .../store/paging5/util/PostDatabase.kt | 2 +- .../paging5/util/PostGetRequestResult.kt | 2 +- .../store/paging5/util/PostKey.kt | 1 - .../paging5/util/PostPutRequestResult.kt | 2 +- .../store/paging5/util/PostStoreFactory.kt | 4 +--- .../store/store5/impl/extensions/store.kt | 16 +++++++++----- 18 files changed, 49 insertions(+), 41 deletions(-) diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt index baa7db834..7003bc582 100644 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt @@ -150,4 +150,4 @@ class StoreMultiCache, Single : StoreData.Single> { fun fromCollection(key: StoreKey.Collection, value: Single): StoreKey.Single fun fromSingle(key: StoreKey.Single, value: Single): StoreKey.Collection -} \ No newline at end of file +} diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt index e163605bb..ca285a6a2 100644 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt @@ -1,6 +1,5 @@ package org.mobilenativefoundation.store.core5 - /** * An interface that defines items that can be uniquely identified. * Every item that implements the [StoreData] interface must have a means of identification. @@ -32,4 +31,4 @@ interface StoreData { */ fun insertItems(strategy: InsertionStrategy, items: List): Collection } -} \ No newline at end of file +} diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt index 245ce05a9..9026c9dd0 100644 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt @@ -58,4 +58,4 @@ interface StoreKey { interface Filter { operator fun invoke(items: List): List } -} \ No newline at end of file +} diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt index ceb8b3619..563800138 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt @@ -1,19 +1,25 @@ @file:Suppress("UNCHECKED_CAST") - package org.mobilenativefoundation.store.paging5 - import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.mobilenativefoundation.store.core5.StoreData import org.mobilenativefoundation.store.core5.StoreKey -import org.mobilenativefoundation.store.store5.* +import org.mobilenativefoundation.store.store5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.MutableStore +import org.mobilenativefoundation.store.store5.Store +import org.mobilenativefoundation.store.store5.StoreReadRequest +import org.mobilenativefoundation.store.store5.StoreReadResponse private class StopProcessingException : Exception() - /** * Initializes and returns a [StateFlow] that reflects the state of the Store, updating by a flow of provided keys. * @param scope A [CoroutineScope]. @@ -28,7 +34,6 @@ private fun , Output : StoreData> launchPagingS ): StateFlow> { val stateFlow = MutableStateFlow>(StoreReadResponse.Initial) - scope.launch { try { @@ -50,9 +55,7 @@ private fun , Output : StoreData> launchPagingS throw StopProcessingException() } } - } catch (_: StopProcessingException) { - } keys.drop(1).collect { key -> @@ -93,7 +96,6 @@ fun , Output : StoreData> MutableStore, Output : StoreData> joinData( key: Key, prevResponse: StoreReadResponse, @@ -109,4 +111,3 @@ private fun , Output : StoreData> jo val joinedOutput = (lastOutput?.insertItems(key.insertionStrategy, currentData.items) ?: currentData) as Output return StoreReadResponse.Data(joinedOutput, currentResponse.origin) } - diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt index 8fbcb903d..8dc2b78d2 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt @@ -7,8 +7,20 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test -import org.mobilenativefoundation.store.paging5.util.* -import org.mobilenativefoundation.store.store5.* +import org.mobilenativefoundation.store.paging5.util.FakePostApi +import org.mobilenativefoundation.store.paging5.util.FakePostDatabase +import org.mobilenativefoundation.store.paging5.util.PostApi +import org.mobilenativefoundation.store.paging5.util.PostData +import org.mobilenativefoundation.store.paging5.util.PostDatabase +import org.mobilenativefoundation.store.paging5.util.PostKey +import org.mobilenativefoundation.store.paging5.util.PostPutRequestResult +import org.mobilenativefoundation.store.paging5.util.PostStoreFactory +import org.mobilenativefoundation.store.store5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.MutableStore +import org.mobilenativefoundation.store.store5.StoreReadRequest +import org.mobilenativefoundation.store.store5.StoreReadResponse +import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin +import org.mobilenativefoundation.store.store5.StoreWriteRequest import kotlin.test.assertEquals import kotlin.test.assertIs @@ -132,7 +144,6 @@ class LaunchPagingStoreTests { assertIs(data2) assertEquals("2", data2.title) - store.write(StoreWriteRequest.of(PostKey.Single("2"), PostData.Post("2", "2-modified"))) val cached3 = store.stream(StoreReadRequest.cached(PostKey.Single("2"), refresh = false)) diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt index be6ae1076..7764cc65e 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostApi.kt @@ -33,5 +33,4 @@ class FakePostApi : PostApi { posts.put(post.id, post) return PostPutRequestResult.Data(post) } - -} \ No newline at end of file +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt index 6eab782c0..a126c9b3f 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FakePostDatabase.kt @@ -30,7 +30,5 @@ class FakePostDatabase(private val userId: String) : PostDatabase { override fun findFeedByUserId(cursor: String?, size: Int): PostData.Feed? { val feed = feeds[userId] return feed - } - -} \ No newline at end of file +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FeedGetRequestResult.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FeedGetRequestResult.kt index 8e6081a8d..e1d13e50e 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FeedGetRequestResult.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/FeedGetRequestResult.kt @@ -7,4 +7,4 @@ sealed class FeedGetRequestResult { data class Message(val error: String) : Error() data class Exception(val error: kotlin.Exception) : Error() } -} \ No newline at end of file +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostApi.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostApi.kt index fd733e301..90d87e601 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostApi.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostApi.kt @@ -4,4 +4,4 @@ interface PostApi { suspend fun get(postId: String): PostGetRequestResult suspend fun get(cursor: String?, size: Int): FeedGetRequestResult suspend fun put(post: PostData.Post): PostPutRequestResult -} \ No newline at end of file +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt index be2a911c9..ad6b05d28 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostData.kt @@ -33,6 +33,3 @@ sealed class PostData : StoreData { } } } - - - diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDatabase.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDatabase.kt index 38b3d40b0..d8ae595c9 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDatabase.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDatabase.kt @@ -5,4 +5,4 @@ interface PostDatabase { fun add(feed: PostData.Feed) fun findPostByPostId(postId: String): PostData.Post? fun findFeedByUserId(cursor: String?, size: Int): PostData.Feed? -} \ No newline at end of file +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostGetRequestResult.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostGetRequestResult.kt index f8f3e31ac..d481f661f 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostGetRequestResult.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostGetRequestResult.kt @@ -7,4 +7,4 @@ sealed class PostGetRequestResult { data class Message(val error: String) : Error() data class Exception(val error: kotlin.Exception) : Error() } -} \ No newline at end of file +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt index df5f0254c..451c5e0b9 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostKey.kt @@ -15,5 +15,4 @@ sealed class PostKey : StoreKey { data class Single( override val id: String ) : StoreKey.Single, PostKey() - } diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostPutRequestResult.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostPutRequestResult.kt index 8fd415099..fdf855dbb 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostPutRequestResult.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostPutRequestResult.kt @@ -7,4 +7,4 @@ sealed class PostPutRequestResult { data class Message(val error: String) : Error() data class Exception(val error: kotlin.Exception) : Error() } -} \ No newline at end of file +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt index f6d201a56..40ef9d872 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostStoreFactory.kt @@ -35,7 +35,6 @@ class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { throw Throwable(result.error) } } - } is PostKey.Cursor -> { @@ -121,7 +120,6 @@ class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { val cursor = (floor(id.toDouble() / 10) * 10) + 1 return PostKey.Cursor(cursor.toInt().toString(), 10) } - } private fun createMemoryCache(): Cache = @@ -137,4 +135,4 @@ class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { updater = createUpdater(), bookkeeper = null ) -} \ No newline at end of file +} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt index d72a62859..21ab44e54 100644 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt +++ b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt @@ -2,7 +2,13 @@ package org.mobilenativefoundation.store.store5.impl.extensions import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.first -import org.mobilenativefoundation.store.store5.* +import org.mobilenativefoundation.store.store5.Bookkeeper +import org.mobilenativefoundation.store.store5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.MutableStore +import org.mobilenativefoundation.store.store5.Store +import org.mobilenativefoundation.store.store5.StoreReadRequest +import org.mobilenativefoundation.store.store5.StoreReadResponse +import org.mobilenativefoundation.store.store5.Updater import org.mobilenativefoundation.store.store5.impl.RealMutableStore import org.mobilenativefoundation.store.store5.impl.RealStore @@ -30,6 +36,7 @@ suspend fun Store.fresh(key: Key) = .first() .requireData() +@OptIn(ExperimentalStoreApi::class) @Suppress("UNCHECKED_CAST") fun Store.asMutableStore( updater: Updater, @@ -45,17 +52,16 @@ fun Store< ) } - @OptIn(ExperimentalStoreApi::class) -suspend fun MutableStore.get(key: Key) = +suspend fun MutableStore.get(key: Key) = stream(StoreReadRequest.cached(key, refresh = false)) .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } .first() .requireData() @OptIn(ExperimentalStoreApi::class) -suspend fun MutableStore.fresh(key: Key) = +suspend fun MutableStore.fresh(key: Key) = stream(StoreReadRequest.fresh(key)) .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } .first() - .requireData() \ No newline at end of file + .requireData() From 025a646a86292fe5aaf74ca590d2c0ee490c1cde Mon Sep 17 00:00:00 2001 From: mramotar Date: Sun, 7 Jan 2024 14:47:57 -0500 Subject: [PATCH 15/17] Add design_doc.md --- paging/docs/design_doc.md | 246 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 paging/docs/design_doc.md diff --git a/paging/docs/design_doc.md b/paging/docs/design_doc.md new file mode 100644 index 000000000..493a95ffb --- /dev/null +++ b/paging/docs/design_doc.md @@ -0,0 +1,246 @@ +# Technical Design Doc: Native Paging Support in Store5 + +## Context and Scope +Feature request: [MobileNativeFoundation/Store#250](https://github.com/MobileNativeFoundation/Store/issues/250) + +This proposal addresses the need for paging support in Store. This enhancement aims to provide a simple, efficient, and flexible way to handle complex operations on large datasets. + +## Goals and Non-Goals +### Goals +- Provide native support for page-based and cursor-based fetches, handling both single items and collections. +- Enable read and write operations within a paging store. +- Support complex loading and fetching operations such as sorting and filtering. +- Ensure thread safety and concurrency support. +- Layer on top of existing Store APIs: no breaking changes! +### Non-Goals +- Integration with Paging3. +- Providing a one-size-fits-all solution: our approach should be flexible to cater to different use cases. + +## The Actual Design + +### APIs +#### StoreKey +An interface that defines keys used by Store for data-fetching operations. Allows Store to load individual items and collections of items. Provides mechanisms for ID-based fetch, page-based fetch, and cursor-based fetch. Includes options for sorting and filtering. + +```kotlin + interface StoreKey { + interface Single : StoreKey { + val id: Id + } + interface Collection : StoreKey { + val insertionStrategy: InsertionStrategy + interface Page : Collection { + val page: Int + val size: Int + val sort: Sort? + val filters: List>? + } + interface Cursor : Collection { + val cursor: Id? + val size: Int + val sort: Sort? + val filters: List>? + } + } + } +``` + +#### StoreData +An interface that defines items that can be uniquely identified. Every item that implements the `StoreData` interface must have a means of identification. This is useful in scenarios when data can be represented as singles or collections. + +```kotlin + interface StoreData { + interface Single : StoreData { + val id: Id + } + interface Collection> : StoreData { + val items: List + fun copyWith(items: List): Collection + fun insertItems(strategy: InsertionStrategy, items: List): Collection + } + } +``` + +#### KeyProvider +An interface to derive keys based on provided data. `StoreMultiCache` depends on `KeyProvider` to: + +1. Derive a single key for a collection item based on the collection’s key and that item’s value. +2. Insert a single item into the correct collection based on its key and value. + +```kotlin + interface KeyProvider> { + fun from(key: StoreKey.Collection, value: Single): StoreKey.Single + fun from(key: StoreKey.Single, value: Single): StoreKey.Collection + } +``` + +### Implementations + +#### StoreMultiCache +Thread-safe caching system with collection decomposition. Manages data with utility functions to get, invalidate, and add items to the cache. Depends on `StoreMultiCacheAccessor` for internal data management. Should be used instead of `MultiCache`. + +```kotlin + class StoreMultiCache, Single : StoreData.Single, Collection : StoreData.Collection, Output : StoreData>( + private val keyProvider: KeyProvider, + singlesCache: Cache, Single> = CacheBuilder, Single>().build(), + collectionsCache: Cache, Collection> = CacheBuilder, Collection>().build(), + ): Cache +``` + +#### StoreMultiCacheAccessor +Thread-safe intermediate data manager for a caching system supporting list decomposition. Tracks keys for rapid data retrieval and modification. + +#### LaunchPagingStore +Main entry point for the paging mechanism. This will launch and manage a `StateFlow` that reflects the current state of the Store. + +```kotlin + fun , Output : StoreData> Store.launchPagingStore( + scope: CoroutineScope, + keys: Flow, + ): StateFlow> + + @OptIn(ExperimentalStoreApi::class) + fun , Output : StoreData> MutableStore.launchPagingStore( + scope: CoroutineScope, + keys: Flow, + ): StateFlow> +``` + +## Usage +### StoreKey Example +```kotlin + sealed class ExampleKey : StoreKey { + data class Cursor( + override val cursor: String?, + override val size: Int, + override val sort: StoreKey.Sort? = null, + override val filters: List>? = null, + override val insertionStrategy: InsertionStrategy = InsertionStrategy.APPEND + ) : StoreKey.Collection.Cursor, ExampleKey() + + data class Single( + override val id: String + ) : StoreKey.Single, ExampleKey() + } +``` + +### StoreData Example +```kotlin + sealed class ExampleData : StoreData { + data class Single(val postId: String, val title: String) : StoreData.Single, ExampleData() { + override val id: String get() = postId + } + + data class Collection(val singles: List) : StoreData.Collection, ExampleData() { + override val items: List get() = singles + override fun copyWith(items: List): StoreData.Collection = copy(singles = items) + override fun insertItems(strategy: InsertionStrategy, items: List): StoreData.Collection { + + return when (strategy) { + InsertionStrategy.APPEND -> { + val updatedItems = items.toMutableList() + updatedItems.addAll(singles) + copyWith(items = updatedItems) + } + + InsertionStrategy.PREPEND -> { + val updatedItems = singles.toMutableList() + updatedItems.addAll(items) + copyWith(items = updatedItems) + } + } + } + } + } +``` + +### LaunchPagingStore Example +```kotlin + @OptIn(ExperimentalStoreApi::class) + class ExampleViewModel( + private val store: MutableStore, + private val coroutineScope: CoroutineScope = viewModelScope, + private val loadSize: Int = DEFAULT_LOAD_SIZE + ) : ViewModel() { + + private val keys = MutableStateFlow(ExampleKey.Cursor(null, loadSize)) + private val _loading = MutableStateFlow(false) + private val _error = MutableStateFlow(null) + + val stateFlow = store.launchPagingStore(coroutineScope, keys) + val loading: StateFlow = _loading.asStateFlow() + val error: StateFlow = _error.asStateFlow() + + init { + TODO("Observe loading and error states and perform any other necessary initializations") + } + + fun loadMore() { + if (_loading.value) return // Prevent loading more if already loading + _loading.value = true + + coroutineScope.launch { + try { + val currentKey = keys.value + val currentCursor = currentKey.cursor + val nextCursor = determineNextCursor(currentCursor) + val nextKey = currentKey.copy(cursor = nextCursor) + keys.value = nextKey + } catch (e: Throwable) { + _error.value = e + } finally { + _loading.value = false + } + } + } + + fun write(key: ExampleKey.Single, value: ExampleData.Single) { + coroutineScope.launch { + try { + store.write(StoreWriteRequest.of(key, value)) + } catch (e: Throwable) { + _error.value = e + } + } + } + + private fun determineNextCursor(cursor: String?): String? { + // Implementation based on specific use case + // Return the next cursor or null if there are no more items to load + TODO("Provide an implementation or handle accordingly") + } + + companion object { + private const val DEFAULT_LOAD_SIZE = 100 + } + } +``` + +## Degree of Constraint +- Data items must implement the `StoreData` interface, ensuring they can be uniquely identified. +- Keys for loading data must implement the `StoreKey` interface. + +## Deprecations +- MultiCache +- Identifiable + +## Alternatives Considered +### Tailored Solution for Paging +#### Direct integration with Paging3 +Paging3 doesn’t have built-in support for: +- Singles and collections +- Write operations +- Sorting and filtering operations + +### Custom `StoreKey` and `StoreData` Structures +#### Loose Typing +#### Annotations and Reflection +#### Functional Programming Approach + +## Cross-Cutting Concerns +- Will Paging3 extensions be a maintenance nightmare? +- Will these APIs be simpler than Paging3? + +## Future Directions +- Bindings for Paging3 (follow-up PR) +- Support for KMP Compose UI (follow-up PR) \ No newline at end of file From fcf6fc07b8ee67982db526482b74a43739c9fd02 Mon Sep 17 00:00:00 2001 From: Matt Ramotar Date: Sun, 7 Jan 2024 14:48:48 -0500 Subject: [PATCH 16/17] Fix build.gradle.kts Signed-off-by: mramotar --- core/build.gradle.kts | 1 - paging/build.gradle.kts | 18 ++++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index b55cc5c72..f7277233d 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -8,7 +8,6 @@ plugins { id("org.jetbrains.kotlinx.kover") `maven-publish` id("kotlinx-atomicfu") - id("org.jetbrains.compose") version("1.5.1") } kotlin { diff --git a/paging/build.gradle.kts b/paging/build.gradle.kts index 981273851..336069779 100644 --- a/paging/build.gradle.kts +++ b/paging/build.gradle.kts @@ -49,8 +49,22 @@ kotlin { } android { - + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") compileSdk = 33 - sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") + defaultConfig { + minSdk = 24 + } + + lint { + disable += "ComposableModifierFactory" + disable += "ModifierFactoryExtensionFunction" + disable += "ModifierFactoryReturnType" + disable += "ModifierFactoryUnreferencedReceiver" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } } From d9b3d140ba6297ac63b28fd7a74bfd3392681b5c Mon Sep 17 00:00:00 2001 From: mramotar Date: Sun, 7 Jan 2024 14:52:07 -0500 Subject: [PATCH 17/17] Update versions Signed-off-by: mramotar --- gradle.properties | 2 +- gradle/libs.versions.toml | 2 +- store/build.gradle.kts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle.properties b/gradle.properties index f1e664f94..8e82db2b1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ org.gradle.jvmargs=-XX:MaxMetaspaceSize=2G # POM file GROUP=org.mobilenativefoundation.store -VERSION_NAME=5.0.0 +VERSION_NAME=5.1.0-SNAPSHOT POM_PACKAGING=pom POM_DESCRIPTION = Store5 is a Kotlin Multiplatform network-resilient repository layer diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5187cf53d..c80fac0e9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ testCore = "1.5.0" kmmBridge = "0.3.2" ktlint = "0.39.0" kover = "0.6.0" -store = "5.0.0-beta03" +store = "5.0.0" truth = "1.1.3" [libraries] diff --git a/store/build.gradle.kts b/store/build.gradle.kts index e9a9d1f8d..22a2d1bde 100644 --- a/store/build.gradle.kts +++ b/store/build.gradle.kts @@ -116,7 +116,7 @@ addGithubPackagesRepository() kmmbridge { githubReleaseArtifacts() githubReleaseVersions() - versionPrefix.set("5.0.0-alpha") + versionPrefix.set("5.0.0") spm() }