From 1d78b440c5443f2db3ca94a76123220e6c2de865 Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Wed, 14 Feb 2024 00:18:13 -0500 Subject: [PATCH 01/10] Fix launchPagingStore Signed-off-by: mramotar_dbx --- .../store/paging5/LaunchPagingStore.kt | 64 ++++++++++--------- .../store/paging5/LaunchPagingStoreTests.kt | 63 ++++++++++++++++-- .../store/paging5/util/PostData.kt | 10 +-- 3 files changed, 100 insertions(+), 37 deletions(-) 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 58138e5e5..ae30558e0 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt @@ -3,13 +3,14 @@ package org.mobilenativefoundation.store.paging5 import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel 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 kotlinx.coroutines.plus import org.mobilenativefoundation.store.core5.ExperimentalStoreApi import org.mobilenativefoundation.store.core5.StoreData import org.mobilenativefoundation.store.core5.StoreKey @@ -18,8 +19,6 @@ 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]. @@ -33,38 +32,47 @@ private fun , Output : StoreData> launchPagingS keys: Flow, stream: (key: Key) -> Flow>, ): StateFlow> { + val childScope = scope + Job() + + val prevData = MutableStateFlow?>(null) val stateFlow = MutableStateFlow>(StoreReadResponse.Initial) + val activeStreams = mutableMapOf() - scope.launch { + childScope.launch { + keys.collect { key -> + if (key !is StoreKey.Collection<*>) { + throw IllegalArgumentException("Invalid key type") + } - try { - val firstKey = keys.first() - if (firstKey !is StoreKey.Collection<*>) throw IllegalArgumentException("Invalid key type") + if (activeStreams[key]?.isActive != true) { + val job = this.launch { + stream(key).collect { response -> + when (response) { + is StoreReadResponse.Data -> { + val joinedDataResponse = joinData(key, prevData.value, response) + prevData.emit(joinedDataResponse) + stateFlow.emit(joinedDataResponse) + } - stream(firstKey).collect { response -> - if (response is StoreReadResponse.Data) { - val joinedDataResponse = joinData(firstKey, stateFlow.value, response) - stateFlow.emit(joinedDataResponse) - } else { - stateFlow.emit(response) + else -> { + stateFlow.emit(response) + } + } + } } - if (response is StoreReadResponse.Data || - response is StoreReadResponse.Error || - response is StoreReadResponse.NoNewData - ) { - throw StopProcessingException() + activeStreams[key] = job + + job.invokeOnCompletion { + activeStreams[key]?.cancel() + activeStreams.remove(key) } } - } 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) - } + scope.coroutineContext[Job]?.invokeOnCompletion { + childScope.cancel() } return stateFlow.asStateFlow() @@ -101,16 +109,14 @@ fun , Output : StoreData> MutableStore, Output : StoreData> joinData( key: Key, - prevResponse: StoreReadResponse, + prevResponse: StoreReadResponse.Data?, currentResponse: StoreReadResponse.Data ): StoreReadResponse.Data { val lastOutput = when (prevResponse) { is StoreReadResponse.Data -> prevResponse.value as? StoreData.Collection> else -> null } - val currentData = currentResponse.value as StoreData.Collection> - 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 af2d0e036..4ed8863b8 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt @@ -1,6 +1,7 @@ package org.mobilenativefoundation.store.paging5 import app.cash.turbine.test +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope @@ -62,7 +63,7 @@ class LaunchPagingStoreTests { fun multipleValidKeysEmittedInSuccession() = testScope.runTest { val key1 = PostKey.Cursor("1", 10) val key2 = PostKey.Cursor("11", 10) - val keys = flowOf(key1, key2) + val keys = MutableStateFlow(key1) val stateFlow = store.launchPagingStore(this, keys) stateFlow.test { @@ -74,10 +75,15 @@ class LaunchPagingStoreTests { assertIs>(state3) assertEquals("1", state3.value.posts[0].postId) + keys.emit(key2) + + val loading2 = awaitItem() + assertIs(loading2) + val state4 = awaitItem() assertIs>(state4) - assertEquals("11", state4.value.posts[0].postId) - assertEquals("1", state4.value.posts[10].postId) + assertEquals("1", state4.value.posts[0].postId) + assertEquals("11", state4.value.posts[10].postId) val data4 = state4.value assertIs(data4) assertEquals(20, data4.items.size) @@ -111,9 +117,10 @@ class LaunchPagingStoreTests { val key1 = PostKey.Cursor("1", 10) val key2 = PostKey.Cursor("11", 10) - val keys = flowOf(key1, key2) + val keys = MutableStateFlow(key1) val stateFlow = store.launchPagingStore(this, keys) + stateFlow.test { val initialState = awaitItem() assertIs(initialState) @@ -123,10 +130,18 @@ class LaunchPagingStoreTests { assertIs>(loadedState1) val data1 = loadedState1.value assertEquals(10, data1.posts.size) + assertEquals("1", data1.posts[0].postId) + expectNoEvents() + + keys.emit(key2) + + val loadingState2 = awaitItem() + assertIs(loadingState2) val loadedState2 = awaitItem() assertIs>(loadedState2) val data2 = loadedState2.value assertEquals(20, data2.posts.size) + assertEquals("1", data2.posts[0].postId) } val cached = store.stream(StoreReadRequest.cached(key1, refresh = false)) @@ -164,4 +179,44 @@ class LaunchPagingStoreTests { assertIs(data4) assertEquals("2-modified", data4.posts[1].title) } + + @Test + fun multipleKeysWithReadsAndWritesUsingOneStream() = testScope.runTest { + val api = FakePostApi() + val db = FakePostDatabase(userId) + val factory = PostStoreFactory(api = api, db = db) + val mutableStore = factory.create() + + val key1 = PostKey.Cursor("1", 10) + val key2 = PostKey.Cursor("11", 10) + val keys = flowOf(key1, key2) + + val stateFlow = mutableStore.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) + assertEquals("1", data1.posts[0].postId) + val loadedState2 = awaitItem() + assertIs>(loadedState2) + val data2 = loadedState2.value + assertEquals(20, data2.posts.size) + assertEquals("1", data1.posts[0].postId) + } + + mutableStore.write(StoreWriteRequest.of(PostKey.Single("2"), PostData.Post("2", "2-modified"))) + + stateFlow.test { + val loadedState3 = awaitItem() + assertIs>(loadedState3) + val data3 = loadedState3.value + assertEquals(20, data3.posts.size) + assertEquals("2-modified", data3.posts[1].title) // Actual is "2" + } + } } 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 ad6b05d28..01625d8eb 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,8 +1,10 @@ package org.mobilenativefoundation.store.paging5.util +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi import org.mobilenativefoundation.store.core5.InsertionStrategy import org.mobilenativefoundation.store.core5.StoreData +@OptIn(ExperimentalStoreApi::class) sealed class PostData : StoreData { data class Post(val postId: String, val title: String) : StoreData.Single, PostData() { override val id: String get() = postId @@ -15,14 +17,14 @@ sealed class PostData : StoreData { return when (strategy) { InsertionStrategy.APPEND -> { - val updatedItems = items.toMutableList() - updatedItems.addAll(posts) + val updatedItems = posts.toMutableList() + updatedItems.addAll(items) copyWith(items = updatedItems) } InsertionStrategy.PREPEND -> { - val updatedItems = posts.toMutableList() - updatedItems.addAll(items) + val updatedItems = items.toMutableList() + updatedItems.addAll(posts) copyWith(items = updatedItems) } From 02752537b0b9a7831da0736b6f18d43a6e38aa8d Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Sat, 17 Feb 2024 12:39:00 -0500 Subject: [PATCH 02/10] All tests passing Signed-off-by: mramotar_dbx --- .../store/core5/StoreKey.kt | 9 +- .../store/paging5/LaunchPagingStore.kt | 229 ++++++++++++------ .../store/paging5/LaunchPagingStoreTests.kt | 33 ++- .../store/paging5/util/FakePostApi.kt | 2 + .../store/paging5/util/PostDataJoiner.kt | 30 +++ .../store/paging5/util/PostKey.kt | 12 +- 6 files changed, 228 insertions(+), 87 deletions(-) create mode 100644 paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDataJoiner.kt 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 529f762a8..2a545d022 100644 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt @@ -1,5 +1,12 @@ package org.mobilenativefoundation.store.core5 + +@OptIn(ExperimentalStoreApi::class) +interface KeyFactory> { + fun createSingleFor(id: Id): Key +} + + /** * An interface that defines keys used by Store for data-fetching operations. * Allows Store to fetch individual items and collections of items. @@ -12,7 +19,7 @@ interface StoreKey { /** * Represents a key for fetching an individual item. */ - interface Single : StoreKey { + interface Single : StoreKey { val id: Id } 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 ae30558e0..b3e913521 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt @@ -4,14 +4,16 @@ package org.mobilenativefoundation.store.paging5 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel 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 kotlinx.coroutines.plus +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.KeyFactory import org.mobilenativefoundation.store.core5.StoreData import org.mobilenativefoundation.store.core5.StoreKey import org.mobilenativefoundation.store.store5.MutableStore @@ -19,104 +21,179 @@ import org.mobilenativefoundation.store.store5.Store import org.mobilenativefoundation.store.store5.StoreReadRequest import org.mobilenativefoundation.store.store5.StoreReadResponse +@ExperimentalStoreApi +interface StorePager, Output : StoreData> { + val state: StateFlow> + fun load(key: Key) +} + +@ExperimentalStoreApi +interface DataJoiner, Output : StoreData.Collection, Single : StoreData.Single> { + suspend operator fun invoke( + key: Key, + data: Map?> + ): StoreReadResponse.Data +} + + /** - * 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 stream A lambda that invokes [Store.stream]. - * @return A read-only [StateFlow] reflecting the state of the Store. + * Initializes and returns a [StateFlow] that reflects the state of the [Store], updating by a flow of provided keys. + * @see [launchPagingStore]. */ @ExperimentalStoreApi -private fun , Output : StoreData> launchPagingStore( +fun , Output : StoreData, SingleKey : StoreKey.Single, Collection : StoreData.Collection, Single : StoreData.Single> MutableStore.launchPagingStore( scope: CoroutineScope, keys: Flow, - stream: (key: Key) -> Flow>, + joiner: DataJoiner, + keyFactory: KeyFactory ): StateFlow> { - val childScope = scope + Job() + fun streamer(key: Key): Flow> { + println("STREAMING FOR KEY $key") + return stream(StoreReadRequest.fresh(key)) + } + + val pager = RealStorePager(scope, ::streamer, joiner, keyFactory) - val prevData = MutableStateFlow?>(null) - val stateFlow = MutableStateFlow>(StoreReadResponse.Initial) - val activeStreams = mutableMapOf() + val childScope = scope + Job() childScope.launch { keys.collect { key -> - if (key !is StoreKey.Collection<*>) { - throw IllegalArgumentException("Invalid key type") - } + pager.load(key) + } + } - if (activeStreams[key]?.isActive != true) { - val job = this.launch { - stream(key).collect { response -> - when (response) { - is StoreReadResponse.Data -> { - val joinedDataResponse = joinData(key, prevData.value, response) - prevData.emit(joinedDataResponse) - stateFlow.emit(joinedDataResponse) - } + return pager.state +} + +@ExperimentalStoreApi +class RealStorePager, SingleKey : StoreKey.Single, Output : StoreData, Collection : StoreData.Collection, Single : StoreData.Single>( + private val scope: CoroutineScope, + private val streamer: (key: Key) -> Flow>, + private val joiner: DataJoiner, + private val keyFactory: KeyFactory +) : StorePager { + private val mutableStateFlow = MutableStateFlow>(StoreReadResponse.Initial) + override val state: StateFlow> = mutableStateFlow.asStateFlow() + + private val data: MutableMap?> = mutableMapOf() + private val streams: MutableMap = mutableMapOf() + + private val dataMutex = Mutex() + private val streamsMutex = Mutex() + + override fun load(key: Key) { + + println("HITTING0 $key") + + if (key !is StoreKey.Collection<*>) { + throw IllegalArgumentException("Invalid key type") + } + + val childScope = scope + Job() + + childScope.launch { + streamsMutex.withLock { + if (streams[key]?.isActive != true) { + data[key] = null + + val nestedKeys = mutableListOf() + + val job = launch { + streamer(key).collect { response -> + when (response) { + is StoreReadResponse.Data -> { + println("HITTING1 $response") + (response as? StoreReadResponse.Data)?.let { + dataMutex.withLock { + data[key] = it + val joinedData = joiner(key, data) + (joinedData as? StoreReadResponse.Data)?.let { + mutableStateFlow.emit(it) + } + } + + + it.value.items.forEach { single -> + // TODO: Start stream for each single + // TODO: When change in single, update paging state + val singleKey = keyFactory.createSingleFor(single.id) + (singleKey as? Key)?.let { k -> + + val nestedJob = launch { + streamer(k).collect { singleResponse -> + when (singleResponse) { + is StoreReadResponse.Data -> { + println("HITTING NESTED $singleResponse") + (singleResponse as? StoreReadResponse.Data)?.let { + dataMutex.withLock { + data[key]?.value?.items?.let { items -> + val index = + items.indexOfFirst { it.id == single.id } + val updatedItems = items.toMutableList() + + if (updatedItems[index] != it.value) { + println("HITTING FOR ${it.value}") + updatedItems[index] = it.value + val updatedCollection = + data[key]!!.value.copyWith(updatedItems) as? Collection + + updatedCollection?.let { collection -> + data[key] = data[key]!!.copy(collection) + + val joinedData = joiner(key, data) + (joinedData as? StoreReadResponse.Data)?.let { + mutableStateFlow.emit(it) + } + } + } + + } - else -> { - stateFlow.emit(response) + } + } + } + + else -> {} + } + } + + + } + + streams[k] = nestedJob + nestedKeys.add(k) + } + + } + } + } + + else -> { + println("HITTING $response") + mutableStateFlow.emit(response) + } } } } - } - activeStreams[key] = job + streams[key] = job + + job.invokeOnCompletion { + nestedKeys.forEach { + streams[it]?.cancel() + streams.remove(it) + } - job.invokeOnCompletion { - activeStreams[key]?.cancel() - activeStreams.remove(key) + streams[key]?.cancel() + streams.remove(key) + } } } } } +} - scope.coroutineContext[Job]?.invokeOnCompletion { - childScope.cancel() - } - return stateFlow.asStateFlow() -} -/** - * Initializes and returns a [StateFlow] that reflects the state of the [Store], updating by a flow of provided keys. - * @see [launchPagingStore]. - */ -@ExperimentalStoreApi -fun , Output : StoreData> Store.launchPagingStore( - scope: CoroutineScope, - keys: Flow, -): StateFlow> { - 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 [launchPagingStore]. - */ -@ExperimentalStoreApi -fun , Output : StoreData> MutableStore.launchPagingStore( - scope: CoroutineScope, - keys: Flow, -): StateFlow> { - return launchPagingStore(scope, keys) { key -> - this.stream(StoreReadRequest.fresh(key)) - } -} -@ExperimentalStoreApi -private fun , Output : StoreData> joinData( - key: Key, - prevResponse: StoreReadResponse.Data?, - currentResponse: StoreReadResponse.Data -): StoreReadResponse.Data { - val lastOutput = when (prevResponse) { - is StoreReadResponse.Data -> prevResponse.value as? StoreData.Collection> - else -> null - } - val currentData = currentResponse.value as StoreData.Collection> - 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 4ed8863b8..e613a68a5 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt @@ -1,18 +1,22 @@ package org.mobilenativefoundation.store.paging5 import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.mobilenativefoundation.store.core5.ExperimentalStoreApi 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.PostDataJoiner import org.mobilenativefoundation.store.paging5.util.PostDatabase import org.mobilenativefoundation.store.paging5.util.PostKey +import org.mobilenativefoundation.store.paging5.util.PostKeyFactory import org.mobilenativefoundation.store.paging5.util.PostPutRequestResult import org.mobilenativefoundation.store.paging5.util.PostStoreFactory import org.mobilenativefoundation.store.store5.MutableStore @@ -33,6 +37,8 @@ class LaunchPagingStoreTests { private lateinit var api: PostApi private lateinit var db: PostDatabase private lateinit var store: MutableStore + private lateinit var joiner: PostDataJoiner + private val keyFactory = PostKeyFactory() @BeforeTest fun setup() { @@ -40,13 +46,14 @@ class LaunchPagingStoreTests { db = FakePostDatabase(userId) val factory = PostStoreFactory(api, db) store = factory.create() + joiner = PostDataJoiner() } @Test fun transitionFromInitialToData() = testScope.runTest { val key = PostKey.Cursor("1", 10) val keys = flowOf(key) - val stateFlow = store.launchPagingStore(this, keys) + val stateFlow = store.launchPagingStore(this, keys, joiner, keyFactory = keyFactory) stateFlow.test { val state1 = awaitItem() @@ -64,7 +71,7 @@ class LaunchPagingStoreTests { val key1 = PostKey.Cursor("1", 10) val key2 = PostKey.Cursor("11", 10) val keys = MutableStateFlow(key1) - val stateFlow = store.launchPagingStore(this, keys) + val stateFlow = store.launchPagingStore(this, keys, joiner, keyFactory = keyFactory) stateFlow.test { val state1 = awaitItem() @@ -95,7 +102,7 @@ class LaunchPagingStoreTests { fun sameKeyEmittedMultipleTimes() = testScope.runTest { val key = PostKey.Cursor("1", 10) val keys = flowOf(key, key) - val stateFlow = store.launchPagingStore(this, keys) + val stateFlow = store.launchPagingStore(this, keys, joiner, keyFactory = keyFactory) stateFlow.test { val state1 = awaitItem() @@ -119,7 +126,7 @@ class LaunchPagingStoreTests { val key2 = PostKey.Cursor("11", 10) val keys = MutableStateFlow(key1) - val stateFlow = store.launchPagingStore(this, keys) + val stateFlow = store.launchPagingStore(this, keys, joiner, keyFactory = keyFactory) stateFlow.test { val initialState = awaitItem() @@ -140,6 +147,7 @@ class LaunchPagingStoreTests { val loadedState2 = awaitItem() assertIs>(loadedState2) val data2 = loadedState2.value + println(data2) assertEquals(20, data2.posts.size) assertEquals("1", data2.posts[0].postId) } @@ -180,6 +188,7 @@ class LaunchPagingStoreTests { assertEquals("2-modified", data4.posts[1].title) } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun multipleKeysWithReadsAndWritesUsingOneStream() = testScope.runTest { val api = FakePostApi() @@ -189,9 +198,9 @@ class LaunchPagingStoreTests { val key1 = PostKey.Cursor("1", 10) val key2 = PostKey.Cursor("11", 10) - val keys = flowOf(key1, key2) + val keys = MutableStateFlow(key1) - val stateFlow = mutableStore.launchPagingStore(this, keys) + val stateFlow = mutableStore.launchPagingStore(this, keys, joiner, keyFactory = keyFactory) stateFlow.test { val initialState = awaitItem() assertIs(initialState) @@ -202,16 +211,22 @@ class LaunchPagingStoreTests { val data1 = loadedState1.value assertEquals(10, data1.posts.size) assertEquals("1", data1.posts[0].postId) + + keys.emit(key2) + + val loadingState2 = awaitItem() + assertIs(loadingState2) + val loadedState2 = awaitItem() assertIs>(loadedState2) val data2 = loadedState2.value assertEquals(20, data2.posts.size) assertEquals("1", data1.posts[0].postId) - } - mutableStore.write(StoreWriteRequest.of(PostKey.Single("2"), PostData.Post("2", "2-modified"))) + mutableStore.write(StoreWriteRequest.of(PostKey.Single("2"), PostData.Post("2", "2-modified"))) + println("WROTE TO STORE") + advanceUntilIdle() - stateFlow.test { val loadedState3 = awaitItem() assertIs>(loadedState3) val data3 = loadedState3.value 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 7764cc65e..59c7bb08f 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 @@ -23,9 +23,11 @@ class FakePostApi : PostApi { } override suspend fun get(cursor: String?, size: Int): FeedGetRequestResult { + println("HITTING IN GET $cursor") val firstIndexInclusive = postsList.indexOfFirst { it.postId == cursor } val lastIndexExclusive = firstIndexInclusive + size val posts = postsList.subList(firstIndexInclusive, lastIndexExclusive) + println("ABOUT TO RETURN FOR $cursor") return FeedGetRequestResult.Data(PostData.Feed(posts = posts)) } diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDataJoiner.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDataJoiner.kt new file mode 100644 index 000000000..2586ef15b --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDataJoiner.kt @@ -0,0 +1,30 @@ +package org.mobilenativefoundation.store.paging5.util + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.paging5.DataJoiner +import org.mobilenativefoundation.store.store5.StoreReadResponse + +@OptIn(ExperimentalStoreApi::class) +class PostDataJoiner : DataJoiner { + override suspend fun invoke( + key: PostKey, + data: Map?> + ): StoreReadResponse.Data { + var combinedItems = mutableListOf() + + data.values.forEach { responseData -> + println("RESPONSE DATA = $responseData") + responseData?.value?.items?.let { items -> + combinedItems = (combinedItems + items).distinctBy { it.postId }.toMutableList() + } + } + + return (key as? PostKey.Cursor)?.let { + val feed = PostData.Feed(combinedItems) + data.values.last { it != null }?.let { + StoreReadResponse.Data(feed, it.origin) + } + } ?: throw IllegalArgumentException("Key must be a Collection type") + } + +} \ 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 451c5e0b9..cff1cd117 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,8 +1,12 @@ package org.mobilenativefoundation.store.paging5.util +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi import org.mobilenativefoundation.store.core5.InsertionStrategy +import org.mobilenativefoundation.store.core5.KeyFactory +import org.mobilenativefoundation.store.core5.StoreData import org.mobilenativefoundation.store.core5.StoreKey +@OptIn(ExperimentalStoreApi::class) sealed class PostKey : StoreKey { data class Cursor( override val cursor: String?, @@ -11,8 +15,14 @@ sealed class PostKey : StoreKey { override val filters: List>? = null, override val insertionStrategy: InsertionStrategy = InsertionStrategy.APPEND ) : StoreKey.Collection.Cursor, PostKey() - data class Single( override val id: String ) : StoreKey.Single, PostKey() } + + +class PostKeyFactory : KeyFactory { + override fun createSingleFor(id: String): PostKey.Single { + return PostKey.Single(id) + } +} \ No newline at end of file From 3a641fe7f2d8b0bd1353c32902a26ac70693e8e8 Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Sat, 17 Feb 2024 14:44:06 -0500 Subject: [PATCH 03/10] Clean up Signed-off-by: mramotar_dbx --- .../store/core5/StoreKey.kt | 7 - .../store/paging5/LaunchPagingStore.kt | 251 +++++++++--------- .../store/paging5/LaunchPagingStoreTests.kt | 208 ++++----------- .../store/paging5/util/PostDataJoiner.kt | 30 --- .../store/paging5/util/PostJoiner.kt | 20 ++ .../store/paging5/util/PostKey.kt | 7 +- 6 files changed, 206 insertions(+), 317 deletions(-) delete mode 100644 paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDataJoiner.kt create mode 100644 paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostJoiner.kt 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 2a545d022..f5560e7f9 100644 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt +++ b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt @@ -1,12 +1,5 @@ package org.mobilenativefoundation.store.core5 - -@OptIn(ExperimentalStoreApi::class) -interface KeyFactory> { - fun createSingleFor(id: Id): Key -} - - /** * An interface that defines keys used by Store for data-fetching operations. * Allows Store to fetch individual items and collections of items. 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 b3e913521..422c69cf2 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.plus import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.core5.KeyFactory import org.mobilenativefoundation.store.core5.StoreData import org.mobilenativefoundation.store.core5.StoreKey import org.mobilenativefoundation.store.store5.MutableStore @@ -21,179 +20,185 @@ import org.mobilenativefoundation.store.store5.Store import org.mobilenativefoundation.store.store5.StoreReadRequest import org.mobilenativefoundation.store.store5.StoreReadResponse -@ExperimentalStoreApi -interface StorePager, Output : StoreData> { - val state: StateFlow> - fun load(key: Key) -} @ExperimentalStoreApi -interface DataJoiner, Output : StoreData.Collection, Single : StoreData.Single> { - suspend operator fun invoke( - key: Key, - data: Map?> - ): StoreReadResponse.Data -} +data class PagingData>( + val items: List +) - -/** - * Initializes and returns a [StateFlow] that reflects the state of the [Store], updating by a flow of provided keys. - * @see [launchPagingStore]. - */ @ExperimentalStoreApi -fun , Output : StoreData, SingleKey : StoreKey.Single, Collection : StoreData.Collection, Single : StoreData.Single> MutableStore.launchPagingStore( - scope: CoroutineScope, - keys: Flow, - joiner: DataJoiner, - keyFactory: KeyFactory -): StateFlow> { - fun streamer(key: Key): Flow> { - println("STREAMING FOR KEY $key") - return stream(StoreReadRequest.fresh(key)) - } +interface Pager, SO : StoreData.Single> { + val state: StateFlow> + fun load(key: K) + + companion object { + fun , K : StoreKey, SO : StoreData.Single, O : StoreData> create( + scope: CoroutineScope, + store: Store, + joiner: Joiner, + keyFactory: KeyFactory + ): Pager { + + val streamer = object : Streamer { + override fun invoke(key: K): Flow> { + return store.stream(StoreReadRequest.fresh(key)) + } + } + + return RealPager( + scope, + streamer, + joiner, + keyFactory + ) + } - val pager = RealStorePager(scope, ::streamer, joiner, keyFactory) + fun , K : StoreKey, SO : StoreData.Single, O : StoreData> create( + scope: CoroutineScope, + store: MutableStore, + joiner: Joiner, + keyFactory: KeyFactory + ): Pager { - val childScope = scope + Job() + val streamer = object : Streamer { + override fun invoke(key: K): Flow> { + return store.stream(StoreReadRequest.fresh(key)) + } + } - childScope.launch { - keys.collect { key -> - pager.load(key) + return RealPager( + scope, + streamer, + joiner, + keyFactory + ) } } +} - return pager.state +@ExperimentalStoreApi +interface Joiner, SO : StoreData.Single> { + suspend operator fun invoke(data: Map>): PagingData } @ExperimentalStoreApi -class RealStorePager, SingleKey : StoreKey.Single, Output : StoreData, Collection : StoreData.Collection, Single : StoreData.Single>( - private val scope: CoroutineScope, - private val streamer: (key: Key) -> Flow>, - private val joiner: DataJoiner, - private val keyFactory: KeyFactory -) : StorePager { - private val mutableStateFlow = MutableStateFlow>(StoreReadResponse.Initial) - override val state: StateFlow> = mutableStateFlow.asStateFlow() +interface Streamer, O : StoreData> { + operator fun invoke(key: K): Flow> +} - private val data: MutableMap?> = mutableMapOf() - private val streams: MutableMap = mutableMapOf() +@ExperimentalStoreApi +interface KeyFactory> { + fun createFor(id: Id): SK +} - private val dataMutex = Mutex() - private val streamsMutex = Mutex() +@ExperimentalStoreApi +class RealPager, K : StoreKey, SO : StoreData.Single, O : StoreData>( + private val scope: CoroutineScope, + private val streamer: Streamer, + private val joiner: Joiner, + private val keyFactory: KeyFactory +) : Pager { - override fun load(key: Key) { + private val mutableStateFlow = MutableStateFlow(emptyPagingData()) + override val state: StateFlow> = mutableStateFlow.asStateFlow() - println("HITTING0 $key") + private val allPagingData: MutableMap> = mutableMapOf() + private val allStreams: MutableMap = mutableMapOf() + private val mutexForAllPagingData = Mutex() + private val mutexForAllStreams = Mutex() + override fun load(key: K) { if (key !is StoreKey.Collection<*>) { - throw IllegalArgumentException("Invalid key type") + throw IllegalArgumentException("Invalid key type.") } val childScope = scope + Job() childScope.launch { - streamsMutex.withLock { - if (streams[key]?.isActive != true) { - data[key] = null + mutexForAllStreams.withLock { + if (allStreams[key]?.isActive != true) { + allPagingData[key] = emptyPagingData() - val nestedKeys = mutableListOf() + val childrenKeys = mutableListOf() - val job = launch { + val mainJob = launch { streamer(key).collect { response -> - when (response) { - is StoreReadResponse.Data -> { - println("HITTING1 $response") - (response as? StoreReadResponse.Data)?.let { - dataMutex.withLock { - data[key] = it - val joinedData = joiner(key, data) - (joinedData as? StoreReadResponse.Data)?.let { - mutableStateFlow.emit(it) - } - } - + if (response is StoreReadResponse.Data) { + (response as? StoreReadResponse.Data>)?.let { dataWithCollection -> - it.value.items.forEach { single -> - // TODO: Start stream for each single - // TODO: When change in single, update paging state - val singleKey = keyFactory.createSingleFor(single.id) - (singleKey as? Key)?.let { k -> - - val nestedJob = launch { - streamer(k).collect { singleResponse -> - when (singleResponse) { - is StoreReadResponse.Data -> { - println("HITTING NESTED $singleResponse") - (singleResponse as? StoreReadResponse.Data)?.let { - dataMutex.withLock { - data[key]?.value?.items?.let { items -> - val index = - items.indexOfFirst { it.id == single.id } - val updatedItems = items.toMutableList() - - if (updatedItems[index] != it.value) { - println("HITTING FOR ${it.value}") - updatedItems[index] = it.value - val updatedCollection = - data[key]!!.value.copyWith(updatedItems) as? Collection - - updatedCollection?.let { collection -> - data[key] = data[key]!!.copy(collection) - - val joinedData = joiner(key, data) - (joinedData as? StoreReadResponse.Data)?.let { - mutableStateFlow.emit(it) - } - } - } - - } + mutexForAllPagingData.withLock { + allPagingData[key] = pagingDataFrom(dataWithCollection.value.items) + val joinedData = joiner(allPagingData) + mutableStateFlow.value = joinedData + } - } - } - } + dataWithCollection.value.items.forEach { single -> - else -> {} - } - } + val childKey = keyFactory.createFor(single.id) + (childKey as? K)?.let { + val childJob = launch { + initStreamAndHandleSingle(single, childKey, key) + } - } + childrenKeys.add(childKey) - streams[k] = nestedJob - nestedKeys.add(k) + // TODO: This might result in a deadlock + mutexForAllStreams.withLock { + allStreams[childKey] = childJob } - } } } - - else -> { - println("HITTING $response") - mutableStateFlow.emit(response) - } } } } - streams[key] = job + allStreams[key] = mainJob - job.invokeOnCompletion { - nestedKeys.forEach { - streams[it]?.cancel() - streams.remove(it) + mainJob.invokeOnCompletion { + childrenKeys.forEach { childKey -> + allStreams[childKey]?.cancel() + allStreams.remove(childKey) } - streams[key]?.cancel() - streams.remove(key) + allStreams[key]?.cancel() + allStreams.remove(key) } } } } } -} + private suspend fun initStreamAndHandleSingle(single: SO, childKey: K, parentKey: K) { + streamer(childKey).collect { response -> + if (response is StoreReadResponse.Data) { + (response as? StoreReadResponse.Data)?.let { dataWithSingle -> + mutexForAllPagingData.withLock { + allPagingData[parentKey]?.items?.let { items -> + val indexOfSingle = items.indexOfFirst { it.id == single.id } + val updatedItems = items.toMutableList() + if (updatedItems[indexOfSingle] != dataWithSingle.value) { + updatedItems[indexOfSingle] = dataWithSingle.value + + val updatedPagingData = allPagingData[parentKey]!!.copy(updatedItems) + allPagingData[parentKey] = updatedPagingData + + val joinedData = joiner(allPagingData) + mutableStateFlow.value = joinedData + } + } + } + + } + } + } + } + private fun emptyPagingData() = PagingData(emptyList()) + private fun pagingDataFrom(items: List) = PagingData(items) + +} 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 e613a68a5..5e8346904 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt @@ -2,9 +2,6 @@ package org.mobilenativefoundation.store.paging5 import app.cash.turbine.test import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -13,23 +10,18 @@ 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.PostDataJoiner import org.mobilenativefoundation.store.paging5.util.PostDatabase +import org.mobilenativefoundation.store.paging5.util.PostJoiner import org.mobilenativefoundation.store.paging5.util.PostKey import org.mobilenativefoundation.store.paging5.util.PostKeyFactory -import org.mobilenativefoundation.store.paging5.util.PostPutRequestResult import org.mobilenativefoundation.store.paging5.util.PostStoreFactory 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.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertIs -@OptIn(ExperimentalStoreApi::class) +@OptIn(ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) class LaunchPagingStoreTests { private val testScope = TestScope() @@ -37,8 +29,9 @@ class LaunchPagingStoreTests { private lateinit var api: PostApi private lateinit var db: PostDatabase private lateinit var store: MutableStore - private lateinit var joiner: PostDataJoiner - private val keyFactory = PostKeyFactory() + private lateinit var joiner: PostJoiner + private lateinit var keyFactory: PostKeyFactory + private lateinit var pager: Pager @BeforeTest fun setup() { @@ -46,71 +39,58 @@ class LaunchPagingStoreTests { db = FakePostDatabase(userId) val factory = PostStoreFactory(api, db) store = factory.create() - joiner = PostDataJoiner() - } - - @Test - fun transitionFromInitialToData() = testScope.runTest { - val key = PostKey.Cursor("1", 10) - val keys = flowOf(key) - val stateFlow = store.launchPagingStore(this, keys, joiner, keyFactory = keyFactory) - - stateFlow.test { - val state1 = awaitItem() - assertIs(state1) - val state2 = awaitItem() - assertIs(state2) - val state3 = awaitItem() - assertIs>(state3) - expectNoEvents() - } + joiner = PostJoiner() + keyFactory = PostKeyFactory() } @Test fun multipleValidKeysEmittedInSuccession() = testScope.runTest { + pager = Pager.create(this, store, joiner, keyFactory) + val key1 = PostKey.Cursor("1", 10) val key2 = PostKey.Cursor("11", 10) - val keys = MutableStateFlow(key1) - val stateFlow = store.launchPagingStore(this, keys, joiner, keyFactory = keyFactory) + + val stateFlow = pager.state stateFlow.test { + pager.load(key1) + val initialState = awaitItem() + assertEquals(0, initialState.items.size) + val state1 = awaitItem() - assertIs(state1) - val state2 = awaitItem() - assertIs(state2) - val state3 = awaitItem() - assertIs>(state3) - assertEquals("1", state3.value.posts[0].postId) + assertEquals(10, state1.items.size) + assertEquals("1", state1.items[0].postId) - keys.emit(key2) + pager.load(key2) - val loading2 = awaitItem() - assertIs(loading2) + val state2 = awaitItem() + assertEquals(20, state2.items.size) + assertEquals("1", state2.items[0].postId) + assertEquals("11", state2.items[10].postId) - val state4 = awaitItem() - assertIs>(state4) - assertEquals("1", state4.value.posts[0].postId) - assertEquals("11", state4.value.posts[10].postId) - val data4 = state4.value - assertIs(data4) - assertEquals(20, data4.items.size) expectNoEvents() } } @Test fun sameKeyEmittedMultipleTimes() = testScope.runTest { + pager = Pager.create(this, store, joiner, keyFactory) + val key = PostKey.Cursor("1", 10) - val keys = flowOf(key, key) - val stateFlow = store.launchPagingStore(this, keys, joiner, keyFactory = keyFactory) + + val stateFlow = pager.state stateFlow.test { + pager.load(key) + val initialState = awaitItem() + assertEquals(0, initialState.items.size) + val state1 = awaitItem() - assertIs(state1) - val state2 = awaitItem() - assertIs(state2) - val state3 = awaitItem() - assertIs>(state3) + assertEquals(10, state1.items.size) + assertEquals("1", state1.items[0].postId) + + pager.load(key) + expectNoEvents() } } @@ -120,118 +100,38 @@ class LaunchPagingStoreTests { val api = FakePostApi() val db = FakePostDatabase(userId) val factory = PostStoreFactory(api = api, db = db) - val store = factory.create() + val mutableStore = factory.create() + + pager = Pager.create(this, mutableStore, joiner, keyFactory) val key1 = PostKey.Cursor("1", 10) val key2 = PostKey.Cursor("11", 10) - val keys = MutableStateFlow(key1) - - val stateFlow = store.launchPagingStore(this, keys, joiner, keyFactory = keyFactory) + val stateFlow = pager.state stateFlow.test { + pager.load(key1) val initialState = awaitItem() - assertIs(initialState) - val loadingState = awaitItem() - assertIs(loadingState) - val loadedState1 = awaitItem() - assertIs>(loadedState1) - val data1 = loadedState1.value - assertEquals(10, data1.posts.size) - assertEquals("1", data1.posts[0].postId) - expectNoEvents() + assertEquals(0, initialState.items.size) - keys.emit(key2) - - val loadingState2 = awaitItem() - assertIs(loadingState2) - val loadedState2 = awaitItem() - assertIs>(loadedState2) - val data2 = loadedState2.value - println(data2) - assertEquals(20, data2.posts.size) - assertEquals("1", data2.posts[0].postId) - } - - 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) - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun multipleKeysWithReadsAndWritesUsingOneStream() = testScope.runTest { - val api = FakePostApi() - val db = FakePostDatabase(userId) - val factory = PostStoreFactory(api = api, db = db) - val mutableStore = factory.create() + val state1 = awaitItem() + assertEquals(10, state1.items.size) + assertEquals("1", state1.items[0].postId) - val key1 = PostKey.Cursor("1", 10) - val key2 = PostKey.Cursor("11", 10) - val keys = MutableStateFlow(key1) + pager.load(key2) - val stateFlow = mutableStore.launchPagingStore(this, keys, joiner, keyFactory = keyFactory) - 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) - assertEquals("1", data1.posts[0].postId) - - keys.emit(key2) - - val loadingState2 = awaitItem() - assertIs(loadingState2) - - val loadedState2 = awaitItem() - assertIs>(loadedState2) - val data2 = loadedState2.value - assertEquals(20, data2.posts.size) - assertEquals("1", data1.posts[0].postId) + val state2 = awaitItem() + assertEquals(20, state2.items.size) + assertEquals("1", state2.items[0].postId) + assertEquals("11", state2.items[10].postId) mutableStore.write(StoreWriteRequest.of(PostKey.Single("2"), PostData.Post("2", "2-modified"))) - println("WROTE TO STORE") advanceUntilIdle() - val loadedState3 = awaitItem() - assertIs>(loadedState3) - val data3 = loadedState3.value - assertEquals(20, data3.posts.size) - assertEquals("2-modified", data3.posts[1].title) // Actual is "2" + val state3 = awaitItem() + assertEquals(20, state3.items.size) + assertEquals("1", state3.items[0].postId) + assertEquals("2-modified", state3.items[1].title) + assertEquals("11", state3.items[10].postId) } } } diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDataJoiner.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDataJoiner.kt deleted file mode 100644 index 2586ef15b..000000000 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostDataJoiner.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.mobilenativefoundation.store.paging5.util - -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.paging5.DataJoiner -import org.mobilenativefoundation.store.store5.StoreReadResponse - -@OptIn(ExperimentalStoreApi::class) -class PostDataJoiner : DataJoiner { - override suspend fun invoke( - key: PostKey, - data: Map?> - ): StoreReadResponse.Data { - var combinedItems = mutableListOf() - - data.values.forEach { responseData -> - println("RESPONSE DATA = $responseData") - responseData?.value?.items?.let { items -> - combinedItems = (combinedItems + items).distinctBy { it.postId }.toMutableList() - } - } - - return (key as? PostKey.Cursor)?.let { - val feed = PostData.Feed(combinedItems) - data.values.last { it != null }?.let { - StoreReadResponse.Data(feed, it.origin) - } - } ?: throw IllegalArgumentException("Key must be a Collection type") - } - -} \ No newline at end of file diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostJoiner.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostJoiner.kt new file mode 100644 index 000000000..e032be3cb --- /dev/null +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostJoiner.kt @@ -0,0 +1,20 @@ +package org.mobilenativefoundation.store.paging5.util + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.paging5.Joiner +import org.mobilenativefoundation.store.paging5.PagingData + +@OptIn(ExperimentalStoreApi::class) +class PostJoiner : Joiner { + override suspend fun invoke(data: Map>): PagingData { + var combinedItems = mutableListOf() + + data.values.forEach { responseData -> + responseData.items.let { items -> + combinedItems = (combinedItems + items).distinctBy { it.postId }.toMutableList() + } + } + + return PagingData(combinedItems) + } +} \ 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 cff1cd117..04bf1285a 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 @@ -2,9 +2,8 @@ package org.mobilenativefoundation.store.paging5.util import org.mobilenativefoundation.store.core5.ExperimentalStoreApi import org.mobilenativefoundation.store.core5.InsertionStrategy -import org.mobilenativefoundation.store.core5.KeyFactory -import org.mobilenativefoundation.store.core5.StoreData import org.mobilenativefoundation.store.core5.StoreKey +import org.mobilenativefoundation.store.paging5.KeyFactory @OptIn(ExperimentalStoreApi::class) sealed class PostKey : StoreKey { @@ -15,14 +14,16 @@ sealed class PostKey : StoreKey { override val filters: List>? = null, override val insertionStrategy: InsertionStrategy = InsertionStrategy.APPEND ) : StoreKey.Collection.Cursor, PostKey() + data class Single( override val id: String ) : StoreKey.Single, PostKey() } +@OptIn(ExperimentalStoreApi::class) class PostKeyFactory : KeyFactory { - override fun createSingleFor(id: String): PostKey.Single { + override fun createFor(id: String): PostKey.Single { return PostKey.Single(id) } } \ No newline at end of file From 6695a275ea6b922930f48f8530a3d350e7ef7cc7 Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Sat, 17 Feb 2024 14:48:02 -0500 Subject: [PATCH 04/10] Extract to files Signed-off-by: mramotar_dbx --- .../store/paging5/Joiner.kt | 10 +++ .../store/paging5/KeyFactory.kt | 9 +++ .../store/paging5/Pager.kt | 62 +++++++++++++++ .../store/paging5/PagingData.kt | 9 +++ .../{LaunchPagingStore.kt => RealPager.kt} | 76 +------------------ .../store/paging5/Streamer.kt | 12 +++ ...chPagingStoreTests.kt => RealPagerTest.kt} | 2 +- 7 files changed, 104 insertions(+), 76 deletions(-) create mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Joiner.kt create mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyFactory.kt create mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Pager.kt create mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingData.kt rename paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/{LaunchPagingStore.kt => RealPager.kt} (67%) create mode 100644 paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Streamer.kt rename paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/{LaunchPagingStoreTests.kt => RealPagerTest.kt} (99%) diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Joiner.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Joiner.kt new file mode 100644 index 000000000..7a5da0967 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Joiner.kt @@ -0,0 +1,10 @@ +package org.mobilenativefoundation.store.paging5 + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey + +@ExperimentalStoreApi +interface Joiner, SO : StoreData.Single> { + suspend operator fun invoke(data: Map>): PagingData +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyFactory.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyFactory.kt new file mode 100644 index 000000000..3d442c8df --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyFactory.kt @@ -0,0 +1,9 @@ +package org.mobilenativefoundation.store.paging5 + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreKey + +@ExperimentalStoreApi +interface KeyFactory> { + fun createFor(id: Id): SK +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Pager.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Pager.kt new file mode 100644 index 000000000..2ebdb71fb --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Pager.kt @@ -0,0 +1,62 @@ +package org.mobilenativefoundation.store.paging5 + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey +import org.mobilenativefoundation.store.store5.MutableStore +import org.mobilenativefoundation.store.store5.Store +import org.mobilenativefoundation.store.store5.StoreReadRequest +import org.mobilenativefoundation.store.store5.StoreReadResponse + +@ExperimentalStoreApi +interface Pager, SO : StoreData.Single> { + val state: StateFlow> + fun load(key: K) + + companion object { + fun , K : StoreKey, SO : StoreData.Single, O : StoreData> create( + scope: CoroutineScope, + store: Store, + joiner: Joiner, + keyFactory: KeyFactory + ): Pager { + + val streamer = object : Streamer { + override fun invoke(key: K): Flow> { + return store.stream(StoreReadRequest.fresh(key)) + } + } + + return RealPager( + scope, + streamer, + joiner, + keyFactory + ) + } + + fun , K : StoreKey, SO : StoreData.Single, O : StoreData> create( + scope: CoroutineScope, + store: MutableStore, + joiner: Joiner, + keyFactory: KeyFactory + ): Pager { + + val streamer = object : Streamer { + override fun invoke(key: K): Flow> { + return store.stream(StoreReadRequest.fresh(key)) + } + } + + return RealPager( + scope, + streamer, + joiner, + keyFactory + ) + } + } +} \ No newline at end of file diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingData.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingData.kt new file mode 100644 index 000000000..5e799b970 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingData.kt @@ -0,0 +1,9 @@ +package org.mobilenativefoundation.store.paging5 + +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData + +@ExperimentalStoreApi +data class PagingData>( + val items: 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/RealPager.kt similarity index 67% rename from paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt rename to paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/RealPager.kt index 422c69cf2..c445685c0 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStore.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/RealPager.kt @@ -4,7 +4,6 @@ package org.mobilenativefoundation.store.paging5 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -15,84 +14,11 @@ import kotlinx.coroutines.sync.withLock import org.mobilenativefoundation.store.core5.ExperimentalStoreApi import org.mobilenativefoundation.store.core5.StoreData import org.mobilenativefoundation.store.core5.StoreKey -import org.mobilenativefoundation.store.store5.MutableStore -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.StoreReadRequest import org.mobilenativefoundation.store.store5.StoreReadResponse @ExperimentalStoreApi -data class PagingData>( - val items: List -) - -@ExperimentalStoreApi -interface Pager, SO : StoreData.Single> { - val state: StateFlow> - fun load(key: K) - - companion object { - fun , K : StoreKey, SO : StoreData.Single, O : StoreData> create( - scope: CoroutineScope, - store: Store, - joiner: Joiner, - keyFactory: KeyFactory - ): Pager { - - val streamer = object : Streamer { - override fun invoke(key: K): Flow> { - return store.stream(StoreReadRequest.fresh(key)) - } - } - - return RealPager( - scope, - streamer, - joiner, - keyFactory - ) - } - - fun , K : StoreKey, SO : StoreData.Single, O : StoreData> create( - scope: CoroutineScope, - store: MutableStore, - joiner: Joiner, - keyFactory: KeyFactory - ): Pager { - - val streamer = object : Streamer { - override fun invoke(key: K): Flow> { - return store.stream(StoreReadRequest.fresh(key)) - } - } - - return RealPager( - scope, - streamer, - joiner, - keyFactory - ) - } - } -} - -@ExperimentalStoreApi -interface Joiner, SO : StoreData.Single> { - suspend operator fun invoke(data: Map>): PagingData -} - -@ExperimentalStoreApi -interface Streamer, O : StoreData> { - operator fun invoke(key: K): Flow> -} - -@ExperimentalStoreApi -interface KeyFactory> { - fun createFor(id: Id): SK -} - -@ExperimentalStoreApi -class RealPager, K : StoreKey, SO : StoreData.Single, O : StoreData>( +internal class RealPager, K : StoreKey, SO : StoreData.Single, O : StoreData>( private val scope: CoroutineScope, private val streamer: Streamer, private val joiner: Joiner, diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Streamer.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Streamer.kt new file mode 100644 index 000000000..ccbf5e3e5 --- /dev/null +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Streamer.kt @@ -0,0 +1,12 @@ +package org.mobilenativefoundation.store.paging5 + +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.core5.StoreData +import org.mobilenativefoundation.store.core5.StoreKey +import org.mobilenativefoundation.store.store5.StoreReadResponse + +@ExperimentalStoreApi +internal interface Streamer, O : StoreData> { + operator fun invoke(key: K): Flow> +} \ 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/RealPagerTest.kt similarity index 99% rename from paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt rename to paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/RealPagerTest.kt index 5e8346904..dd7cb3f74 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/LaunchPagingStoreTests.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/RealPagerTest.kt @@ -22,7 +22,7 @@ import kotlin.test.Test import kotlin.test.assertEquals @OptIn(ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) -class LaunchPagingStoreTests { +class RealPagerTest { private val testScope = TestScope() private val userId = "123" From 93790a955b76e1262e017e3f08436c2b8f4f3488 Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Sat, 17 Feb 2024 14:52:35 -0500 Subject: [PATCH 05/10] Remove TODO Signed-off-by: mramotar_dbx --- .../kotlin/org/mobilenativefoundation/store/paging5/RealPager.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/RealPager.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/RealPager.kt index c445685c0..f8acbb795 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/RealPager.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/RealPager.kt @@ -69,7 +69,6 @@ internal class RealPager, K : StoreKey, S childrenKeys.add(childKey) - // TODO: This might result in a deadlock mutexForAllStreams.withLock { allStreams[childKey] = childJob } From 60e1948f46ed038f7fb5a8ff7c081731b2710f12 Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Sat, 17 Feb 2024 14:54:57 -0500 Subject: [PATCH 06/10] Remove logs Signed-off-by: mramotar_dbx --- .../mobilenativefoundation/store/paging5/util/FakePostApi.kt | 4 +--- 1 file changed, 1 insertion(+), 3 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 59c7bb08f..d56aedb76 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 @@ -23,16 +23,14 @@ class FakePostApi : PostApi { } override suspend fun get(cursor: String?, size: Int): FeedGetRequestResult { - println("HITTING IN GET $cursor") val firstIndexInclusive = postsList.indexOfFirst { it.postId == cursor } val lastIndexExclusive = firstIndexInclusive + size val posts = postsList.subList(firstIndexInclusive, lastIndexExclusive) - println("ABOUT TO RETURN FOR $cursor") return FeedGetRequestResult.Data(PostData.Feed(posts = posts)) } override suspend fun put(post: PostData.Post): PostPutRequestResult { - posts.put(post.id, post) + posts[post.id] = post return PostPutRequestResult.Data(post) } } From 38ea142a44500a4f68c3d5459db38b34849cf2ec Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Sat, 17 Feb 2024 15:16:52 -0500 Subject: [PATCH 07/10] Remove design doc Signed-off-by: mramotar_dbx --- paging/docs/design_doc.md | 246 -------------------------------------- 1 file changed, 246 deletions(-) delete mode 100644 paging/docs/design_doc.md diff --git a/paging/docs/design_doc.md b/paging/docs/design_doc.md deleted file mode 100644 index 493a95ffb..000000000 --- a/paging/docs/design_doc.md +++ /dev/null @@ -1,246 +0,0 @@ -# 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 15b5c6c875d4bcfcd324ec038d13a51f986f04fb Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Sat, 17 Feb 2024 15:17:02 -0500 Subject: [PATCH 08/10] Document RealPager Signed-off-by: mramotar_dbx --- .../store/cache5/StoreMultiCache.kt | 2 + .../store/paging5/RealPager.kt | 38 ++++++++++++++++++- .../store/paging5/util/PostStoreFactory.kt | 27 +------------ 3 files changed, 39 insertions(+), 28 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 7003bc582..8e4007d6a 100644 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt +++ b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt @@ -2,6 +2,7 @@ package org.mobilenativefoundation.store.cache5 +import org.mobilenativefoundation.store.core5.ExperimentalStoreApi import org.mobilenativefoundation.store.core5.KeyProvider import org.mobilenativefoundation.store.core5.StoreData import org.mobilenativefoundation.store.core5.StoreKey @@ -12,6 +13,7 @@ import org.mobilenativefoundation.store.core5.StoreKey * Depends on [StoreMultiCacheAccessor] for internal data management. * @see [Cache]. */ +@OptIn(ExperimentalStoreApi::class) class StoreMultiCache, Single : StoreData.Single, Collection : StoreData.Collection, Output : StoreData>( private val keyProvider: KeyProvider, singlesCache: Cache, Single> = CacheBuilder, Single>().build(), diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/RealPager.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/RealPager.kt index f8acbb795..bfe2ed15a 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/RealPager.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/RealPager.kt @@ -16,7 +16,21 @@ import org.mobilenativefoundation.store.core5.StoreData import org.mobilenativefoundation.store.core5.StoreKey import org.mobilenativefoundation.store.store5.StoreReadResponse - +/** + * An internal class that implements the [Pager] interface. + * It manages the paging of data items based on the given [StoreKey]. + * It also synchronizes updates to single items and collections. + * + * @param Id The type of the identifier that uniquely identifies data items. + * @param SK The subtype of [StoreKey.Single] that represents keys for single items. + * @param K The type of [StoreKey] used by the Store this pager delegates to. + * @param SO The subtype of [StoreData.Single] representing the data model for single items. + * @param O The type of [StoreData] representing the output of the Store this pager delegates to. + * @param scope The [CoroutineScope] within which the pager operates. Used to launch coroutines for data streaming and joining. + * @param streamer A [Streamer] function type that provides a flow of [StoreReadResponse] for a given key. + * @param joiner A [Joiner] function type that combines multiple paging data into a single [StateFlow]. + * @param keyFactory A [KeyFactory] to create new [StoreKey] instances for single items based on their identifiers. + */ @ExperimentalStoreApi internal class RealPager, K : StoreKey, SO : StoreData.Single, O : StoreData>( private val scope: CoroutineScope, @@ -25,50 +39,64 @@ internal class RealPager, K : StoreKey, S private val keyFactory: KeyFactory ) : Pager { + // StateFlow to emit updates of PagingData. private val mutableStateFlow = MutableStateFlow(emptyPagingData()) override val state: StateFlow> = mutableStateFlow.asStateFlow() + // Maps to keep track of all PagingData and corresponding streams. private val allPagingData: MutableMap> = mutableMapOf() private val allStreams: MutableMap = mutableMapOf() + // Mutexes for thread-safe access to maps. private val mutexForAllPagingData = Mutex() private val mutexForAllStreams = Mutex() + override fun load(key: K) { if (key !is StoreKey.Collection<*>) { throw IllegalArgumentException("Invalid key type.") } + // Creating a child scope for coroutines. val childScope = scope + Job() + // Launching a coroutine within the child scope for data loading. childScope.launch { + // Locking the streams map to check and manage stream jobs. mutexForAllStreams.withLock { + // Checking if there's no active stream for the key. if (allStreams[key]?.isActive != true) { + // Initializing the PagingData for the key. allPagingData[key] = emptyPagingData() val childrenKeys = mutableListOf() + // Main job to stream data for the key. val mainJob = launch { streamer(key).collect { response -> if (response is StoreReadResponse.Data) { + // Handling collection data response. (response as? StoreReadResponse.Data>)?.let { dataWithCollection -> + // Updating paging data and state flow inside a locked block for thread safety. mutexForAllPagingData.withLock { allPagingData[key] = pagingDataFrom(dataWithCollection.value.items) val joinedData = joiner(allPagingData) mutableStateFlow.value = joinedData } + // For each item in the collection, initiate streaming and handling of single data. dataWithCollection.value.items.forEach { single -> val childKey = keyFactory.createFor(single.id) (childKey as? K)?.let { + // Launching a coroutine for each single item. val childJob = launch { initStreamAndHandleSingle(single, childKey, key) } + // Keeping track of child keys and jobs. childrenKeys.add(childKey) - mutexForAllStreams.withLock { allStreams[childKey] = childJob } @@ -79,9 +107,11 @@ internal class RealPager, K : StoreKey, S } } + // Storing the main job and handling its completion. allStreams[key] = mainJob mainJob.invokeOnCompletion { + // On completion, cancel and remove all child streams and the main stream. childrenKeys.forEach { childKey -> allStreams[childKey]?.cancel() allStreams.remove(childKey) @@ -95,20 +125,24 @@ internal class RealPager, K : StoreKey, S } } + // Handles streaming and updating of single item data within a collection. private suspend fun initStreamAndHandleSingle(single: SO, childKey: K, parentKey: K) { streamer(childKey).collect { response -> if (response is StoreReadResponse.Data) { (response as? StoreReadResponse.Data)?.let { dataWithSingle -> mutexForAllPagingData.withLock { allPagingData[parentKey]?.items?.let { items -> + // Finding and updating the single item within the parent collection. val indexOfSingle = items.indexOfFirst { it.id == single.id } val updatedItems = items.toMutableList() if (updatedItems[indexOfSingle] != dataWithSingle.value) { updatedItems[indexOfSingle] = dataWithSingle.value + // Creating and updating the paging data with the updated item list. val updatedPagingData = allPagingData[parentKey]!!.copy(updatedItems) allPagingData[parentKey] = updatedPagingData + // Updating the state flow with the joined data. val joinedData = joiner(allPagingData) mutableStateFlow.value = joinedData } 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 8ed9b2011..603272e2e 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 @@ -3,19 +3,14 @@ package org.mobilenativefoundation.store.paging5.util import kotlinx.coroutines.flow.flow -import org.mobilenativefoundation.store.cache5.Cache -import org.mobilenativefoundation.store.cache5.StoreMultiCache -import org.mobilenativefoundation.store.core5.KeyProvider -import org.mobilenativefoundation.store.core5.StoreKey -import org.mobilenativefoundation.store.store5.Converter import org.mobilenativefoundation.store.core5.ExperimentalStoreApi +import org.mobilenativefoundation.store.store5.Converter 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) { @@ -106,29 +101,9 @@ class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { } ) - private fun createPagingCacheKeyProvider(): KeyProvider = - object : KeyProvider { - override fun fromCollection( - key: StoreKey.Collection, - value: PostData.Post - ): StoreKey.Single { - return PostKey.Single(value.postId) - } - - 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) - } - } - - private fun createMemoryCache(): Cache = - StoreMultiCache(createPagingCacheKeyProvider()) - fun create(): MutableStore = StoreBuilder.from( fetcher = createFetcher(), sourceOfTruth = createSourceOfTruth(), - memoryCache = createMemoryCache() ).toMutableStoreBuilder( converter = createConverter() ).build( From 8ee88bb7a1e8e26a86e726d3f816e08f33471a28 Mon Sep 17 00:00:00 2001 From: mramotar_dbx Date: Sat, 17 Feb 2024 15:21:49 -0500 Subject: [PATCH 09/10] Format Signed-off-by: mramotar_dbx --- .../org/mobilenativefoundation/store/paging5/Joiner.kt | 2 +- .../org/mobilenativefoundation/store/paging5/KeyFactory.kt | 2 +- .../kotlin/org/mobilenativefoundation/store/paging5/Pager.kt | 2 +- .../org/mobilenativefoundation/store/paging5/PagingData.kt | 2 +- .../org/mobilenativefoundation/store/paging5/RealPager.kt | 5 ----- .../org/mobilenativefoundation/store/paging5/Streamer.kt | 2 +- .../mobilenativefoundation/store/paging5/util/PostJoiner.kt | 2 +- .../org/mobilenativefoundation/store/paging5/util/PostKey.kt | 3 +-- 8 files changed, 7 insertions(+), 13 deletions(-) diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Joiner.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Joiner.kt index 7a5da0967..855d6737b 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Joiner.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Joiner.kt @@ -7,4 +7,4 @@ import org.mobilenativefoundation.store.core5.StoreKey @ExperimentalStoreApi interface Joiner, SO : StoreData.Single> { suspend operator fun invoke(data: Map>): PagingData -} \ No newline at end of file +} diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyFactory.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyFactory.kt index 3d442c8df..1a9c8ee91 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyFactory.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/KeyFactory.kt @@ -6,4 +6,4 @@ import org.mobilenativefoundation.store.core5.StoreKey @ExperimentalStoreApi interface KeyFactory> { fun createFor(id: Id): SK -} \ No newline at end of file +} diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Pager.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Pager.kt index 2ebdb71fb..0f855bc17 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Pager.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Pager.kt @@ -59,4 +59,4 @@ interface Pager, SO : StoreData.Single> { ) } } -} \ No newline at end of file +} diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingData.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingData.kt index 5e799b970..89965a3d9 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingData.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/PagingData.kt @@ -6,4 +6,4 @@ import org.mobilenativefoundation.store.core5.StoreData @ExperimentalStoreApi data class PagingData>( val items: List -) \ No newline at end of file +) diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/RealPager.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/RealPager.kt index bfe2ed15a..fb6721b52 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/RealPager.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/RealPager.kt @@ -148,7 +148,6 @@ internal class RealPager, K : StoreKey, S } } } - } } } @@ -156,8 +155,4 @@ internal class RealPager, K : StoreKey, S private fun emptyPagingData() = PagingData(emptyList()) private fun pagingDataFrom(items: List) = PagingData(items) - } - - - diff --git a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Streamer.kt b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Streamer.kt index ccbf5e3e5..8a59885aa 100644 --- a/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Streamer.kt +++ b/paging/src/commonMain/kotlin/org/mobilenativefoundation/store/paging5/Streamer.kt @@ -9,4 +9,4 @@ import org.mobilenativefoundation.store.store5.StoreReadResponse @ExperimentalStoreApi internal interface Streamer, O : StoreData> { operator fun invoke(key: K): Flow> -} \ No newline at end of file +} diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostJoiner.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostJoiner.kt index e032be3cb..50962b808 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostJoiner.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/util/PostJoiner.kt @@ -17,4 +17,4 @@ class PostJoiner : Joiner { return PagingData(combinedItems) } -} \ 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 04bf1285a..051767168 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 @@ -20,10 +20,9 @@ sealed class PostKey : StoreKey { ) : StoreKey.Single, PostKey() } - @OptIn(ExperimentalStoreApi::class) class PostKeyFactory : KeyFactory { override fun createFor(id: String): PostKey.Single { return PostKey.Single(id) } -} \ No newline at end of file +} From e787b4c9d4749209882968c9ddf5878a985a38b7 Mon Sep 17 00:00:00 2001 From: Matt Ramotar Date: Mon, 19 Feb 2024 17:09:21 -0500 Subject: [PATCH 10/10] Example with custom key Signed-off-by: mramotar_dbx --- .../store/paging5/RealPagerTest.kt | 40 +++++++++++++++++++ .../store/paging5/util/PostKey.kt | 7 ++++ .../store/paging5/util/PostStoreFactory.kt | 25 ++++++++++++ 3 files changed, 72 insertions(+) diff --git a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/RealPagerTest.kt b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/RealPagerTest.kt index dd7cb3f74..3581456e9 100644 --- a/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/RealPagerTest.kt +++ b/paging/src/commonTest/kotlin/org/mobilenativefoundation/store/paging5/RealPagerTest.kt @@ -134,4 +134,44 @@ class RealPagerTest { assertEquals("11", state3.items[10].postId) } } + + @Test + fun multipleCustomKeysWithReadsAndWrites() = testScope.runTest { + val api = FakePostApi() + val db = FakePostDatabase(userId) + val factory = PostStoreFactory(api = api, db = db) + val mutableStore = factory.create() + + pager = Pager.create(this, mutableStore, joiner, keyFactory) + + val key1 = PostKey.Custom("1", 10) + val key2 = PostKey.Custom("11", 10) + + val stateFlow = pager.state + stateFlow.test { + pager.load(key1) + val initialState = awaitItem() + assertEquals(0, initialState.items.size) + + val state1 = awaitItem() + assertEquals(10, state1.items.size) + assertEquals("1", state1.items[0].postId) + + pager.load(key2) + + val state2 = awaitItem() + assertEquals(20, state2.items.size) + assertEquals("1", state2.items[0].postId) + assertEquals("11", state2.items[10].postId) + + mutableStore.write(StoreWriteRequest.of(PostKey.Single("2"), PostData.Post("2", "2-modified"))) + advanceUntilIdle() + + val state3 = awaitItem() + assertEquals(20, state3.items.size) + assertEquals("1", state3.items[0].postId) + assertEquals("2-modified", state3.items[1].title) + assertEquals("11", state3.items[10].postId) + } + } } 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 051767168..8c99a9115 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,6 +15,13 @@ sealed class PostKey : StoreKey { override val insertionStrategy: InsertionStrategy = InsertionStrategy.APPEND ) : StoreKey.Collection.Cursor, PostKey() + data class Custom( + val page: String, + val size: Int, + val sort: StoreKey.Sort? = null, + override val insertionStrategy: InsertionStrategy = InsertionStrategy.APPEND + ): StoreKey.Collection, PostKey() + data class Single( override val id: String ) : StoreKey.Single, PostKey() 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 603272e2e..4ea94efd8 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 @@ -47,6 +47,22 @@ class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { } } } + + is PostKey.Custom -> { + when (val result = api.get(key.page, key.size)) { + is FeedGetRequestResult.Data -> { + result.data + } + + is FeedGetRequestResult.Error.Exception -> { + throw Throwable(result.error) + } + + is FeedGetRequestResult.Error.Message -> { + throw Throwable(result.error) + } + } + } } } @@ -63,6 +79,11 @@ class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { val feed = db.findFeedByUserId(key.cursor, key.size) emit(feed) } + + is PostKey.Custom -> { + val feed = db.findFeedByUserId(key.page, key.size) + emit(feed) + } } } }, @@ -75,6 +96,10 @@ class PostStoreFactory(private val api: PostApi, private val db: PostDatabase) { key is PostKey.Cursor && data is PostData.Feed -> { db.add(data) } + + key is PostKey.Custom && data is PostData.Feed -> { + db.add(data) + } } } )