diff --git a/README.md b/README.md index ecb5071ec..9be5f657c 100644 --- a/README.md +++ b/README.md @@ -55,14 +55,15 @@ Let's start by looking at what a fully configured Store looks like. We will then ```kotlin StoreBuilder - .fromNonFlow { - api.fetchSubreddit(it, "10").data.children.map(::toPosts) - }.persister( - reader = db.postDao()::loadPosts, - writer = db.postDao()::insertPosts, - delete = db.postDao()::clearFeed, - deleteAll = db.postDao()::clearAllFeeds - ).build() + .from( + fetcher = nonFlowValueFetcher { api.fetchSubreddit(it, "10").data.children.map(::toPosts) }, + sourceOfTruth = SourceOfTrue.from( + reader = db.postDao()::loadPosts, + writer = db.postDao()::insertPosts, + delete = db.postDao()::clearFeed, + deleteAll = db.postDao()::clearAllFeeds + ) + ).build() ``` With the above setup you have: @@ -75,12 +76,12 @@ And now for the details: ### Creating a Store -You create a Store using a builder. The only requirement is to include a function that returns a `Flow` or a `suspend` function that returns a `ReturnType`. +You create a Store using a builder. The only requirement is to include a `Fetcher` which is just a `typealias` to a function that returns a `Flow>`. ```kotlin val store = StoreBuilder - .from { articleId -> api.getArticle(articleId) } // api returns Flow
+ .from(valueFetcher { articleId -> api.getArticle(articleId) }) // api returns Flow
.build() ``` @@ -126,8 +127,8 @@ lifecycleScope.launchWhenStarted { For convenience, there are `Store.get(key)` and `Store.fresh(key)` extension functions. -* `suspend fun Store.get(key: Key): Value`: This method returns a single value for the given key. If available, it will be returned from the in memory cache or the persister. An error will be thrown if no value is available in either the `cache` or `persister`, and the `fetcher` fails to load the data from the network. -* `suspend fun Store.fresh(key: Key): Value`: This method returns a single value for the given key that is obtained by querying the fetcher. An error will be thrown if the `fetcher` fails to load the data from the network, regardless of whether any value is available in the `cache` or `persister`. +* `suspend fun Store.get(key: Key): Value`: This method returns a single value for the given key. If available, it will be returned from the in memory cache or the sourceOfTruth. An error will be thrown if no value is available in either the `cache` or `sourceOfTruth`, and the `fetcher` fails to load the data from the network. +* `suspend fun Store.fresh(key: Key): Value`: This method returns a single value for the given key that is obtained by querying the fetcher. An error will be thrown if the `fetcher` fails to load the data from the network, regardless of whether any value is available in the `cache` or `sourceOfTruth`. ```kotlin lifecycleScope.launchWhenStarted { @@ -136,7 +137,7 @@ lifecycleScope.launchWhenStarted { } ``` -The first time you call to `suspend store.get(key)`, the response will be stored in an in-memory cache and in the persister, if provided. +The first time you call to `suspend store.get(key)`, the response will be stored in an in-memory cache and in the sourceOfTruth, if provided. All subsequent calls to `store.get(key)` with the same `Key` will retrieve the cached version of the data, minimizing unnecessary data calls. This prevents your app from fetching fresh data over the network (or from another external data source) in situations when doing so would unnecessarily waste bandwidth and battery. A great use case is any time your views are recreated after a rotation, they will be able to request the cached data from your Store. Having this data available can help you avoid the need to retain this in the view layer. By default, 100 items will be cached in memory for 24 hours. You may [pass in your own memory policy to override the default policy](#Configuring-In-memory-Cache). @@ -171,10 +172,10 @@ To prevent duplicate requests for the same data, Store offers an inflight deboun ### Disk as Cache -Stores can enable disk caching by passing a `Persister` into the builder. Whenever a new network request is made, the Store will first write to the disk cache and then read from the disk cache. +Stores can enable disk caching by passing a `SourceOfTruth` into the builder. Whenever a new network request is made, the Store will first write to the disk cache and then read from the disk cache. ### Disk as Single Source of Truth -Providing `persister` whose `read` function can return a `Flow` allows you to make Store treat your disk as source of truth. +Providing `sourceOfTruth` whose `reader` function can return a `Flow` allows you to make Store treat your disk as source of truth. Any changes made on disk, even if it is not made by Store, will update the active `Store` streams. This feature, combined with persistence libraries that provide observable queries ([Jetpack Room](https://developer.android.com/jetpack/androidx/releases/room), [SQLDelight](https://github.com/cashapp/sqldelight) or [Realm](https://realm.io/products/realm-database/)) @@ -184,16 +185,18 @@ allows you to create offline first applications that can be used without an acti ```kotlin StoreBuilder - .fromNonFlow { - api.fetchSubreddit(it, "10").data.children.map(::toPosts) - }.persister( - reader = db.postDao()::loadPosts, - writer = db.postDao()::insertPosts, - delete = db.postDao()::clearFeed - ).build() + .from( + fetcher = nonFlowValueFetcher { api.fetchSubreddit(it, "10").data.children.map(::toPosts) }, + sourceOfTruth = SourceOfTrue.from( + reader = db.postDao()::loadPosts, + writer = db.postDao()::insertPosts, + delete = db.postDao()::clearFeed, + deleteAll = db.postDao()::clearAllFeeds + ) + ).build() ``` -Stores don’t care how you’re storing or retrieving your data from disk. As a result, you can use Stores with object storage or any database (Realm, SQLite, CouchDB, Firebase etc). Technically, there is nothing stopping you from implementing an in-memory cache for the “persister” implementation and instead have two levels of in-memory caching--one with inflated and one with deflated models, allowing for sharing of the “persister” cache data between stores. +Stores don’t care how you’re storing or retrieving your data from disk. As a result, you can use Stores with object storage or any database (Realm, SQLite, CouchDB, Firebase etc). Technically, there is nothing stopping you from implementing an in-memory cache for the "sourceOfTruth" implementation and instead have two levels of in-memory caching--one with inflated and one with deflated models, allowing for sharing of the “sourceOfTruth” cache data between stores. If using SQLite we recommend working with [Room](https://developer.android.com/topic/libraries/architecture/room) which returns a `Flow` from a query @@ -211,18 +214,19 @@ You can configure in-memory cache with the `MemoryPolicy`: ```kotlin StoreBuilder - .fromNonFlow { - api.fetchSubreddit(it, "10").data.children.map(::toPosts) - }.cachePolicy( + .from( + fetcher = nonFlowValueFetcher { api.fetchSubreddit(it, "10").data.children.map(::toPosts) }, + sourceOfTruth = SourceOfTrue.from( + reader = db.postDao()::loadPosts, + writer = db.postDao()::insertPosts, + delete = db.postDao()::clearFeed, + deleteAll = db.postDao()::clearAllFeeds + ) + ).cachePolicy( MemoryPolicy.builder() .setMemorySize(10) .setExpireAfterAccess(10.minutes) // or setExpireAfterWrite(10.minutes) .build() - ).persister( - reader = db.postDao()::loadPosts, - writer = db.postDao()::insertPosts, - delete = db.postDao()::clearFeed, - deleteAll = db.postDao()::clearAllFeeds ).build() ``` @@ -236,7 +240,7 @@ Note that `setExpireAfterAccess` and `setExpireAfterWrite` **cannot** both be se You can delete a specific entry by key from a store, or clear all entries in a store. -#### Store with no persister +#### Store with no sourceOfTruth ```kotlin val store = StoreBuilder @@ -257,29 +261,30 @@ The following will clear all entries from the in-memory cache: store.clearAll() ``` -#### Store with persister +#### Store with sourceOfTruth -When store has a persister (source of truth), you'll need to provide the `delete` and `deleteAll` functions for `clear(key)` and `clearAll()` to work: +When store has a sourceOfTruth, you'll need to provide the `delete` and `deleteAll` functions for `clear(key)` and `clearAll()` to work: ```kotlin StoreBuilder - .fromNonFlow { key: String -> - api.fetchData(key) - }.persister( - reader = dao::loadData, - writer = dao::writeData, - delete = dao::clearDataByKey, - deleteAll = dao::clearAllData - ).build() + .from( + fetcher = nonFlowValueFetcher { api.fetchData(key) }, + sourceOfTruth = SourceOfTrue.from( + reader = dao::loadData, + writer = dao::writeData, + delete = dao::clearDataByKey, + deleteAll = dao::clearAllData + ) + ).build() ``` -The following will clear the entry associated with the key from both the in-memory cache and the persister (source of truth): +The following will clear the entry associated with the key from both the in-memory cache and the sourceOfTruth: ```kotlin store.clear("10") ``` -The following will clear all entries from both the in-memory cache and the persister (source of truth): +The following will clear all entries from both the in-memory cache and the sourceOfTruth: ```kotlin store.clearAll() diff --git a/RELEASING.md b/RELEASING.md index 970e3c3ce..e25432b06 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -6,12 +6,17 @@ Releasing 3. Update the `README.md` with the new version. 4. `git commit -am "Prepare for release X.Y.Z."` (where X.Y.Z is the new version) 5. `git tag -a X.Y.X -m "Version X.Y.Z"` (where X.Y.Z is the new version) - 8. Update the top level `build.gradle` to the next SNAPSHOT version. - 9. `git commit -am "Prepare next development version."` - 10. `git push && git push --tags` - 11. Create a PR with these 2 commits. + 6. Update the top level `build.gradle` to the next SNAPSHOT version. + 7. `git commit -am "Prepare next development version."` + 8. `git push && git push --tags` + 9. Create a PR with these 2 commits. * **IMPORTANT** Add this comment to your PR "This is a release PR, it must be merged as individual commits. Do not squash commits on merge" * Longer explanation: we release automatically through Travis CI. When Travis builds on master a script is run to send either a new shapshot or a new release version to Maven. If you squash the commits in the PR, Travis will only see what's left at the end, which is your commit to change back to `SNAPSHOT` release. Thus, Travis will not end up sending a release version to Maven. If you land as multiple commits, Travis will build both and send a release build to Maven for the commit where you bumped the version to a new release version. - 11. Update the sample module's `build.gradle` to point to the newly released version. (It may take ~2 hours for artifact to be available after release) -If step 5 or 6 fails, drop the Sonatype repo, fix the problem, commit, and start again at step 5. + +**Note:** We are currently not pinning the sample app to the last version because the API is still fluid while `Store` is in alpha. We will resume pinning the sample app to a released version when we move to beta (see #159). + +When we're ready to pin, restore the final step: + +10. Update the sample module's `build.gradle` to point to the newly released version. (It may take ~2 hours for artifact to be available after release) + diff --git a/app/build.gradle b/app/build.gradle index 64c5d00e9..41440c186 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -42,15 +42,7 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) { } } -ext.storeRelease = [ - version : '4.0.0-alpha05', - group : 'com.dropbox.mobile.store' -] - dependencies { - implementation "${storeRelease.group}:store4:${storeRelease.version}" - implementation "${storeRelease.group}:cache4:${storeRelease.version}" - implementation "${storeRelease.group}:filesystem4:${storeRelease.version}" testImplementation libraries.junit testImplementation libraries.mockito @@ -74,6 +66,9 @@ dependencies { implementation libraries.retrofitMoshiConverter kapt(libraries.moshiCodegen) kapt(libraries.roomCompiler) + implementation project(':store') + implementation project(':cache') + implementation project(':filesystem') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$versions.kotlin" implementation libraries.coroutinesCore diff --git a/app/src/main/java/com/dropbox/android/sample/Graph.kt b/app/src/main/java/com/dropbox/android/sample/Graph.kt index 17f8a1ee5..a46c1aa56 100644 --- a/app/src/main/java/com/dropbox/android/sample/Graph.kt +++ b/app/src/main/java/com/dropbox/android/sample/Graph.kt @@ -15,7 +15,9 @@ import com.dropbox.android.external.store4.StoreBuilder import com.dropbox.android.external.store4.MemoryPolicy import com.dropbox.android.external.store4.Persister import com.dropbox.android.external.store4.Store +import com.dropbox.android.external.store4.SourceOfTruth import com.dropbox.android.external.store4.legacy.BarCode +import com.dropbox.android.external.store4.nonFlowValueFetcher import com.squareup.moshi.Moshi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -31,21 +33,28 @@ import java.io.IOException import kotlin.time.ExperimentalTime import kotlin.time.seconds -@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class, ExperimentalTime::class, ExperimentalStdlibApi::class) +@OptIn( + FlowPreview::class, + ExperimentalCoroutinesApi::class, + ExperimentalTime::class, + ExperimentalStdlibApi::class +) object Graph { private val moshi = Moshi.Builder().build() fun provideRoomStore(context: SampleApp): Store> { val db = provideRoom(context) return StoreBuilder - .fromNonFlow { key: String -> - provideRetrofit().fetchSubreddit(key, 10).data.children.map(::toPosts) - } - .persister( - reader = db.postDao()::loadPosts, - writer = db.postDao()::insertPosts, - delete = db.postDao()::clearFeedBySubredditName, - deleteAll = db.postDao()::clearAllFeeds + .from( + nonFlowValueFetcher { key: String -> + provideRetrofit().fetchSubreddit(key, 10).data.children.map(::toPosts) + }, + sourceOfTruth = SourceOfTruth.from( + reader = db.postDao()::loadPosts, + writer = db.postDao()::insertPosts, + delete = db.postDao()::clearFeedBySubredditName, + deleteAll = db.postDao()::clearAllFeeds + ) ) .build() } @@ -53,14 +62,17 @@ object Graph { fun provideRoomStoreMultiParam(context: SampleApp): Store, List> { val db = provideRoom(context) return StoreBuilder - .fromNonFlow, List> { (query, config) -> - provideRetrofit().fetchSubreddit(query, config.limit) - .data.children.map(::toPosts) - } - .persister(reader = { (query, _) -> db.postDao().loadPosts(query) }, - writer = { (query, _), posts -> db.postDao().insertPosts(query, posts) }, - delete = { (query, _) -> db.postDao().clearFeedBySubredditName(query) }, - deleteAll = db.postDao()::clearAllFeeds + .from, List, List>( + nonFlowValueFetcher { (query, config) -> + provideRetrofit().fetchSubreddit(query, config.limit) + .data.children.map(::toPosts) + }, + sourceOfTruth = SourceOfTruth.from( + reader = { (query, _) -> db.postDao().loadPosts(query) }, + writer = { (query, _), posts -> db.postDao().insertPosts(query, posts) }, + delete = { (query, _) -> db.postDao().clearFeedBySubredditName(query) }, + deleteAll = db.postDao()::clearAllFeeds + ) ) .build() } @@ -86,25 +98,26 @@ object Graph { }) val adapter = moshi.adapter(RedditConfig::class.java) return StoreBuilder - .fromNonFlow { - delay(500) - RedditConfig(10) - } - .nonFlowingPersister( - reader = { - runCatching { - val source = fileSystemPersister.read(Unit) - source?.let { adapter.fromJson(it) } - }.getOrNull() + .from( + nonFlowValueFetcher { + delay(500) + RedditConfig(10) }, - writer = { _, config -> - val buffer = Buffer() - withContext(Dispatchers.IO) { - adapter.toJson(buffer, config) + sourceOfTruth = SourceOfTruth.fromNonFlow( + reader = { + runCatching { + val source = fileSystemPersister.read(Unit) + source?.let { adapter.fromJson(it) } + }.getOrNull() + }, + writer = { _, config -> + val buffer = Buffer() + withContext(Dispatchers.IO) { + adapter.toJson(buffer, config) + } + fileSystemPersister.write(Unit, buffer) } - fileSystemPersister.write(Unit, buffer) - } - ) + )) .cachePolicy( MemoryPolicy.builder().setExpireAfterWrite(10.seconds).build() ) diff --git a/app/src/main/java/com/dropbox/android/sample/ui/room/RoomFragment.kt b/app/src/main/java/com/dropbox/android/sample/ui/room/RoomFragment.kt index ec93c5408..11ba3993d 100644 --- a/app/src/main/java/com/dropbox/android/sample/ui/room/RoomFragment.kt +++ b/app/src/main/java/com/dropbox/android/sample/ui/room/RoomFragment.kt @@ -130,8 +130,9 @@ internal class StoreState( it is StoreResponse.Loading ) } - if (it is StoreResponse.Error) { - _errors.send(it.error.localizedMessage!!) + when (it) { + is StoreResponse.Error.Exception -> _errors.send(it.error.localizedMessage!!) + is StoreResponse.Error.Message -> _errors.send(it.message) } }.transform { if (it is StoreResponse.Data) { diff --git a/app/src/main/java/com/dropbox/android/sample/ui/stream/StreamFragment.kt b/app/src/main/java/com/dropbox/android/sample/ui/stream/StreamFragment.kt index 0475e87a1..f882c67f9 100644 --- a/app/src/main/java/com/dropbox/android/sample/ui/stream/StreamFragment.kt +++ b/app/src/main/java/com/dropbox/android/sample/ui/stream/StreamFragment.kt @@ -10,6 +10,7 @@ import com.dropbox.android.external.store4.StoreBuilder import com.dropbox.android.external.store4.StoreRequest import com.dropbox.android.external.store4.fresh import com.dropbox.android.external.store4.get +import com.dropbox.android.external.store4.nonFlowValueFetcher import com.dropbox.android.sample.R import kotlinx.android.synthetic.main.fragment_stream.* import kotlinx.coroutines.CoroutineScope @@ -50,7 +51,9 @@ class StreamFragment : Fragment(), CoroutineScope { var counter = 0 val store = StoreBuilder - .fromNonFlow { key: Int -> (key * 1000 + counter++).also { delay(1_000) } } + .from(nonFlowValueFetcher { key: Int -> + (key * 1000 + counter++).also { delay(1_000) } + }) .cachePolicy( MemoryPolicy .builder() diff --git a/store-rx2/api/store-rx2.api b/store-rx2/api/store-rx2.api index 12b27cfd1..194e803a4 100644 --- a/store-rx2/api/store-rx2.api +++ b/store-rx2/api/store-rx2.api @@ -1,15 +1,26 @@ +public final class com/dropbox/store/rx2/RxFetcherKt { + public static final fun flowableFetcher (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function1; + public static final fun flowableValueFetcher (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function1; + public static final fun singleFetcher (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function1; + public static final fun singleValueFetcher (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function1; +} + +public final class com/dropbox/store/rx2/RxSourceOfTruthKt { + public static final fun fromFlowable (Lcom/dropbox/android/external/store4/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)Lcom/dropbox/android/external/store4/SourceOfTruth; + public static synthetic fun fromFlowable$default (Lcom/dropbox/android/external/store4/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/dropbox/android/external/store4/SourceOfTruth; + public static final fun fromMaybe (Lcom/dropbox/android/external/store4/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)Lcom/dropbox/android/external/store4/SourceOfTruth; + public static synthetic fun fromMaybe$default (Lcom/dropbox/android/external/store4/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/dropbox/android/external/store4/SourceOfTruth; +} + +public final class com/dropbox/store/rx2/RxStoreBuilderKt { + public static final fun withScheduler (Lcom/dropbox/android/external/store4/StoreBuilder;Lio/reactivex/Scheduler;)Lcom/dropbox/android/external/store4/StoreBuilder; +} + public final class com/dropbox/store/rx2/RxStoreKt { public static final fun freshSingle (Lcom/dropbox/android/external/store4/Store;Ljava/lang/Object;)Lio/reactivex/Single; - public static final fun fromFlowable (Lcom/dropbox/android/external/store4/StoreBuilder$Companion;Lkotlin/jvm/functions/Function1;)Lcom/dropbox/android/external/store4/StoreBuilder; - public static final fun fromSingle (Lcom/dropbox/android/external/store4/StoreBuilder$Companion;Lkotlin/jvm/functions/Function1;)Lcom/dropbox/android/external/store4/StoreBuilder; public static final fun getSingle (Lcom/dropbox/android/external/store4/Store;Ljava/lang/Object;)Lio/reactivex/Single; public static final fun observe (Lcom/dropbox/android/external/store4/Store;Lcom/dropbox/android/external/store4/StoreRequest;)Lio/reactivex/Flowable; public static final fun observeClear (Lcom/dropbox/android/external/store4/Store;Ljava/lang/Object;)Lio/reactivex/Completable; public static final fun observeClearAll (Lcom/dropbox/android/external/store4/Store;)Lio/reactivex/Completable; - public static final fun withFlowablePersister (Lcom/dropbox/android/external/store4/StoreBuilder;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)Lcom/dropbox/android/external/store4/StoreBuilder; - public static synthetic fun withFlowablePersister$default (Lcom/dropbox/android/external/store4/StoreBuilder;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/dropbox/android/external/store4/StoreBuilder; - public static final fun withScheduler (Lcom/dropbox/android/external/store4/StoreBuilder;Lio/reactivex/Scheduler;)Lcom/dropbox/android/external/store4/StoreBuilder; - public static final fun withSinglePersister (Lcom/dropbox/android/external/store4/StoreBuilder;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)Lcom/dropbox/android/external/store4/StoreBuilder; - public static synthetic fun withSinglePersister$default (Lcom/dropbox/android/external/store4/StoreBuilder;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/dropbox/android/external/store4/StoreBuilder; } diff --git a/store-rx2/src/main/kotlin/com/dropbox/store/rx2/RxFetcher.kt b/store-rx2/src/main/kotlin/com/dropbox/store/rx2/RxFetcher.kt new file mode 100644 index 000000000..384f5afe6 --- /dev/null +++ b/store-rx2/src/main/kotlin/com/dropbox/store/rx2/RxFetcher.kt @@ -0,0 +1,81 @@ +package com.dropbox.store.rx2 + +import com.dropbox.android.external.store4.Fetcher +import com.dropbox.android.external.store4.FetcherResult +import com.dropbox.android.external.store4.Store +import com.dropbox.android.external.store4.valueFetcher +import io.reactivex.Flowable +import io.reactivex.Single +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.reactive.asFlow + +/** + * Creates a new [Fetcher] from a [flowableFactory]. + * + * [Store] does not catch exception thrown in [flowableFactory] or in the returned [Flowable]. These + * exception will be propagated to the caller. + * + * Use when creating a [Store] that fetches objects in a multiple responses per request + * network protocol (e.g Web Sockets). + * + * @param flowableFactory a factory for a [Flowable] source of network records. + */ +@FlowPreview +@ExperimentalCoroutinesApi +fun flowableFetcher( + flowableFactory: (key: Key) -> Flowable> +): Fetcher = { key: Key -> flowableFactory(key).asFlow() } + +/** + * "Creates" a [Fetcher] from a [singleFactory]. + * + * [Store] does not catch exception thrown in [singleFactory] or in the returned [Single]. These + * exception will be propagated to the caller. + * + * Use when creating a [Store] that fetches objects in a single response per request network + * protocol (e.g Http). + * + * @param singleFactory a factory for a [Single] source of network records. + */ +@FlowPreview +@ExperimentalCoroutinesApi +fun singleFetcher( + singleFactory: (key: Key) -> Single> +): Fetcher = { key: Key -> singleFactory(key).toFlowable().asFlow() } + +/** + * "Creates" a [Fetcher] from a [flowableFactory] and translate the results to a [FetcherResult]. + * + * Emitted values will be wrapped in [FetcherResult.Data]. if an exception disrupts the stream then + * it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [flowableFactory] itself are + * not caught and will be returned to the caller. + * + * Use when creating a [Store] that fetches objects in a multiple responses per request + * network protocol (e.g Web Sockets). + * + * @param flowFactory a factory for a [Flowable] source of network records. + */ +@FlowPreview +@ExperimentalCoroutinesApi +fun flowableValueFetcher( + flowableFactory: (key: Key) -> Flowable +): Fetcher = valueFetcher { key: Key -> flowableFactory(key).asFlow() } + +/** + * Creates a new [Fetcher] from a [singleFactory] and translate the results to a [FetcherResult]. + * + * The emitted value will be wrapped in [FetcherResult.Data]. if an exception is returned then + * it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [singleFactory] itself are + * not caught and will be returned to the caller. + * + * Use when creating a [Store] that fetches objects in a single response per request network + * protocol (e.g Http). + * + * @param singleFactory a factory for a [Single] source of network records. + */ +@FlowPreview +@ExperimentalCoroutinesApi +fun singleValueFetcher( + singleFactory: (key: Key) -> Single +): Fetcher = flowableValueFetcher { key: Key -> singleFactory(key).toFlowable() } diff --git a/store-rx2/src/main/kotlin/com/dropbox/store/rx2/RxSourceOfTruth.kt b/store-rx2/src/main/kotlin/com/dropbox/store/rx2/RxSourceOfTruth.kt new file mode 100644 index 000000000..70165d5c7 --- /dev/null +++ b/store-rx2/src/main/kotlin/com/dropbox/store/rx2/RxSourceOfTruth.kt @@ -0,0 +1,68 @@ +package com.dropbox.store.rx2 + +import com.dropbox.android.external.store4.SourceOfTruth +import io.reactivex.Completable +import io.reactivex.Flowable +import io.reactivex.Maybe +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.rx2.await + +/** + * Creates a [Maybe] source of truth that is accessible via [reader], [writer], [delete] and + * [deleteAll]. + * + * @param reader function for reading records from the source of truth + * @param writer function for writing updates to the backing source of truth + * @param delete function for deleting records in the source of truth for the given key + * @param deleteAll function for deleting all records in the source of truth + * + */ +@FlowPreview +@ExperimentalCoroutinesApi +fun SourceOfTruth.Companion.fromMaybe( + reader: (Key) -> Maybe, + writer: (Key, Input) -> Completable, + delete: ((Key) -> Completable)? = null, + deleteAll: (() -> Completable)? = null +): SourceOfTruth { + val deleteFun: (suspend (Key) -> Unit)? = + if (delete != null) { key -> delete(key).await() } else null + val deleteAllFun: (suspend () -> Unit)? = deleteAll?.let { { deleteAll().await() } } + return fromNonFlow( + reader = { key -> reader.invoke(key).await() }, + writer = { key, output -> writer.invoke(key, output).await() }, + delete = deleteFun, + deleteAll = deleteAllFun + ) +} + +/** + * Creates a ([Flowable]) source of truth that is accessed via [reader], [writer], [delete] and + * [deleteAll]. + * + * @param reader function for reading records from the source of truth + * @param writer function for writing updates to the backing source of truth + * @param delete function for deleting records in the source of truth for the given key + * @param deleteAll function for deleting all records in the source of truth + * + */ +@FlowPreview +@ExperimentalCoroutinesApi +fun SourceOfTruth.Companion.fromFlowable( + reader: (Key) -> Flowable, + writer: (Key, Input) -> Completable, + delete: ((Key) -> Completable)? = null, + deleteAll: (() -> Completable)? = null +): SourceOfTruth { + val deleteFun: (suspend (Key) -> Unit)? = + if (delete != null) { key -> delete(key).await() } else null + val deleteAllFun: (suspend () -> Unit)? = deleteAll?.let { { deleteAll().await() } } + return from( + reader = { key -> reader.invoke(key).asFlow() }, + writer = { key, output -> writer.invoke(key, output).await() }, + delete = deleteFun, + deleteAll = deleteAllFun + ) +} diff --git a/store-rx2/src/main/kotlin/com/dropbox/store/rx2/RxStore.kt b/store-rx2/src/main/kotlin/com/dropbox/store/rx2/RxStore.kt index e946a7032..0649b7e57 100644 --- a/store-rx2/src/main/kotlin/com/dropbox/store/rx2/RxStore.kt +++ b/store-rx2/src/main/kotlin/com/dropbox/store/rx2/RxStore.kt @@ -9,17 +9,8 @@ import com.dropbox.android.external.store4.fresh import com.dropbox.android.external.store4.get import io.reactivex.Completable import io.reactivex.Flowable -import io.reactivex.Maybe -import io.reactivex.Scheduler -import io.reactivex.Single -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.reactive.asFlow -import kotlinx.coroutines.rx2.asCoroutineDispatcher import kotlinx.coroutines.rx2.asFlowable -import kotlinx.coroutines.rx2.await import kotlinx.coroutines.rx2.rxCompletable import kotlinx.coroutines.rx2.rxSingle @@ -48,109 +39,6 @@ fun Store.observeClear(key: Key): Complet fun Store.observeClearAll(): Completable = rxCompletable { clearAll() } -/** - * Creates a new [StoreBuilder] from a [Flowable] fetcher. - * - * Use when creating a [Store] that fetches objects in an websocket-like multiple responses - * per request protocol. - * - * @param fetcher a function for fetching a flow of network records. - */ -@FlowPreview -@ExperimentalCoroutinesApi -fun StoreBuilder.Companion.fromFlowable( - fetcher: (key: Key) -> Flowable -): StoreBuilder = from { key: Key -> - fetcher(key).asFlow() -} - -/** - * Creates a new [StoreBuilder] from a [Single] fetcher. - * - * Use when creating a [Store] that fetches objects from a [Single] source that emits one response - * - * @param fetcher a function for fetching a [Single] network response for a [Key] - */ -@FlowPreview -@ExperimentalCoroutinesApi -fun StoreBuilder.Companion.fromSingle( - fetcher: (key: Key) -> Single -): StoreBuilder = - from { key: Key -> fetcher(key).toFlowable().asFlow() } - -/** - * Define what scheduler fetcher requests will be called on, - * if a scheduler is not set Store will use [GlobalScope] - */ -@FlowPreview -@ExperimentalCoroutinesApi -fun StoreBuilder.withScheduler( - scheduler: Scheduler -): StoreBuilder { - return scope(CoroutineScope(scheduler.asCoroutineDispatcher())) -} - -/** - * Connects a (Non Flow) [Single] source of truth that is accessible via [reader], [writer], - * [delete], and [deleteAll]. - * - * @see com.dropbox.android.external.store4.StoreBuilder.persister - */ -@FlowPreview -@ExperimentalCoroutinesApi -fun StoreBuilder.withSinglePersister( - reader: (Key) -> Maybe, - writer: (Key, Output) -> Single, - delete: ((Key) -> Completable)? = null, - deleteAll: (() -> Completable)? = null -): StoreBuilder { - val deleteFun: (suspend (Key) -> Unit)? = - if (delete != null) { key -> delete(key).await() } else null - val deleteAllFun: (suspend () -> Unit)? = deleteAll?.let { { deleteAll().await() } } - return nonFlowingPersister( - reader = { key -> reader.invoke(key).await() }, - writer = { key, output -> writer.invoke(key, output).await() }, - delete = deleteFun, - deleteAll = deleteAllFun - ) -} - -/** - * Connects a ([Flowable]) source of truth that is accessed via [reader], [writer] and [delete]. - * - * For maximal flexibility, [writer]'s record type ([Output]] and [reader]'s record type - * ([NewOutput]) are not identical. This allows us to read one type of objects from network and - * transform them to another type when placing them in local storage. - * - * A source of truth is usually backed by local storage. It's purpose is to eliminate the need - * for waiting on network update before local modifications are available (via [Store.stream]). - * - * @param reader reads records from the source of truth - * @param writer writes records **coming in from the fetcher (network)** to the source of truth. - * Writing local user updates to the source of truth via [Store] is currently not supported. - * @param delete deletes records in the source of truth for the give key - * @param deleteAll deletes all records in the source of truth - * - */ -@FlowPreview -@ExperimentalCoroutinesApi -fun StoreBuilder.withFlowablePersister( - reader: (Key) -> Flowable, - writer: (Key, Output) -> Single, - delete: ((Key) -> Completable)? = null, - deleteAll: (() -> Completable)? = null -): StoreBuilder { - val deleteFun: (suspend (Key) -> Unit)? = - if (delete != null) { key -> delete(key).await() } else null - val deleteAllFun: (suspend () -> Unit)? = deleteAll?.let { { deleteAll().await() } } - return persister( - reader = { key -> reader.invoke(key).asFlow() }, - writer = { key, output -> writer.invoke(key, output).await() }, - delete = deleteFun, - deleteAll = deleteAllFun - ) -} - /** * Helper factory that will return data as a [Single] for [key] if it is cached otherwise will return fresh/network data (updating your caches) */ diff --git a/store-rx2/src/main/kotlin/com/dropbox/store/rx2/RxStoreBuilder.kt b/store-rx2/src/main/kotlin/com/dropbox/store/rx2/RxStoreBuilder.kt new file mode 100644 index 000000000..49347e24d --- /dev/null +++ b/store-rx2/src/main/kotlin/com/dropbox/store/rx2/RxStoreBuilder.kt @@ -0,0 +1,21 @@ +package com.dropbox.store.rx2 + +import com.dropbox.android.external.store4.StoreBuilder +import io.reactivex.Scheduler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.rx2.asCoroutineDispatcher + +/** + * Define what scheduler fetcher requests will be called on, + * if a scheduler is not set Store will use [GlobalScope] + */ +@FlowPreview +@ExperimentalCoroutinesApi +fun StoreBuilder.withScheduler( + scheduler: Scheduler +): StoreBuilder { + return scope(CoroutineScope(scheduler.asCoroutineDispatcher())) +} diff --git a/store-rx2/src/test/kotlin/com/dropbox/store/rx2/test/HotRxSingleStoreTest.kt b/store-rx2/src/test/kotlin/com/dropbox/store/rx2/test/HotRxSingleStoreTest.kt index d9576e9ea..f0a48948a 100644 --- a/store-rx2/src/test/kotlin/com/dropbox/store/rx2/test/HotRxSingleStoreTest.kt +++ b/store-rx2/src/test/kotlin/com/dropbox/store/rx2/test/HotRxSingleStoreTest.kt @@ -1,10 +1,12 @@ package com.dropbox.store.rx2.test +import com.dropbox.android.external.store4.Fetcher +import com.dropbox.android.external.store4.FetcherResult import com.dropbox.android.external.store4.ResponseOrigin import com.dropbox.android.external.store4.StoreBuilder import com.dropbox.android.external.store4.StoreRequest import com.dropbox.android.external.store4.StoreResponse -import com.dropbox.store.rx2.fromSingle +import com.dropbox.store.rx2.singleFetcher import com.google.common.truth.Truth.assertThat import io.reactivex.Single import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -20,14 +22,15 @@ import org.junit.runners.JUnit4 @ExperimentalCoroutinesApi class HotRxSingleStoreTest { private val testScope = TestCoroutineScope() + @Test fun `GIVEN a hot fetcher WHEN two cached and one fresh call THEN fetcher is only called twice`() = testScope.runBlockingTest { - val fetcher = FakeFetcher( - 3 to "three-1", - 3 to "three-2" + val fetcher: FakeRxFetcher> = FakeRxFetcher( + 3 to FetcherResult.Data("three-1"), + 3 to FetcherResult.Data("three-2") ) - val pipeline = StoreBuilder.fromSingle { fetcher.fetch(it) } + val pipeline = StoreBuilder.from(singleFetcher { fetcher.fetch(it) }) .scope(testScope) .build() @@ -62,10 +65,11 @@ class HotRxSingleStoreTest { } } -class FakeFetcher( +class FakeRxFetcher( vararg val responses: Pair ) { private var index = 0 + @Suppress("RedundantSuspendModifier") // needed for function reference fun fetch(key: Key): Single { // will throw if fetcher called more than twice diff --git a/store-rx2/src/test/kotlin/com/dropbox/store/rx2/test/RxFlowableStoreTest.kt b/store-rx2/src/test/kotlin/com/dropbox/store/rx2/test/RxFlowableStoreTest.kt index 5b4320bb4..ccb763b28 100644 --- a/store-rx2/src/test/kotlin/com/dropbox/store/rx2/test/RxFlowableStoreTest.kt +++ b/store-rx2/src/test/kotlin/com/dropbox/store/rx2/test/RxFlowableStoreTest.kt @@ -1,15 +1,19 @@ package com.dropbox.store.rx2.test +import com.dropbox.android.external.store4.FetcherResult import com.dropbox.android.external.store4.ResponseOrigin +import com.dropbox.android.external.store4.SourceOfTruth import com.dropbox.android.external.store4.StoreBuilder import com.dropbox.android.external.store4.StoreRequest import com.dropbox.android.external.store4.StoreResponse -import com.dropbox.store.rx2.observe +import com.dropbox.store.rx2.flowableFetcher import com.dropbox.store.rx2.fromFlowable -import com.dropbox.store.rx2.withFlowablePersister +import com.dropbox.store.rx2.observe import io.reactivex.BackpressureStrategy +import io.reactivex.Completable import io.reactivex.Flowable -import io.reactivex.Single +import io.reactivex.schedulers.TestScheduler +import io.reactivex.subscribers.TestSubscriber import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import org.junit.Test @@ -21,33 +25,43 @@ import java.util.concurrent.atomic.AtomicInteger @FlowPreview @ExperimentalCoroutinesApi class RxFlowableStoreTest { + private val testScheduler = TestScheduler() private val atomicInteger = AtomicInteger(0) private val fakeDisk = mutableMapOf() private val store = - StoreBuilder.fromFlowable { - Flowable.create({ emitter -> - emitter.onNext("$it ${atomicInteger.incrementAndGet()} occurrence") - emitter.onNext("$it ${atomicInteger.incrementAndGet()} occurrence") - emitter.onComplete() - }, BackpressureStrategy.LATEST) - } - .withFlowablePersister( + StoreBuilder.from( + flowableFetcher { + Flowable.create({ emitter -> + emitter.onNext( + FetcherResult.Data("$it ${atomicInteger.incrementAndGet()} occurrence") + ) + emitter.onNext( + FetcherResult.Data("$it ${atomicInteger.incrementAndGet()} occurrence") + ) + emitter.onComplete() + }, BackpressureStrategy.LATEST) + }, + sourceOfTruth = SourceOfTruth.fromFlowable( reader = { if (fakeDisk[it] != null) Flowable.fromCallable { fakeDisk[it]!! } else - Flowable.empty() + Flowable.empty() }, writer = { key, value -> - Single.fromCallable { fakeDisk[key] = value } + Completable.fromAction { fakeDisk[key] = value } } - ) + )) .build() @Test fun simpleTest() { + var testSubscriber = TestSubscriber>() store.observe(StoreRequest.fresh(3)) - .test() + .subscribeOn(testScheduler) + .subscribe(testSubscriber) + testScheduler.triggerActions() + testSubscriber .awaitCount(3) .assertValues( StoreResponse.Loading(ResponseOrigin.Fetcher), @@ -55,16 +69,24 @@ class RxFlowableStoreTest { StoreResponse.Data("3 2 occurrence", ResponseOrigin.Fetcher) ) + testSubscriber = TestSubscriber>() store.observe(StoreRequest.cached(3, false)) - .test() + .subscribeOn(testScheduler) + .subscribe(testSubscriber) + testScheduler.triggerActions() + testSubscriber .awaitCount(2) .assertValues( StoreResponse.Data("3 2 occurrence", ResponseOrigin.Cache), - StoreResponse.Data("3 2 occurrence", ResponseOrigin.Persister) + StoreResponse.Data("3 2 occurrence", ResponseOrigin.SourceOfTruth) ) + testSubscriber = TestSubscriber>() store.observe(StoreRequest.fresh(3)) - .test() + .subscribeOn(testScheduler) + .subscribe(testSubscriber) + testScheduler.triggerActions() + testSubscriber .awaitCount(3) .assertValues( StoreResponse.Loading(ResponseOrigin.Fetcher), @@ -72,12 +94,16 @@ class RxFlowableStoreTest { StoreResponse.Data("3 4 occurrence", ResponseOrigin.Fetcher) ) + testSubscriber = TestSubscriber>() store.observe(StoreRequest.cached(3, false)) - .test() + .subscribeOn(testScheduler) + .subscribe(testSubscriber) + testScheduler.triggerActions() + testSubscriber .awaitCount(2) .assertValues( StoreResponse.Data("3 4 occurrence", ResponseOrigin.Cache), - StoreResponse.Data("3 4 occurrence", ResponseOrigin.Persister) + StoreResponse.Data("3 4 occurrence", ResponseOrigin.SourceOfTruth) ) } } diff --git a/store-rx2/src/test/kotlin/com/dropbox/store/rx2/test/RxSingleStoreExtensionsTest.kt b/store-rx2/src/test/kotlin/com/dropbox/store/rx2/test/RxSingleStoreExtensionsTest.kt index 5260bc476..c70731205 100644 --- a/store-rx2/src/test/kotlin/com/dropbox/store/rx2/test/RxSingleStoreExtensionsTest.kt +++ b/store-rx2/src/test/kotlin/com/dropbox/store/rx2/test/RxSingleStoreExtensionsTest.kt @@ -1,12 +1,14 @@ package com.dropbox.store.rx2.test import com.dropbox.android.external.store4.ExperimentalStoreApi +import com.dropbox.android.external.store4.FetcherResult +import com.dropbox.android.external.store4.SourceOfTruth import com.dropbox.android.external.store4.StoreBuilder import com.dropbox.store.rx2.freshSingle -import com.dropbox.store.rx2.fromSingle +import com.dropbox.store.rx2.fromMaybe import com.dropbox.store.rx2.getSingle +import com.dropbox.store.rx2.singleFetcher import com.dropbox.store.rx2.withScheduler -import com.dropbox.store.rx2.withSinglePersister import io.reactivex.Completable import io.reactivex.Maybe import io.reactivex.Single @@ -24,28 +26,25 @@ import java.util.concurrent.atomic.AtomicInteger @ExperimentalCoroutinesApi class RxSingleStoreExtensionsTest { private val atomicInteger = AtomicInteger(0) - private var fakeDisk = mutableMapOf() + private var fakeDisk = mutableMapOf() private val store = - StoreBuilder.fromSingle { Single.fromCallable { "$it ${atomicInteger.incrementAndGet()}" } } - .withSinglePersister( - reader = { - if (fakeDisk[it] != null) - Maybe.fromCallable { fakeDisk[it]!! } - else - Maybe.empty() - }, + StoreBuilder.from( + fetcher = singleFetcher { + Single.fromCallable { FetcherResult.Data("$it ${atomicInteger.incrementAndGet()}") } + }, + sourceOfTruth = SourceOfTruth.fromMaybe( + reader = { Maybe.fromCallable { fakeDisk[it] } }, writer = { key, value -> - Single.fromCallable { fakeDisk[key] = value } + Completable.fromAction { fakeDisk[key] = value } }, delete = { key -> - fakeDisk[key] = null - Completable.complete() + Completable.fromAction { fakeDisk.remove(key) } }, deleteAll = { - fakeDisk.clear() - Completable.complete() + Completable.fromAction { fakeDisk.clear() } } ) + ) .withScheduler(Schedulers.trampoline()) .build() diff --git a/store-rx2/src/test/kotlin/com/dropbox/store/rx2/test/RxSingleStoreTest.kt b/store-rx2/src/test/kotlin/com/dropbox/store/rx2/test/RxSingleStoreTest.kt index 26ebf0390..171fb2f23 100644 --- a/store-rx2/src/test/kotlin/com/dropbox/store/rx2/test/RxSingleStoreTest.kt +++ b/store-rx2/src/test/kotlin/com/dropbox/store/rx2/test/RxSingleStoreTest.kt @@ -1,16 +1,18 @@ package com.dropbox.store.rx2.test import com.dropbox.android.external.store4.ExperimentalStoreApi +import com.dropbox.android.external.store4.FetcherResult import com.dropbox.android.external.store4.ResponseOrigin import com.dropbox.android.external.store4.StoreBuilder import com.dropbox.android.external.store4.StoreRequest import com.dropbox.android.external.store4.StoreResponse -import com.dropbox.store.rx2.fromSingle +import com.dropbox.android.external.store4.SourceOfTruth +import com.dropbox.store.rx2.fromMaybe import com.dropbox.store.rx2.observe import com.dropbox.store.rx2.observeClear import com.dropbox.store.rx2.observeClearAll +import com.dropbox.store.rx2.singleFetcher import com.dropbox.store.rx2.withScheduler -import com.dropbox.store.rx2.withSinglePersister import io.reactivex.Completable import io.reactivex.Maybe import io.reactivex.Single @@ -28,28 +30,25 @@ import java.util.concurrent.atomic.AtomicInteger @ExperimentalCoroutinesApi class RxSingleStoreTest { private val atomicInteger = AtomicInteger(0) - private var fakeDisk = mutableMapOf() + private var fakeDisk = mutableMapOf() private val store = - StoreBuilder.fromSingle { Single.fromCallable { "$it ${atomicInteger.incrementAndGet()}" } } - .withSinglePersister( - reader = { - if (fakeDisk[it] != null) - Maybe.fromCallable { fakeDisk[it]!! } - else - Maybe.empty() - }, + StoreBuilder.from( + fetcher = singleFetcher { + Single.fromCallable { FetcherResult.Data("$it ${atomicInteger.incrementAndGet()}") } + }, + sourceOfTruth = SourceOfTruth.fromMaybe( + reader = { Maybe.fromCallable { fakeDisk[it] } }, writer = { key, value -> - Single.fromCallable { fakeDisk[key] = value } + Completable.fromAction { fakeDisk[key] = value } }, delete = { key -> - fakeDisk[key] = null - Completable.complete() + Completable.fromAction { fakeDisk.remove(key) } }, deleteAll = { - fakeDisk.clear() - Completable.complete() + Completable.fromAction { fakeDisk.clear() } } ) + ) .withScheduler(Schedulers.trampoline()) .build() @@ -68,7 +67,7 @@ class RxSingleStoreTest { .awaitCount(2) .assertValues( StoreResponse.Data("3 1", ResponseOrigin.Cache), - StoreResponse.Data("3 1", ResponseOrigin.Persister) + StoreResponse.Data("3 1", ResponseOrigin.SourceOfTruth) ) store.observe(StoreRequest.fresh(3)) @@ -84,7 +83,7 @@ class RxSingleStoreTest { .awaitCount(2) .assertValues( StoreResponse.Data("3 2", ResponseOrigin.Cache), - StoreResponse.Data("3 2", ResponseOrigin.Persister) + StoreResponse.Data("3 2", ResponseOrigin.SourceOfTruth) ) } diff --git a/store/api/store.api b/store/api/store.api index c141a8941..0a99cd5ca 100644 --- a/store/api/store.api +++ b/store/api/store.api @@ -9,6 +9,52 @@ public abstract interface class com/dropbox/android/external/store4/DiskWrite { public abstract interface annotation class com/dropbox/android/external/store4/ExperimentalStoreApi : java/lang/annotation/Annotation { } +public final class com/dropbox/android/external/store4/FetcherKt { + public static final fun fetcher (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function1; + public static final fun nonFlowFetcher (Lkotlin/jvm/functions/Function2;)Lkotlin/jvm/functions/Function1; + public static final fun nonFlowValueFetcher (Lkotlin/jvm/functions/Function2;)Lkotlin/jvm/functions/Function1; + public static final fun valueFetcher (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function1; +} + +public abstract class com/dropbox/android/external/store4/FetcherResult { +} + +public final class com/dropbox/android/external/store4/FetcherResult$Data : com/dropbox/android/external/store4/FetcherResult { + public fun (Ljava/lang/Object;)V + public final fun component1 ()Ljava/lang/Object; + public final fun copy (Ljava/lang/Object;)Lcom/dropbox/android/external/store4/FetcherResult$Data; + public static synthetic fun copy$default (Lcom/dropbox/android/external/store4/FetcherResult$Data;Ljava/lang/Object;ILjava/lang/Object;)Lcom/dropbox/android/external/store4/FetcherResult$Data; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()Ljava/lang/Object; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract class com/dropbox/android/external/store4/FetcherResult$Error : com/dropbox/android/external/store4/FetcherResult { +} + +public final class com/dropbox/android/external/store4/FetcherResult$Error$Exception : com/dropbox/android/external/store4/FetcherResult$Error { + public fun (Ljava/lang/Throwable;)V + public final fun component1 ()Ljava/lang/Throwable; + public final fun copy (Ljava/lang/Throwable;)Lcom/dropbox/android/external/store4/FetcherResult$Error$Exception; + public static synthetic fun copy$default (Lcom/dropbox/android/external/store4/FetcherResult$Error$Exception;Ljava/lang/Throwable;ILjava/lang/Object;)Lcom/dropbox/android/external/store4/FetcherResult$Error$Exception; + public fun equals (Ljava/lang/Object;)Z + public final fun getError ()Ljava/lang/Throwable; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/dropbox/android/external/store4/FetcherResult$Error$Message : com/dropbox/android/external/store4/FetcherResult$Error { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lcom/dropbox/android/external/store4/FetcherResult$Error$Message; + public static synthetic fun copy$default (Lcom/dropbox/android/external/store4/FetcherResult$Error$Message;Ljava/lang/String;ILjava/lang/Object;)Lcom/dropbox/android/external/store4/FetcherResult$Error$Message; + public fun equals (Ljava/lang/Object;)Z + public final fun getMessage ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class com/dropbox/android/external/store4/MemoryPolicy { public static final field Companion Lcom/dropbox/android/external/store4/MemoryPolicy$Companion; public static final field DEFAULT_SIZE_POLICY J @@ -42,11 +88,26 @@ public abstract interface class com/dropbox/android/external/store4/Persister : public final class com/dropbox/android/external/store4/ResponseOrigin : java/lang/Enum { public static final field Cache Lcom/dropbox/android/external/store4/ResponseOrigin; public static final field Fetcher Lcom/dropbox/android/external/store4/ResponseOrigin; - public static final field Persister Lcom/dropbox/android/external/store4/ResponseOrigin; + public static final field SourceOfTruth Lcom/dropbox/android/external/store4/ResponseOrigin; public static fun valueOf (Ljava/lang/String;)Lcom/dropbox/android/external/store4/ResponseOrigin; public static fun values ()[Lcom/dropbox/android/external/store4/ResponseOrigin; } +public abstract interface class com/dropbox/android/external/store4/SourceOfTruth { + public static final field Companion Lcom/dropbox/android/external/store4/SourceOfTruth$Companion; + public abstract fun delete (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun reader (Ljava/lang/Object;)Lkotlinx/coroutines/flow/Flow; + public abstract fun write (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/dropbox/android/external/store4/SourceOfTruth$Companion { + public final fun from (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lcom/dropbox/android/external/store4/SourceOfTruth; + public static synthetic fun from$default (Lcom/dropbox/android/external/store4/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/dropbox/android/external/store4/SourceOfTruth; + public final fun fromNonFlow (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lcom/dropbox/android/external/store4/SourceOfTruth; + public static synthetic fun fromNonFlow$default (Lcom/dropbox/android/external/store4/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/dropbox/android/external/store4/SourceOfTruth; +} + public abstract interface class com/dropbox/android/external/store4/Store { public abstract fun clear (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun clearAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -58,20 +119,12 @@ public abstract interface class com/dropbox/android/external/store4/StoreBuilder public abstract fun build ()Lcom/dropbox/android/external/store4/Store; public abstract fun cachePolicy (Lcom/dropbox/android/external/store4/MemoryPolicy;)Lcom/dropbox/android/external/store4/StoreBuilder; public abstract fun disableCache ()Lcom/dropbox/android/external/store4/StoreBuilder; - public abstract fun nonFlowingPersister (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lcom/dropbox/android/external/store4/StoreBuilder; - public abstract fun nonFlowingPersisterLegacy (Lcom/dropbox/android/external/store4/Persister;)Lcom/dropbox/android/external/store4/StoreBuilder; - public abstract fun persister (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lcom/dropbox/android/external/store4/StoreBuilder; public abstract fun scope (Lkotlinx/coroutines/CoroutineScope;)Lcom/dropbox/android/external/store4/StoreBuilder; } public final class com/dropbox/android/external/store4/StoreBuilder$Companion { public final fun from (Lkotlin/jvm/functions/Function1;)Lcom/dropbox/android/external/store4/StoreBuilder; - public final fun fromNonFlow (Lkotlin/jvm/functions/Function2;)Lcom/dropbox/android/external/store4/StoreBuilder; -} - -public final class com/dropbox/android/external/store4/StoreBuilder$DefaultImpls { - public static synthetic fun nonFlowingPersister$default (Lcom/dropbox/android/external/store4/StoreBuilder;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/dropbox/android/external/store4/StoreBuilder; - public static synthetic fun persister$default (Lcom/dropbox/android/external/store4/StoreBuilder;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/dropbox/android/external/store4/StoreBuilder; + public final fun from (Lkotlin/jvm/functions/Function1;Lcom/dropbox/android/external/store4/SourceOfTruth;)Lcom/dropbox/android/external/store4/StoreBuilder; } public final class com/dropbox/android/external/store4/StoreKt { @@ -101,7 +154,7 @@ public final class com/dropbox/android/external/store4/StoreRequest$Companion { public abstract class com/dropbox/android/external/store4/StoreResponse { public final fun dataOrNull ()Ljava/lang/Object; - public final fun errorOrNull ()Ljava/lang/Throwable; + public final fun errorMessageOrNull ()Ljava/lang/String; public abstract fun getOrigin ()Lcom/dropbox/android/external/store4/ResponseOrigin; public final fun requireData ()Ljava/lang/Object; public final fun throwIfError ()V @@ -120,12 +173,15 @@ public final class com/dropbox/android/external/store4/StoreResponse$Data : com/ public fun toString ()Ljava/lang/String; } -public final class com/dropbox/android/external/store4/StoreResponse$Error : com/dropbox/android/external/store4/StoreResponse { +public abstract class com/dropbox/android/external/store4/StoreResponse$Error : com/dropbox/android/external/store4/StoreResponse { +} + +public final class com/dropbox/android/external/store4/StoreResponse$Error$Exception : com/dropbox/android/external/store4/StoreResponse$Error { public fun (Ljava/lang/Throwable;Lcom/dropbox/android/external/store4/ResponseOrigin;)V public final fun component1 ()Ljava/lang/Throwable; public final fun component2 ()Lcom/dropbox/android/external/store4/ResponseOrigin; - public final fun copy (Ljava/lang/Throwable;Lcom/dropbox/android/external/store4/ResponseOrigin;)Lcom/dropbox/android/external/store4/StoreResponse$Error; - public static synthetic fun copy$default (Lcom/dropbox/android/external/store4/StoreResponse$Error;Ljava/lang/Throwable;Lcom/dropbox/android/external/store4/ResponseOrigin;ILjava/lang/Object;)Lcom/dropbox/android/external/store4/StoreResponse$Error; + public final fun copy (Ljava/lang/Throwable;Lcom/dropbox/android/external/store4/ResponseOrigin;)Lcom/dropbox/android/external/store4/StoreResponse$Error$Exception; + public static synthetic fun copy$default (Lcom/dropbox/android/external/store4/StoreResponse$Error$Exception;Ljava/lang/Throwable;Lcom/dropbox/android/external/store4/ResponseOrigin;ILjava/lang/Object;)Lcom/dropbox/android/external/store4/StoreResponse$Error$Exception; public fun equals (Ljava/lang/Object;)Z public final fun getError ()Ljava/lang/Throwable; public fun getOrigin ()Lcom/dropbox/android/external/store4/ResponseOrigin; @@ -133,6 +189,19 @@ public final class com/dropbox/android/external/store4/StoreResponse$Error : com public fun toString ()Ljava/lang/String; } +public final class com/dropbox/android/external/store4/StoreResponse$Error$Message : com/dropbox/android/external/store4/StoreResponse$Error { + public fun (Ljava/lang/String;Lcom/dropbox/android/external/store4/ResponseOrigin;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Lcom/dropbox/android/external/store4/ResponseOrigin; + public final fun copy (Ljava/lang/String;Lcom/dropbox/android/external/store4/ResponseOrigin;)Lcom/dropbox/android/external/store4/StoreResponse$Error$Message; + public static synthetic fun copy$default (Lcom/dropbox/android/external/store4/StoreResponse$Error$Message;Ljava/lang/String;Lcom/dropbox/android/external/store4/ResponseOrigin;ILjava/lang/Object;)Lcom/dropbox/android/external/store4/StoreResponse$Error$Message; + public fun equals (Ljava/lang/Object;)Z + public final fun getMessage ()Ljava/lang/String; + public fun getOrigin ()Lcom/dropbox/android/external/store4/ResponseOrigin; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class com/dropbox/android/external/store4/StoreResponse$Loading : com/dropbox/android/external/store4/StoreResponse { public fun (Lcom/dropbox/android/external/store4/ResponseOrigin;)V public final fun component1 ()Lcom/dropbox/android/external/store4/ResponseOrigin; @@ -144,6 +213,10 @@ public final class com/dropbox/android/external/store4/StoreResponse$Loading : c public fun toString ()Ljava/lang/String; } +public final class com/dropbox/android/external/store4/StoreResponseKt { + public static final fun doThrow (Lcom/dropbox/android/external/store4/StoreResponse$Error;)Ljava/lang/Void; +} + public final class com/dropbox/android/external/store4/legacy/BarCode { public fun (Ljava/lang/String;Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; diff --git a/store/src/main/java/com/dropbox/android/external/store4/Fetcher.kt b/store/src/main/java/com/dropbox/android/external/store4/Fetcher.kt new file mode 100644 index 000000000..85f6c2fbc --- /dev/null +++ b/store/src/main/java/com/dropbox/android/external/store4/Fetcher.kt @@ -0,0 +1,102 @@ +package com.dropbox.android.external.store4 + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map + +sealed class FetcherResult { + data class Data(val value: T) : FetcherResult() + sealed class Error : FetcherResult() { + data class Exception(val error: Throwable) : Error() + data class Message(val message: String) : Error() + } +} + +/** + * Fetcher is used by [Store] to fetch network records for a given key. The return type is [Flow] to + * allow for multiple result per request. + * + * Note: Store does not catch exceptions thrown by a [Fetcher]. This is done in order to avoid + * silently swallowing NPEs and such. Use [FetcherResult.Error] to communicate expected errors. + * + * See [nonFlowFetcher] for easily translating from a regular `suspend` function. + * See [valueFetcher], [nonFlowValueFetcher] for easily translating to [FetcherResult] (and + * automatically transforming exceptions into [FetcherResult.Error]. + */ +typealias Fetcher = (key: Key) -> Flow> + +/** + * "Creates" a [Fetcher] from a [flowFactory]. + * + * Use when creating a [Store] that fetches objects in a multiple responses per request + * network protocol (e.g Web Sockets). + * + * [Store] does not catch exception thrown in [flowFactory] or in the returned [Flow]. These + * exception will be propagated to the caller. + * + * @param flowFactory a factory for a [Flow]ing source of network records. + */ +fun fetcher( + flowFactory: (Key) -> Flow> +): Fetcher = flowFactory + +/** + * "Creates" a [Fetcher] from a non-[Flow] source. + * + * Use when creating a [Store] that fetches objects in a single response per request network + * protocol (e.g Http). + * + * [Store] does not catch exception thrown in [doFetch]. These exception will be propagated to the + * caller. + * + * @param doFetch a source of network records. + */ +fun nonFlowFetcher( + doFetch: suspend (Key) -> FetcherResult +): Fetcher = doFetch.asFlow() + +/** + * "Creates" a [Fetcher] from a [flowFactory] and translate the results to a [FetcherResult]. + * + * Emitted values will be wrapped in [FetcherResult.Data]. if an exception disrupts the flow then + * it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [flowFactory] itself are not + * caught and will be returned to the caller. + * + * Use when creating a [Store] that fetches objects in a multiple responses per request + * network protocol (e.g Web Sockets). + * + * @param flowFactory a factory for a [Flow]ing source of network records. + */ +@ExperimentalCoroutinesApi +fun valueFetcher( + flowFactory: (Key) -> Flow +): Fetcher = { key: Key -> + flowFactory(key).map { FetcherResult.Data(it) as FetcherResult } + .catch { th: Throwable -> + emit(FetcherResult.Error.Exception(th)) + } +} + +/** + * "Creates" a [Fetcher] from a non-[Flow] source and translate the results to a [FetcherResult]. + * + * Emitted values will be wrapped in [FetcherResult.Data]. if an exception disrupts the flow then + * it will be wrapped in [FetcherResult.Error] + * + * Use when creating a [Store] that fetches objects in a single response per request + * network protocol (e.g Http). + * + * @param doFetch a source of network records. + */ +@ExperimentalCoroutinesApi +fun nonFlowValueFetcher( + doFetch: suspend (key: Key) -> Output +): Fetcher = valueFetcher(doFetch.asFlow()) + +private fun (suspend (key: Key) -> Value).asFlow() = { key: Key -> + flow { + emit(invoke(key)) + } +} diff --git a/store/src/main/java/com/dropbox/android/external/store4/SourceOfTruth.kt b/store/src/main/java/com/dropbox/android/external/store4/SourceOfTruth.kt new file mode 100644 index 000000000..26fb7d84e --- /dev/null +++ b/store/src/main/java/com/dropbox/android/external/store4/SourceOfTruth.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.dropbox.android.external.store4 + +import com.dropbox.android.external.store4.impl.PersistentNonFlowingSourceOfTruth +import com.dropbox.android.external.store4.impl.PersistentSourceOfTruth +import kotlinx.coroutines.flow.Flow + +/** + * + * [SourceOfTruth], as name implies, is the persistence API which [Store] uses to serve values to + * the collectors. If provided, [Store] will only return values received from [SourceOfTruth] back + * to the collectors. + * + * In other words, values coming from the [Fetcher] will always be sent to the [SourceOfTruth] + * and will be read back via [reader] to be returned to the collector. + * + * This round-trip ensures the data is consistent across the application in case the [Fetcher] may + * not return all fields or return a different class type than the app uses. It is particularly + * useful if your application has a local observable database which is directly modified by the app + * as Store can observe these changes and update the collectors even before value is synced to the + * backend. + * + * Source of truth takes care of making any source (no matter if it has flowing reads or not) into + * a common flowing API. + * + * A source of truth is usually backed by local storage. It's purpose is to eliminate the need + * for waiting on network update before local modifications are available (via [Store.stream]). + * + * For maximal flexibility, [writer]'s record type ([Input]] and [reader]'s record type + * ([Output]) are not identical. This allows us to read one type of objects from network and + * transform them to another type when placing them in local storage. + * + * A source of truth is usually backed by local storage. It's purpose is to eliminate the need + * for waiting on network update before local modifications are available (via [Store.stream]). + * + */ +interface SourceOfTruth { + + /** + * Used by [Store] to read records from the source of truth. + * + * @param key The key to read for. + */ + fun reader(key: Key): Flow + + /** + * Used by [Store] to write records **coming in from the fetcher (network)** to the source of + * truth. + * + * **Note:** [Store] currently does not support updating the source of truth with local user + * updates (i.e writing record of type [Output]). However, any changes in the local database + * will still be visible via [Store.stream] APIs as long as you are using a local storage that + * supports observability (e.g. Room, SQLDelight, Realm). + * + * @param key The key to update for. + */ + suspend fun write(key: Key, value: Input) + + /** + * Used by [Store] to delete records in the source of truth for the given key. + * + * @param key The key to delete for. + */ + suspend fun delete(key: Key) + + /** + * Used by [Store] to delete all records in the source of truth. + */ + suspend fun deleteAll() + + companion object { + /** + * Creates a (non-[Flow]) source of truth that is accessible via [reader], [writer], + * [delete] and [deleteAll]. + * + * @param reader function for reading records from the source of truth + * @param writer function for writing updates to the backing source of truth + * @param delete function for deleting records in the source of truth for the given key + * @param deleteAll function for deleting all records in the source of truth + */ + fun fromNonFlow( + reader: suspend (Key) -> Output?, + writer: suspend (Key, Input) -> Unit, + delete: (suspend (Key) -> Unit)? = null, + deleteAll: (suspend () -> Unit)? = null + ): SourceOfTruth = PersistentNonFlowingSourceOfTruth( + realReader = reader, + realWriter = writer, + realDelete = delete, + realDeleteAll = deleteAll + ) + + /** + * Creates a ([Flow]) source of truth that is accessed via [reader], [writer], [delete] and + * [deleteAll]. + * + * @param reader function for reading records from the source of truth + * @param writer function for writing updates to the backing source of truth + * @param delete function for deleting records in the source of truth for the given key + * @param deleteAll function for deleting all records in the source of truth + * + */ + fun from( + reader: (Key) -> Flow, + writer: suspend (Key, Input) -> Unit, + delete: (suspend (Key) -> Unit)? = null, + deleteAll: (suspend () -> Unit)? = null + ): SourceOfTruth = PersistentSourceOfTruth( + realReader = reader, + realWriter = writer, + realDelete = delete, + realDeleteAll = deleteAll + ) + } +} diff --git a/store/src/main/java/com/dropbox/android/external/store4/StoreBuilder.kt b/store/src/main/java/com/dropbox/android/external/store4/StoreBuilder.kt index 2e5638617..558d619c2 100644 --- a/store/src/main/java/com/dropbox/android/external/store4/StoreBuilder.kt +++ b/store/src/main/java/com/dropbox/android/external/store4/StoreBuilder.kt @@ -15,16 +15,11 @@ */ package com.dropbox.android.external.store4 -import com.dropbox.android.external.store4.impl.PersistentNonFlowingSourceOfTruth -import com.dropbox.android.external.store4.impl.PersistentSourceOfTruth import com.dropbox.android.external.store4.impl.RealStore -import com.dropbox.android.external.store4.impl.SourceOfTruth import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow import kotlin.time.ExperimentalTime /** @@ -56,213 +51,55 @@ interface StoreBuilder { */ fun disableCache(): StoreBuilder - /** - * Connects a (non-[Flow]) source of truth that is accessible via [reader], [writer], - * [delete], and [deleteAll]. - * - * @see persister - */ - fun nonFlowingPersister( - reader: suspend (Key) -> NewOutput?, - writer: suspend (Key, Output) -> Unit, - delete: (suspend (Key) -> Unit)? = null, - deleteAll: (suspend () -> Unit)? = null - ): StoreBuilder - - /** - * Connects a ([kotlinx.coroutines.flow.Flow]) source of truth that is accessed via [reader], [writer] and [delete]. - * - * A source of truth is usually backed by local storage. It's purpose is to eliminate the need - * for waiting on network update before local modifications are available (via [Store.stream]). - * - * @param [com.dropbox.android.external.store4.Persister] reads records from the source of truth - * WARNING: Delete operation is not supported when using a legacy [com.dropbox.android.external.store4.Persister], - * please use another override - */ - fun nonFlowingPersisterLegacy( - persister: Persister - ): StoreBuilder - - /** - * Connects a ([kotlinx.coroutines.flow.Flow]) source of truth that is accessed via [reader], [writer] and [delete]. - * - * For maximal flexibility, [writer]'s record type ([Output]] and [reader]'s record type - * ([NewOutput]) are not identical. This allows us to read one type of objects from network and - * transform them to another type when placing them in local storage. - * - * A source of truth is usually backed by local storage. It's purpose is to eliminate the need - * for waiting on network update before local modifications are available (via [Store.stream]). - * - * @param reader reads records from the source of truth - * @param writer writes records **coming in from the fetcher (network)** to the source of truth. - * Writing local user updates to the source of truth via [Store] is currently not supported. - * @param delete deletes records in the source of truth for the give key - * @param deleteAll deletes all records in the source of truth - * - */ - fun persister( - reader: (Key) -> Flow, - writer: suspend (Key, Output) -> Unit, - delete: (suspend (Key) -> Unit)? = null, - deleteAll: (suspend () -> Unit)? = null - ): StoreBuilder - companion object { + /** - * Creates a new [StoreBuilder] from a non-[Flow] fetcher. + * Creates a new [StoreBuilder] from a [Fetcher]. * - * Use when creating a [Store] that fetches objects in an HTTP-like single response per - * request protocol. - * - * @param fetcher a function for fetching network records. + * @param fetcher a [Fetcher] flow of network records. */ @OptIn(ExperimentalTime::class) - fun fromNonFlow( - fetcher: suspend (key: Key) -> Output - ): StoreBuilder = BuilderImpl { key: Key -> - flow { - emit(fetcher(key)) - } - } + fun from( + fetcher: Fetcher + ): StoreBuilder = RealStoreBuilder(fetcher) /** - * Creates a new [StoreBuilder] from a [Flow] fetcher. - * - * Use when creating a [Store] that fetches objects in an websocket-like multiple responses - * per request protocol. + * Creates a new [StoreBuilder] from a [Fetcher] and a [SourceOfTruth]. * * @param fetcher a function for fetching a flow of network records. + * @param sourceOfTruth a [SourceOfTruth] for the store. */ - @OptIn(ExperimentalTime::class) - fun from( - fetcher: (key: Key) -> Flow - ): StoreBuilder = BuilderImpl(fetcher) - } -} - -@FlowPreview -@OptIn(ExperimentalTime::class) -@ExperimentalCoroutinesApi -private class BuilderImpl( - private val fetcher: (key: Key) -> Flow -) : StoreBuilder { - private var scope: CoroutineScope? = null - private var cachePolicy: MemoryPolicy? = StoreDefaults.memoryPolicy - - private fun withSourceOfTruth( - sourceOfTruth: SourceOfTruth? = null - ) = BuilderWithSourceOfTruth(fetcher, sourceOfTruth).let { builder -> - if (cachePolicy == null) { - builder.disableCache() - } else { - builder.cachePolicy(cachePolicy) - } - }.let { builder -> - scope?.let { - builder.scope(it) - } ?: builder - } - - private fun withLegacySourceOfTruth( - sourceOfTruth: PersistentNonFlowingSourceOfTruth - ) = BuilderWithSourceOfTruth(fetcher, sourceOfTruth).let { builder -> - if (cachePolicy == null) { - builder.disableCache() - } else { - builder.cachePolicy(cachePolicy) - } - }.let { builder -> - scope?.let { - builder.scope(it) - } ?: builder - } - - override fun scope(scope: CoroutineScope): BuilderImpl { - this.scope = scope - return this - } - - override fun cachePolicy(memoryPolicy: MemoryPolicy?): BuilderImpl { - cachePolicy = memoryPolicy - return this - } - - override fun disableCache(): BuilderImpl { - cachePolicy = null - return this - } - - override fun nonFlowingPersister( - reader: suspend (Key) -> NewOutput?, - writer: suspend (Key, Output) -> Unit, - delete: (suspend (Key) -> Unit)?, - deleteAll: (suspend () -> Unit)? - ): BuilderWithSourceOfTruth { - return withSourceOfTruth( - PersistentNonFlowingSourceOfTruth( - realReader = reader, - realWriter = writer, - realDelete = delete, - realDeleteAll = deleteAll - ) - ) - } - - override fun nonFlowingPersisterLegacy( - persister: Persister - ): BuilderWithSourceOfTruth { - val sourceOfTruth: PersistentNonFlowingSourceOfTruth = - PersistentNonFlowingSourceOfTruth( - realReader = { key -> persister.read(key) }, - realWriter = { key, input -> persister.write(key, input) }, - realDelete = { error("Delete is not implemented in legacy persisters") }, - realDeleteAll = { error("Delete all is not implemented in legacy persisters") } - ) - return withLegacySourceOfTruth(sourceOfTruth) - } - - override fun persister( - reader: (Key) -> Flow, - writer: suspend (Key, Output) -> Unit, - delete: (suspend (Key) -> Unit)?, - deleteAll: (suspend () -> Unit)? - ): BuilderWithSourceOfTruth { - return withSourceOfTruth( - PersistentSourceOfTruth( - realReader = reader, - realWriter = writer, - realDelete = delete, - realDeleteAll = deleteAll - ) + fun from( + fetcher: Fetcher, + sourceOfTruth: SourceOfTruth + ): StoreBuilder = RealStoreBuilder( + fetcher = fetcher, + sourceOfTruth = sourceOfTruth ) } - - override fun build(): Store { - return withSourceOfTruth().build() - } } @FlowPreview @OptIn(ExperimentalTime::class) @ExperimentalCoroutinesApi -private class BuilderWithSourceOfTruth( - private val fetcher: (key: Key) -> Flow, +private class RealStoreBuilder( + private val fetcher: Fetcher, private val sourceOfTruth: SourceOfTruth? = null ) : StoreBuilder { private var scope: CoroutineScope? = null private var cachePolicy: MemoryPolicy? = StoreDefaults.memoryPolicy - override fun scope(scope: CoroutineScope): BuilderWithSourceOfTruth { + override fun scope(scope: CoroutineScope): RealStoreBuilder { this.scope = scope return this } - override fun cachePolicy(memoryPolicy: MemoryPolicy?): BuilderWithSourceOfTruth { + override fun cachePolicy(memoryPolicy: MemoryPolicy?): RealStoreBuilder { cachePolicy = memoryPolicy return this } - override fun disableCache(): BuilderWithSourceOfTruth { + override fun disableCache(): RealStoreBuilder { cachePolicy = null return this } @@ -276,21 +113,4 @@ private class BuilderWithSourceOfTruth( memoryPolicy = cachePolicy ) } - - override fun persister( - reader: (Key) -> Flow, - writer: suspend (Key, Output) -> Unit, - delete: (suspend (Key) -> Unit)?, - deleteAll: (suspend () -> Unit)? - ): StoreBuilder = error("Multiple persisters are not supported") - - override fun nonFlowingPersister( - reader: suspend (Key) -> NewOutput?, - writer: suspend (Key, Output) -> Unit, - delete: (suspend (Key) -> Unit)?, - deleteAll: (suspend () -> Unit)? - ): StoreBuilder = error("Multiple persisters are not supported") - - override fun nonFlowingPersisterLegacy(persister: Persister): StoreBuilder = - error("Multiple persisters are not supported") } diff --git a/store/src/main/java/com/dropbox/android/external/store4/StoreResponse.kt b/store/src/main/java/com/dropbox/android/external/store4/StoreResponse.kt index f474bd1a4..a89a016fd 100644 --- a/store/src/main/java/com/dropbox/android/external/store4/StoreResponse.kt +++ b/store/src/main/java/com/dropbox/android/external/store4/StoreResponse.kt @@ -41,8 +41,17 @@ sealed class StoreResponse { /** * Error dispatched by a pipeline */ - data class Error(val error: Throwable, override val origin: ResponseOrigin) : - StoreResponse() + sealed class Error : StoreResponse() { + data class Exception( + val error: Throwable, + override val origin: ResponseOrigin + ) : Error() + + data class Message( + val message: String, + override val origin: ResponseOrigin + ) : Error() + } /** * Returns the available data or throws [NullPointerException] if there is no data. @@ -50,7 +59,7 @@ sealed class StoreResponse { fun requireData(): T { return when (this) { is Data -> value - is Error -> throw error + is Error -> this.doThrow() else -> throw NullPointerException("there is no data in $this") } } @@ -61,7 +70,7 @@ sealed class StoreResponse { */ fun throwIfError() { if (this is Error) { - throw error + this.doThrow() } } @@ -69,8 +78,12 @@ sealed class StoreResponse { * If this [StoreResponse] is of type [StoreResponse.Error], returns the available error * from it. Otherwise, returns `null`. */ - fun errorOrNull(): Throwable? { - return (this as? Error)?.error + fun errorMessageOrNull(): String? { + return when (this) { + is Error.Message -> message + is Error.Exception -> error.localizedMessage ?: "exception: ${error.javaClass}" + else -> null + } } /** @@ -81,10 +94,11 @@ sealed class StoreResponse { else -> null } + @Suppress("UNCHECKED_CAST") internal fun swapType(): StoreResponse = when (this) { - is Error -> Error(error, origin) - is Loading -> Loading(origin) - is Data -> throw IllegalStateException("cannot swap type for StoreResponse.Data") + is Error -> this as Error + is Loading -> this as Loading + is Data -> throw RuntimeException("cannot swap type for StoreResponse.Data") } } @@ -96,12 +110,19 @@ enum class ResponseOrigin { * [StoreResponse] is sent from the cache */ Cache, + /** * [StoreResponse] is sent from the persister */ - Persister, + SourceOfTruth, + /** * [StoreResponse] is sent from a fetcher, */ Fetcher } + +fun StoreResponse.Error.doThrow(): Nothing = when (this) { + is StoreResponse.Error.Exception -> throw error + is StoreResponse.Error.Message -> throw RuntimeException(message) +} diff --git a/store/src/main/java/com/dropbox/android/external/store4/impl/FetcherController.kt b/store/src/main/java/com/dropbox/android/external/store4/impl/FetcherController.kt index 702d9cf42..d3038bf14 100644 --- a/store/src/main/java/com/dropbox/android/external/store4/impl/FetcherController.kt +++ b/store/src/main/java/com/dropbox/android/external/store4/impl/FetcherController.kt @@ -15,14 +15,16 @@ */ package com.dropbox.android.external.store4.impl +import com.dropbox.android.external.store4.Fetcher +import com.dropbox.android.external.store4.FetcherResult import com.dropbox.android.external.store4.ResponseOrigin +import com.dropbox.android.external.store4.SourceOfTruth import com.dropbox.android.external.store4.StoreResponse import com.dropbox.flow.multicast.Multicaster import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map @@ -37,7 +39,7 @@ import kotlinx.coroutines.flow.map */ @FlowPreview @ExperimentalCoroutinesApi -internal class FetcherController( +internal class FetcherController( /** * The [CoroutineScope] to use when collecting from the fetcher */ @@ -45,7 +47,7 @@ internal class FetcherController( /** * The function that provides the actualy fetcher flow when needed */ - private val realFetcher: (Key) -> Flow, + private val realFetcher: Fetcher, /** * [SourceOfTruth] to send the data each time fetcher dispatches a value. Can be `null` if * no [SourceOfTruth] is available. @@ -64,12 +66,20 @@ internal class FetcherController( scope = scope, bufferSize = 0, source = flow { emitAll(realFetcher(key)) }.map { - StoreResponse.Data( - it, - origin = ResponseOrigin.Fetcher - ) as StoreResponse - }.catch { - emit(StoreResponse.Error(it, origin = ResponseOrigin.Fetcher)) + when (it) { + is FetcherResult.Data -> StoreResponse.Data( + it.value, + origin = ResponseOrigin.Fetcher + ) as StoreResponse + is FetcherResult.Error.Message -> StoreResponse.Error.Message( + it.message, + origin = ResponseOrigin.Fetcher + ) + is FetcherResult.Error.Exception -> StoreResponse.Error.Exception( + it.error, + origin = ResponseOrigin.Fetcher + ) + } }, piggybackingDownstream = enablePiggyback, onEach = { response -> diff --git a/store/src/main/java/com/dropbox/android/external/store4/impl/SourceOfTruth.kt b/store/src/main/java/com/dropbox/android/external/store4/impl/RealSourceOfTruth.kt similarity index 70% rename from store/src/main/java/com/dropbox/android/external/store4/impl/SourceOfTruth.kt rename to store/src/main/java/com/dropbox/android/external/store4/impl/RealSourceOfTruth.kt index cdc817e1e..ba498a539 100644 --- a/store/src/main/java/com/dropbox/android/external/store4/impl/SourceOfTruth.kt +++ b/store/src/main/java/com/dropbox/android/external/store4/impl/RealSourceOfTruth.kt @@ -15,31 +15,16 @@ */ package com.dropbox.android.external.store4.impl -import com.dropbox.android.external.store4.ResponseOrigin +import com.dropbox.android.external.store4.SourceOfTruth import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow -/** - * Source of truth takes care of making any source (no matter if it has flowing reads or not) into - * a common flowing API. Used w/ a [SourceOfTruthWithBarrier] in front of it in the - * [RealStore] implementation to avoid dispatching values to downstream while - * a write is in progress. - */ -internal interface SourceOfTruth { - val defaultOrigin: ResponseOrigin - fun reader(key: Key): Flow - suspend fun write(key: Key, value: Input) - suspend fun delete(key: Key) - suspend fun deleteAll() -} - internal class PersistentSourceOfTruth( private val realReader: (Key) -> Flow, private val realWriter: suspend (Key, Input) -> Unit, private val realDelete: (suspend (Key) -> Unit)? = null, private val realDeleteAll: (suspend () -> Unit)? = null ) : SourceOfTruth { - override val defaultOrigin = ResponseOrigin.Persister override fun reader(key: Key): Flow = realReader(key) @@ -60,11 +45,11 @@ internal class PersistentNonFlowingSourceOfTruth( private val realDelete: (suspend (Key) -> Unit)? = null, private val realDeleteAll: (suspend () -> Unit)? ) : SourceOfTruth { - override val defaultOrigin = ResponseOrigin.Persister - override fun reader(key: Key): Flow = flow { - emit(realReader(key)) - } + override fun reader(key: Key): Flow = + flow { + emit(realReader(key)) + } override suspend fun write(key: Key, value: Input) = realWriter(key, value) diff --git a/store/src/main/java/com/dropbox/android/external/store4/impl/RealStore.kt b/store/src/main/java/com/dropbox/android/external/store4/impl/RealStore.kt index 0952db269..2218f59dd 100644 --- a/store/src/main/java/com/dropbox/android/external/store4/impl/RealStore.kt +++ b/store/src/main/java/com/dropbox/android/external/store4/impl/RealStore.kt @@ -18,8 +18,10 @@ package com.dropbox.android.external.store4.impl import com.dropbox.android.external.cache4.Cache import com.dropbox.android.external.store4.CacheType import com.dropbox.android.external.store4.ExperimentalStoreApi +import com.dropbox.android.external.store4.Fetcher import com.dropbox.android.external.store4.MemoryPolicy import com.dropbox.android.external.store4.ResponseOrigin +import com.dropbox.android.external.store4.SourceOfTruth import com.dropbox.android.external.store4.Store import com.dropbox.android.external.store4.StoreRequest import com.dropbox.android.external.store4.StoreResponse @@ -43,7 +45,7 @@ import kotlin.time.ExperimentalTime @FlowPreview internal class RealStore( scope: CoroutineScope, - fetcher: (Key) -> Flow, + fetcher: Fetcher, sourceOfTruth: SourceOfTruth? = null, private val memoryPolicy: MemoryPolicy? ) : Store { diff --git a/store/src/main/java/com/dropbox/android/external/store4/impl/SourceOfTruthWithBarrier.kt b/store/src/main/java/com/dropbox/android/external/store4/impl/SourceOfTruthWithBarrier.kt index 4e35fd883..4162b452d 100644 --- a/store/src/main/java/com/dropbox/android/external/store4/impl/SourceOfTruthWithBarrier.kt +++ b/store/src/main/java/com/dropbox/android/external/store4/impl/SourceOfTruthWithBarrier.kt @@ -16,6 +16,7 @@ package com.dropbox.android.external.store4.impl import com.dropbox.android.external.store4.ResponseOrigin +import com.dropbox.android.external.store4.SourceOfTruth import com.dropbox.android.external.store4.impl.operators.mapIndexed import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -31,6 +32,9 @@ import java.util.concurrent.atomic.AtomicLong /** * Wraps a [SourceOfTruth] and blocks reads while a write is in progress. + * + * Used in the [com.dropbox.android.external.store4.impl.RealStore] implementation to avoid + * dispatching values to downstream while a write is in progress. */ @FlowPreview @ExperimentalCoroutinesApi @@ -70,7 +74,7 @@ internal class SourceOfTruthWithBarrier( ) } else { DataWithOrigin( - origin = delegate.defaultOrigin, + origin = ResponseOrigin.SourceOfTruth, value = output ) } diff --git a/store/src/test/java/com/dropbox/android/external/store3/DontCacheErrorsTest.kt b/store/src/test/java/com/dropbox/android/external/store3/DontCacheErrorsTest.kt index 96d687c2d..160bc7942 100644 --- a/store/src/test/java/com/dropbox/android/external/store3/DontCacheErrorsTest.kt +++ b/store/src/test/java/com/dropbox/android/external/store3/DontCacheErrorsTest.kt @@ -2,6 +2,7 @@ package com.dropbox.android.external.store3 import com.dropbox.android.external.store4.get import com.dropbox.android.external.store4.legacy.BarCode +import com.dropbox.android.external.store4.nonFlowValueFetcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.test.TestCoroutineScope @@ -19,14 +20,17 @@ class DontCacheErrorsTest( ) { private val testScope = TestCoroutineScope() private var shouldThrow: Boolean = false + // TODO move to test coroutine scope - private val store = TestStoreBuilder.from(testScope) { - if (shouldThrow) { - throw RuntimeException() - } else { - 0 - } - }.build(storeType) + private val store = TestStoreBuilder.from( + testScope, + fetcher = nonFlowValueFetcher { + if (shouldThrow) { + throw RuntimeException() + } else { + 0 + } + }).build(storeType) @Test fun testStoreDoesntCacheErrors() = testScope.runBlockingTest { diff --git a/store/src/test/java/com/dropbox/android/external/store3/NoNetworkTest.kt b/store/src/test/java/com/dropbox/android/external/store3/NoNetworkTest.kt index d678ccd95..a2db5ebf2 100644 --- a/store/src/test/java/com/dropbox/android/external/store3/NoNetworkTest.kt +++ b/store/src/test/java/com/dropbox/android/external/store3/NoNetworkTest.kt @@ -3,11 +3,12 @@ package com.dropbox.android.external.store3 import com.dropbox.android.external.store4.Store import com.dropbox.android.external.store4.get import com.dropbox.android.external.store4.legacy.BarCode +import com.dropbox.android.external.store4.nonFlowValueFetcher +import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.test.TestCoroutineScope import kotlinx.coroutines.test.runBlockingTest -import com.google.common.truth.Truth.assertThat import org.junit.Assert.fail import org.junit.Test import org.junit.runner.RunWith @@ -20,9 +21,11 @@ class NoNetworkTest( storeType: TestStoreType ) { private val testScope = TestCoroutineScope() - private val store: Store = TestStoreBuilder.from(testScope) { - throw EXCEPTION - }.build(storeType) + private val store: Store = TestStoreBuilder.from( + testScope, + fetcher = nonFlowValueFetcher { + throw EXCEPTION + }).build(storeType) @Test fun testNoNetwork() = testScope.runBlockingTest { diff --git a/store/src/test/java/com/dropbox/android/external/store3/SequentialTest.kt b/store/src/test/java/com/dropbox/android/external/store3/SequentialTest.kt index a77c3cfa7..6c67fa0eb 100644 --- a/store/src/test/java/com/dropbox/android/external/store3/SequentialTest.kt +++ b/store/src/test/java/com/dropbox/android/external/store3/SequentialTest.kt @@ -2,12 +2,13 @@ package com.dropbox.android.external.store3 import com.dropbox.android.external.store4.get import com.dropbox.android.external.store4.legacy.BarCode +import com.dropbox.android.external.store4.nonFlowValueFetcher +import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.async import kotlinx.coroutines.test.TestCoroutineScope import kotlinx.coroutines.test.runBlockingTest -import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized @@ -23,10 +24,10 @@ class SequentialTest( var networkCalls = 0 private val store = TestStoreBuilder.from( scope = testScope, - cached = true - ) { + cached = true, + fetcher = nonFlowValueFetcher { networkCalls++ - }.build(storeType) + }).build(storeType) @Test fun sequentially() = testScope.runBlockingTest { diff --git a/store/src/test/java/com/dropbox/android/external/store3/StoreTest.kt b/store/src/test/java/com/dropbox/android/external/store3/StoreTest.kt index c9e3e7566..20d5415a7 100644 --- a/store/src/test/java/com/dropbox/android/external/store3/StoreTest.kt +++ b/store/src/test/java/com/dropbox/android/external/store3/StoreTest.kt @@ -2,6 +2,7 @@ package com.dropbox.android.external.store3 import com.dropbox.android.external.cache4.Cache import com.dropbox.android.external.store4.Fetcher +import com.dropbox.android.external.store4.FetcherResult import com.dropbox.android.external.store4.Persister import com.dropbox.android.external.store4.fresh import com.dropbox.android.external.store4.get @@ -9,7 +10,6 @@ import com.dropbox.android.external.store4.legacy.BarCode import com.google.common.truth.Truth.assertThat import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.doReturn -import com.nhaarman.mockitokotlin2.doThrow import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.never import com.nhaarman.mockitokotlin2.times @@ -18,6 +18,7 @@ import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.async +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestCoroutineScope import kotlinx.coroutines.test.runBlockingTest import org.junit.Assert.fail @@ -37,7 +38,7 @@ class StoreTest( ) { private val testScope = TestCoroutineScope() private val counter = AtomicInteger(0) - private val fetcher: Fetcher = mock() + private val fetcher: Fetcher = mock() private var persister: Persister = mock() private val barCode = BarCode("key", "value") @@ -50,7 +51,7 @@ class StoreTest( ).build(storeType) whenever(fetcher.invoke(barCode)) - .thenReturn(NETWORK) + .thenReturn(flowOf(FetcherResult.Data(NETWORK))) whenever(persister.read(barCode)) .thenReturn(null) @@ -77,9 +78,9 @@ class StoreTest( whenever(fetcher.invoke(barCode)) .thenAnswer { if (counter.incrementAndGet() == 1) { - NETWORK + flowOf(FetcherResult.Data(NETWORK)) } else { - throw RuntimeException("Yo Dawg your inflight is broken") + flowOf(FetcherResult.Error.Message("Yo Dawg your inflight is broken")) } } @@ -109,7 +110,7 @@ class StoreTest( simpleStore.clear(barCode) whenever(fetcher.invoke(barCode)) - .thenReturn(NETWORK) + .thenReturn(flowOf(FetcherResult.Data(NETWORK))) whenever(persister.read(barCode)) .thenReturn(null) @@ -146,7 +147,8 @@ class StoreTest( persister = persister ).build(storeType) - whenever(fetcher.invoke(barCode)) doThrow RuntimeException(ERROR) + whenever(fetcher.invoke(barCode)) doReturn + flowOf(FetcherResult.Error.Message(ERROR)) whenever(persister.read(barCode)) doReturn DISK diff --git a/store/src/test/java/com/dropbox/android/external/store3/StoreThrowOnNoItems.kt b/store/src/test/java/com/dropbox/android/external/store3/StoreThrowOnNoItems.kt index dc2412465..2d270f26a 100644 --- a/store/src/test/java/com/dropbox/android/external/store3/StoreThrowOnNoItems.kt +++ b/store/src/test/java/com/dropbox/android/external/store3/StoreThrowOnNoItems.kt @@ -25,7 +25,7 @@ class StoreThrowOnNoItems( ) { private val testScope = TestCoroutineScope() private val counter = AtomicInteger(0) - private val fetcher: Fetcher = mock() + private val fetcher: Fetcher = mock() private var persister: Persister = mock() private val barCode = BarCode("key", "value") diff --git a/store/src/test/java/com/dropbox/android/external/store3/StreamOneKeyTest.kt b/store/src/test/java/com/dropbox/android/external/store3/StreamOneKeyTest.kt index ef9568efc..39481127c 100644 --- a/store/src/test/java/com/dropbox/android/external/store3/StreamOneKeyTest.kt +++ b/store/src/test/java/com/dropbox/android/external/store3/StreamOneKeyTest.kt @@ -1,11 +1,11 @@ package com.dropbox.android.external.store3 -import com.dropbox.android.external.store4.Fetcher import com.dropbox.android.external.store4.Persister import com.dropbox.android.external.store4.StoreRequest import com.dropbox.android.external.store4.fresh import com.dropbox.android.external.store4.get import com.dropbox.android.external.store4.legacy.BarCode +import com.dropbox.android.external.store4.testutil.FakeFetcher import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.Dispatchers @@ -14,11 +14,11 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.broadcastIn -import kotlinx.coroutines.flow.transform import kotlinx.coroutines.plus import kotlinx.coroutines.test.TestCoroutineScope import kotlinx.coroutines.test.runBlockingTest import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.transform import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -31,12 +31,17 @@ class StreamOneKeyTest( private val storeType: TestStoreType ) { - val fetcher: Fetcher = mock() val persister: Persister = mock() private val barCode = BarCode("key", "value") private val barCode2 = BarCode("key2", "value2") private val testScope = TestCoroutineScope() + private val fetcher = FakeFetcher( + barCode to TEST_ITEM, + barCode to TEST_ITEM2, + barCode2 to TEST_ITEM + ) + private val store = TestStoreBuilder.from( scope = testScope, fetcher = fetcher, @@ -45,9 +50,6 @@ class StreamOneKeyTest( @Before fun setUp() = runBlockingTest { - whenever(fetcher.invoke(barCode)) - .thenReturn(TEST_ITEM) - .thenReturn(TEST_ITEM2) whenever(persister.read(barCode)) .let { @@ -65,13 +67,14 @@ class StreamOneKeyTest( .thenReturn(true) } - @Suppress("UsePropertyAccessSyntax") // for assert isTrue() isFalse() @Test fun testStream() = testScope.runBlockingTest { - val streamSubscription = store.stream(StoreRequest.skipMemory( - key = barCode, - refresh = true - )).transform { + val streamSubscription = store.stream( + StoreRequest.skipMemory( + key = barCode, + refresh = true + ) + ).transform { it.throwIfError() it.dataOrNull()?.let { emit(it) @@ -86,8 +89,6 @@ class StreamOneKeyTest( assertThat(streamSubscription.poll()).isEqualTo(TEST_ITEM) // get for another barcode should not trigger a stream for barcode1 - whenever(fetcher.invoke(barCode2)) - .thenReturn(TEST_ITEM) whenever(persister.read(barCode2)) .thenReturn(TEST_ITEM) whenever(persister.write(barCode2, TEST_ITEM)) diff --git a/store/src/test/java/com/dropbox/android/external/store3/TestStoreBuilder.kt b/store/src/test/java/com/dropbox/android/external/store3/TestStoreBuilder.kt index e17a188a0..de4f5f3af 100644 --- a/store/src/test/java/com/dropbox/android/external/store3/TestStoreBuilder.kt +++ b/store/src/test/java/com/dropbox/android/external/store3/TestStoreBuilder.kt @@ -15,14 +15,13 @@ */ package com.dropbox.android.external.store3 -import com.dropbox.android.external.store3.util.KeyParser import com.dropbox.android.external.store4.Fetcher import com.dropbox.android.external.store4.MemoryPolicy import com.dropbox.android.external.store4.Persister import com.dropbox.android.external.store4.Store import com.dropbox.android.external.store4.StoreBuilder import com.dropbox.android.external.store4.impl.PersistentSourceOfTruth -import com.dropbox.android.external.store4.impl.SourceOfTruth +import com.dropbox.android.external.store4.SourceOfTruth import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -40,64 +39,22 @@ data class TestStoreBuilder( @OptIn(ExperimentalTime::class) companion object { - - fun from( - scope: CoroutineScope, - fetcher: Fetcher, - persister: Persister? = null, - inflight: Boolean = true - ): TestStoreBuilder = from( - scope = scope, - inflight = inflight, - persister = persister, - fetcher = { fetcher.invoke(it) } - ) - - @Suppress("UNCHECKED_CAST") fun from( scope: CoroutineScope, - inflight: Boolean = true, cached: Boolean = false, cacheMemoryPolicy: MemoryPolicy? = null, persister: Persister? = null, - fetcher: suspend (Key) -> Output - ): TestStoreBuilder = from( - scope = scope, - inflight = inflight, - cached = cached, - cacheMemoryPolicy = cacheMemoryPolicy, - persister = persister, - fetcher = object : Fetcher { - override suspend fun invoke(key: Key): Output = fetcher(key) - } - ) - - @Suppress("UNCHECKED_CAST") - fun from( - scope: CoroutineScope, - inflight: Boolean = true, - cached: Boolean = false, - cacheMemoryPolicy: MemoryPolicy? = null, - persister: Persister? = null, - // parser that runs after fetch - fetchParser: KeyParser? = null, - // parser that runs after get from db - postParser: KeyParser? = null, - fetcher: Fetcher + fetcher: Fetcher ): TestStoreBuilder { return TestStoreBuilder( buildStore = { - StoreBuilder - .from { key: Key -> - flow { - val value = fetcher.invoke(key = key) - if (fetchParser != null) { - emit(fetchParser.apply(key, value)) - } else { - emit(value) - } - } + StoreBuilder.let { + if (persister == null) { + it.from(fetcher) + } else { + it.from(fetcher, sourceOfTruthFromLegacy(persister)) } + } .scope(scope) .let { if (cached) { @@ -110,38 +67,18 @@ data class TestStoreBuilder( it.disableCache() } } - .let { - if (persister == null) { - it - } else { - val sourceOfTruth = sourceOfTruthFromLegacy(persister, postParser) - it.persister( - sourceOfTruth::reader, - sourceOfTruth::write, - sourceOfTruth::delete - ) - } - }.build() + .build() } ) } internal fun sourceOfTruthFromLegacy( - persister: Persister, - // parser that runs after get from db - postParser: KeyParser? = null + persister: Persister ): SourceOfTruth { return PersistentSourceOfTruth( realReader = { key -> flow { - if (postParser == null) { - emit(persister.read(key)) - } else { - persister.read(key)?.let { - val postParsed = postParser.apply(key, it) - emit(postParsed) - } ?: emit(null) - } + emit(persister.read(key)) } }, realWriter = { key, value -> diff --git a/store/src/test/java/com/dropbox/android/external/store3/util/KeyParser.kt b/store/src/test/java/com/dropbox/android/external/store3/util/KeyParser.kt deleted file mode 100644 index 7a6c7d4fd..000000000 --- a/store/src/test/java/com/dropbox/android/external/store3/util/KeyParser.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.dropbox.android.external.store3.util - -@Deprecated("just for testing") -interface KeyParser { - suspend fun apply(key: Key, raw: Raw): Parsed -} diff --git a/store/src/test/java/com/dropbox/android/external/store4/Fetcher.kt b/store/src/test/java/com/dropbox/android/external/store4/Fetcher.kt deleted file mode 100644 index a21a6b28d..000000000 --- a/store/src/test/java/com/dropbox/android/external/store4/Fetcher.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.dropbox.android.external.store4 - -/** - * Interface for fetching new data for a Store - * - * @param data type before parsing - */ -@Deprecated("used in tests") -interface Fetcher { - - /** - * @param key Container with Key and Type used as a request param - * @return Observable that emits [Raw] data - */ - suspend fun invoke(key: Key): Raw -} diff --git a/store/src/test/java/com/dropbox/android/external/store4/FetcherControllerTest.kt b/store/src/test/java/com/dropbox/android/external/store4/FetcherControllerTest.kt index cc10cc0bd..06ac5a6d6 100644 --- a/store/src/test/java/com/dropbox/android/external/store4/FetcherControllerTest.kt +++ b/store/src/test/java/com/dropbox/android/external/store4/FetcherControllerTest.kt @@ -15,7 +15,6 @@ */ package com.dropbox.android.external.store4 -import com.dropbox.android.external.store4.ResponseOrigin.Fetcher import com.dropbox.android.external.store4.StoreResponse.Data import com.dropbox.android.external.store4.impl.FetcherController import com.google.common.truth.Truth.assertThat @@ -37,16 +36,17 @@ import org.junit.runners.JUnit4 @RunWith(JUnit4::class) class FetcherControllerTest { private val testScope = TestCoroutineScope() + @Test fun simple() = testScope.runBlockingTest { val fetcherController = FetcherController( - scope = testScope, - realFetcher = { key: Int -> - flow { - emit(key * key) - } - }, - sourceOfTruth = null + scope = testScope, + realFetcher = { key: Int -> + flow { + emit(FetcherResult.Data(key * key) as FetcherResult) + } + }, + sourceOfTruth = null ) val fetcher = fetcherController.getFetcher(3) assertThat(fetcherController.fetcherSize()).isEqualTo(0) @@ -54,10 +54,10 @@ class FetcherControllerTest { assertThat(fetcherController.fetcherSize()).isEqualTo(1) }.first() assertThat(received).isEqualTo( - Data( - value = 9, - origin = Fetcher - ) + Data( + value = 9, + origin = ResponseOrigin.Fetcher + ) ) assertThat(fetcherController.fetcherSize()).isEqualTo(0) } @@ -66,23 +66,23 @@ class FetcherControllerTest { fun concurrent() = testScope.runBlockingTest { var createdCnt = 0 val fetcherController = FetcherController( - scope = testScope, - realFetcher = { key: Int -> - createdCnt++ - flow { - // make sure it takes time, otherwise, we may not share - delay(1) - emit(key * key) - } - }, - sourceOfTruth = null + scope = testScope, + realFetcher = { key: Int -> + createdCnt++ + flow { + // make sure it takes time, otherwise, we may not share + delay(1) + emit(FetcherResult.Data(key * key) as FetcherResult) + } + }, + sourceOfTruth = null ) val fetcherCount = 20 fun createFetcher() = async { fetcherController.getFetcher(3) - .onEach { - assertThat(fetcherController.fetcherSize()).isEqualTo(1) - }.first() + .onEach { + assertThat(fetcherController.fetcherSize()).isEqualTo(1) + }.first() } val fetchers = (0 until fetcherCount).map { @@ -90,10 +90,10 @@ class FetcherControllerTest { } fetchers.forEach { assertThat(it.await()).isEqualTo( - Data( - value = 9, - origin = Fetcher - ) + Data( + value = 9, + origin = ResponseOrigin.Fetcher + ) ) } assertThat(fetcherController.fetcherSize()).isEqualTo(0) diff --git a/store/src/test/java/com/dropbox/android/external/store4/SourceOfTruthWithBarrierTest.kt b/store/src/test/java/com/dropbox/android/external/store4/SourceOfTruthWithBarrierTest.kt index 083e8b467..1afe19bba 100644 --- a/store/src/test/java/com/dropbox/android/external/store4/SourceOfTruthWithBarrierTest.kt +++ b/store/src/test/java/com/dropbox/android/external/store4/SourceOfTruthWithBarrierTest.kt @@ -17,7 +17,6 @@ package com.dropbox.android.external.store4 import com.dropbox.android.external.store4.impl.DataWithOrigin import com.dropbox.android.external.store4.impl.PersistentSourceOfTruth -import com.dropbox.android.external.store4.impl.SourceOfTruth import com.dropbox.android.external.store4.impl.SourceOfTruthWithBarrier import com.dropbox.android.external.store4.testutil.InMemoryPersister import com.google.common.truth.Truth.assertThat @@ -60,7 +59,7 @@ class SourceOfTruthWithBarrierTest { source.write(1, "a") assertThat(collector.await()).isEqualTo( listOf( - DataWithOrigin(delegate.defaultOrigin, null), + DataWithOrigin(ResponseOrigin.SourceOfTruth, null), DataWithOrigin(ResponseOrigin.Fetcher, "a") ) ) @@ -94,7 +93,7 @@ class SourceOfTruthWithBarrierTest { source.write(1, "b") assertThat(collector.await()).isEqualTo( listOf( - DataWithOrigin(delegate.defaultOrigin, "a"), + DataWithOrigin(ResponseOrigin.SourceOfTruth, "a"), DataWithOrigin(ResponseOrigin.Fetcher, "b") ) ) diff --git a/store/src/test/java/com/dropbox/android/external/store4/StoreResponseTest.kt b/store/src/test/java/com/dropbox/android/external/store4/StoreResponseTest.kt index 4ad53bb8f..93359afd5 100644 --- a/store/src/test/java/com/dropbox/android/external/store4/StoreResponseTest.kt +++ b/store/src/test/java/com/dropbox/android/external/store4/StoreResponseTest.kt @@ -3,6 +3,7 @@ package com.dropbox.android.external.store4 import com.dropbox.android.external.store4.ResponseOrigin.Fetcher import com.google.common.truth.Truth.assertThat import org.junit.Test +import java.io.IOException class StoreResponseTest { @@ -14,23 +15,34 @@ class StoreResponseTest { StoreResponse.Loading(Fetcher).requireData() } + @Test(expected = IOException::class) + fun throwIfErrorException() { + StoreResponse.Error.Exception(IOException(), Fetcher).throwIfError() + } + @Test(expected = RuntimeException::class) - fun throwIfError() { - StoreResponse.Error(RuntimeException(), Fetcher).throwIfError() + fun throwIfErrorMessage() { + StoreResponse.Error.Message("test error", Fetcher).throwIfError() } @Test() - fun errorOrNull() { + fun errorMessageOrNull() { assertThat( - StoreResponse.Error( - RuntimeException(), + StoreResponse.Error.Exception( + IOException(), Fetcher - ).errorOrNull() - ).isInstanceOf(RuntimeException::class.java) - assertThat(StoreResponse.Loading(Fetcher).errorOrNull()).isNull() + ).errorMessageOrNull() + ).contains(IOException::class.java.toString()) + assertThat( + StoreResponse.Error.Message( + "test error message", + Fetcher + ).errorMessageOrNull() + ).isEqualTo("test error message") + assertThat(StoreResponse.Loading(Fetcher).errorMessageOrNull()).isNull() } - @Test(expected = IllegalStateException::class) + @Test(expected = RuntimeException::class) fun swapType() { StoreResponse.Data("Foo", Fetcher).swapType() } diff --git a/store/src/test/java/com/dropbox/android/external/store4/impl/ClearAllStoreTest.kt b/store/src/test/java/com/dropbox/android/external/store4/impl/ClearAllStoreTest.kt index df1053094..8e9383d23 100644 --- a/store/src/test/java/com/dropbox/android/external/store4/impl/ClearAllStoreTest.kt +++ b/store/src/test/java/com/dropbox/android/external/store4/impl/ClearAllStoreTest.kt @@ -4,7 +4,9 @@ import com.dropbox.android.external.store4.ExperimentalStoreApi import com.dropbox.android.external.store4.ResponseOrigin import com.dropbox.android.external.store4.StoreBuilder import com.dropbox.android.external.store4.StoreResponse.Data +import com.dropbox.android.external.store4.nonFlowValueFetcher import com.dropbox.android.external.store4.testutil.InMemoryPersister +import com.dropbox.android.external.store4.testutil.asSourceOfTruth import com.dropbox.android.external.store4.testutil.getData import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -28,7 +30,7 @@ class ClearAllStoreTest { private val value1 = 1 private val value2 = 2 - private val fetcher: suspend (key: String) -> Int = { key: String -> + private val fetcher = nonFlowValueFetcher { key: String -> when (key) { key1 -> value1 key2 -> value2 @@ -41,15 +43,11 @@ class ClearAllStoreTest { @Test fun `calling clearAll() on store with persister (no in-memory cache) deletes all entries from the persister`() = testScope.runBlockingTest { - val store = StoreBuilder.fromNonFlow( - fetcher = fetcher + val store = StoreBuilder.from( + fetcher = fetcher, + sourceOfTruth = persister.asSourceOfTruth() ).scope(testScope) .disableCache() - .nonFlowingPersister( - reader = persister::read, - writer = persister::write, - deleteAll = persister::deleteAll - ) .build() // should receive data from network first time @@ -72,14 +70,14 @@ class ClearAllStoreTest { assertThat(store.getData(key1)) .isEqualTo( Data( - origin = ResponseOrigin.Persister, + origin = ResponseOrigin.SourceOfTruth, value = value1 ) ) assertThat(store.getData(key2)) .isEqualTo( Data( - origin = ResponseOrigin.Persister, + origin = ResponseOrigin.SourceOfTruth, value = value2 ) ) @@ -111,7 +109,7 @@ class ClearAllStoreTest { @Test fun `calling clearAll() on store with in-memory cache (no persister) deletes all entries from the in-memory cache`() = testScope.runBlockingTest { - val store = StoreBuilder.fromNonFlow( + val store = StoreBuilder.from( fetcher = fetcher ).scope(testScope).build() diff --git a/store/src/test/java/com/dropbox/android/external/store4/impl/ClearStoreByKeyTest.kt b/store/src/test/java/com/dropbox/android/external/store4/impl/ClearStoreByKeyTest.kt index 3b21c1496..89d2a64c2 100644 --- a/store/src/test/java/com/dropbox/android/external/store4/impl/ClearStoreByKeyTest.kt +++ b/store/src/test/java/com/dropbox/android/external/store4/impl/ClearStoreByKeyTest.kt @@ -3,7 +3,9 @@ package com.dropbox.android.external.store4.impl import com.dropbox.android.external.store4.ResponseOrigin import com.dropbox.android.external.store4.StoreBuilder import com.dropbox.android.external.store4.StoreResponse.Data +import com.dropbox.android.external.store4.nonFlowValueFetcher import com.dropbox.android.external.store4.testutil.InMemoryPersister +import com.dropbox.android.external.store4.testutil.asSourceOfTruth import com.dropbox.android.external.store4.testutil.getData import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -28,15 +30,11 @@ class ClearStoreByKeyTest { testScope.runBlockingTest { val key = "key" val value = 1 - val store = StoreBuilder.fromNonFlow( - fetcher = { value } + val store = StoreBuilder.from( + fetcher = nonFlowValueFetcher { value }, + sourceOfTruth = persister.asSourceOfTruth() ).scope(testScope) .disableCache() - .nonFlowingPersister( - reader = persister::read, - writer = persister::write, - delete = persister::deleteByKey - ) .build() // should receive data from network first time @@ -52,7 +50,7 @@ class ClearStoreByKeyTest { assertThat(store.getData(key)) .isEqualTo( Data( - origin = ResponseOrigin.Persister, + origin = ResponseOrigin.SourceOfTruth, value = value ) ) @@ -77,8 +75,8 @@ class ClearStoreByKeyTest { testScope.runBlockingTest { val key = "key" val value = 1 - val store = StoreBuilder.fromNonFlow( - fetcher = { value } + val store = StoreBuilder.from( + fetcher = nonFlowValueFetcher { value } ).scope(testScope).build() // should receive data from network first time @@ -119,20 +117,16 @@ class ClearStoreByKeyTest { val key2 = "key2" val value1 = 1 val value2 = 2 - val store = StoreBuilder.fromNonFlow( - fetcher = { key -> + val store = StoreBuilder.from( + fetcher = nonFlowValueFetcher { key -> when (key) { key1 -> value1 key2 -> value2 else -> throw IllegalStateException("Unknown key") } - } + }, + sourceOfTruth = persister.asSourceOfTruth() ).scope(testScope) - .nonFlowingPersister( - reader = persister::read, - writer = persister::write, - delete = persister::deleteByKey - ) .build() // get data for both keys diff --git a/store/src/test/java/com/dropbox/android/external/store4/impl/FetcherResponseTest.kt b/store/src/test/java/com/dropbox/android/external/store4/impl/FetcherResponseTest.kt new file mode 100644 index 000000000..62267a326 --- /dev/null +++ b/store/src/test/java/com/dropbox/android/external/store4/impl/FetcherResponseTest.kt @@ -0,0 +1,208 @@ +package com.dropbox.android.external.store4.impl + +import com.dropbox.android.external.store4.FetcherResult +import com.dropbox.android.external.store4.ResponseOrigin +import com.dropbox.android.external.store4.StoreBuilder +import com.dropbox.android.external.store4.StoreRequest +import com.dropbox.android.external.store4.StoreResponse +import com.dropbox.android.external.store4.nonFlowFetcher +import com.dropbox.android.external.store4.nonFlowValueFetcher +import com.dropbox.android.external.store4.testutil.assertThat +import com.dropbox.android.external.store4.valueFetcher +import com.google.common.truth.Truth +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Test + +@ExperimentalCoroutinesApi +@FlowPreview +class FetcherResponseTest { + private val testScope = TestCoroutineScope() + + @Test + fun `GIVEN a Fetcher that throws an exception in invoke WHEN streaming THEN the exceptions should not be caught`() { + val result = kotlin.runCatching { + testScope.runBlockingTest { + val store = StoreBuilder.from( + nonFlowFetcher { + throw RuntimeException("don't catch me") + } + ).buildWithTestScope() + + val result = store.stream(StoreRequest.fresh(1)).toList() + Truth.assertThat(result).isEmpty() + } + } + Truth.assertThat(result.isFailure).isTrue() + Truth.assertThat(result.exceptionOrNull()).hasMessageThat().contains( + "don't catch me" + ) + } + + @Test + fun `GIVEN a Fetcher that emits Error and Data WHEN steaming THEN it can emit value after an error`() { + val exception = RuntimeException("first error") + testScope.runBlockingTest { + val store = StoreBuilder.from { key: Int -> + flowOf( + FetcherResult.Error.Exception(exception), + FetcherResult.Data("$key") + ) + }.buildWithTestScope() + + assertThat(store.stream(StoreRequest.fresh(1))) + .emitsExactly( + StoreResponse.Loading(ResponseOrigin.Fetcher), + StoreResponse.Error.Exception(exception, ResponseOrigin.Fetcher), + StoreResponse.Data("1", ResponseOrigin.Fetcher) + ) + } + } + + @Test + fun `GIVEN transformer WHEN raw value THEN unwrapped value returned AND value is cached`() = + testScope.runBlockingTest { + val fetcher = valueFetcher { flowOf(it * it) } + val pipeline = StoreBuilder + .from(fetcher).buildWithTestScope() + + assertThat(pipeline.stream(StoreRequest.cached(3, refresh = false))) + .emitsExactly( + StoreResponse.Loading( + origin = ResponseOrigin.Fetcher + ), StoreResponse.Data( + value = 9, + origin = ResponseOrigin.Fetcher + ) + ) + assertThat( + pipeline.stream(StoreRequest.cached(3, refresh = false)) + ).emitsExactly( + StoreResponse.Data( + value = 9, + origin = ResponseOrigin.Cache + ) + ) + } + + @Test + fun `GIVEN transformer WHEN error message THEN error returned to user AND error isn't cached`() = + testScope.runBlockingTest { + var count = 0 + val fetcher = { _: Int -> + flowOf(count++).map { + if (it > 0) { + FetcherResult.Data(it) + } else { + FetcherResult.Error.Message("zero") + } + } + } + val pipeline = StoreBuilder.from(fetcher) + .buildWithTestScope() + + assertThat(pipeline.stream(StoreRequest.fresh(3))) + .emitsExactly( + StoreResponse.Loading( + origin = ResponseOrigin.Fetcher + ), StoreResponse.Error.Message( + message = "zero", + origin = ResponseOrigin.Fetcher + ) + ) + assertThat( + pipeline.stream(StoreRequest.cached(3, refresh = false)) + ).emitsExactly( + StoreResponse.Loading( + origin = ResponseOrigin.Fetcher + ), StoreResponse.Data( + value = 1, + origin = ResponseOrigin.Fetcher + ) + ) + } + + @Test + fun `GIVEN transformer WHEN error exception THEN error returned to user AND error isn't cached`() = + testScope.runBlockingTest { + val e = Exception() + var count = 0 + val fetcher = { _: Int -> + flowOf(count++).map { + if (it > 0) { + FetcherResult.Data(it) + } else { + FetcherResult.Error.Exception(e) + } + } + } + val pipeline = StoreBuilder + .from(fetcher) + .buildWithTestScope() + + assertThat(pipeline.stream(StoreRequest.fresh(3))) + .emitsExactly( + StoreResponse.Loading( + origin = ResponseOrigin.Fetcher + ), StoreResponse.Error.Exception( + error = e, + origin = ResponseOrigin.Fetcher + ) + ) + assertThat( + pipeline.stream(StoreRequest.cached(3, refresh = false)) + ).emitsExactly( + StoreResponse.Loading( + origin = ResponseOrigin.Fetcher + ), StoreResponse.Data( + value = 1, + origin = ResponseOrigin.Fetcher + ) + ) + } + + @Test + fun `GIVEN exceptionsAsErrors WHEN exception thrown THEN error returned to user AND error isn't cached`() = + testScope.runBlockingTest { + var count = 0 + val e = Exception() + val fetcher = nonFlowValueFetcher { + count++ + if (count == 1) { + throw e + } + count - 1 + } + val pipeline = StoreBuilder + .from(fetcher = fetcher) + .buildWithTestScope() + + assertThat(pipeline.stream(StoreRequest.fresh(3))) + .emitsExactly( + StoreResponse.Loading( + origin = ResponseOrigin.Fetcher + ), StoreResponse.Error.Exception( + error = e, + origin = ResponseOrigin.Fetcher + ) + ) + assertThat( + pipeline.stream(StoreRequest.cached(3, refresh = false)) + ).emitsExactly( + StoreResponse.Loading( + origin = ResponseOrigin.Fetcher + ), StoreResponse.Data( + value = 1, + origin = ResponseOrigin.Fetcher + ) + ) + } + + private fun StoreBuilder.buildWithTestScope() = + scope(testScope).build() +} diff --git a/store/src/test/java/com/dropbox/android/external/store4/impl/FlowStoreTest.kt b/store/src/test/java/com/dropbox/android/external/store4/impl/FlowStoreTest.kt index 6489adf76..127c86541 100644 --- a/store/src/test/java/com/dropbox/android/external/store4/impl/FlowStoreTest.kt +++ b/store/src/test/java/com/dropbox/android/external/store4/impl/FlowStoreTest.kt @@ -15,9 +15,7 @@ */ package com.dropbox.android.external.store4.impl -import com.dropbox.android.external.store4.ResponseOrigin.Cache -import com.dropbox.android.external.store4.ResponseOrigin.Fetcher -import com.dropbox.android.external.store4.ResponseOrigin.Persister +import com.dropbox.android.external.store4.ResponseOrigin import com.dropbox.android.external.store4.Store import com.dropbox.android.external.store4.StoreBuilder import com.dropbox.android.external.store4.StoreRequest @@ -25,17 +23,20 @@ import com.dropbox.android.external.store4.StoreResponse import com.dropbox.android.external.store4.StoreResponse.Data import com.dropbox.android.external.store4.StoreResponse.Loading import com.dropbox.android.external.store4.fresh +import com.dropbox.android.external.store4.nonFlowValueFetcher import com.dropbox.android.external.store4.testutil.FakeFetcher +import com.dropbox.android.external.store4.testutil.FakeFlowingFetcher import com.dropbox.android.external.store4.testutil.InMemoryPersister import com.dropbox.android.external.store4.testutil.asFlowable +import com.dropbox.android.external.store4.testutil.asSourceOfTruth import com.dropbox.android.external.store4.testutil.assertThat +import com.dropbox.android.external.store4.valueFetcher import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.async import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first @@ -59,17 +60,17 @@ class FlowStoreTest { 3 to "three-1", 3 to "three-2" ) - val pipeline = build( - nonFlowingFetcher = fetcher::fetch, - enableCache = true - ) + val pipeline = StoreBuilder + .from(fetcher) + .buildWithTestScope() + assertThat(pipeline.stream(StoreRequest.cached(3, refresh = false))) .emitsExactly( Loading( - origin = Fetcher + origin = ResponseOrigin.Fetcher ), Data( value = "three-1", - origin = Fetcher + origin = ResponseOrigin.Fetcher ) ) assertThat( @@ -77,17 +78,17 @@ class FlowStoreTest { ).emitsExactly( Data( value = "three-1", - origin = Cache + origin = ResponseOrigin.Cache ) ) assertThat(pipeline.stream(StoreRequest.fresh(3))) .emitsExactly( Loading( - origin = Fetcher + origin = ResponseOrigin.Fetcher ), Data( value = "three-2", - origin = Fetcher + origin = ResponseOrigin.Fetcher ) ) assertThat( @@ -96,7 +97,7 @@ class FlowStoreTest { .emitsExactly( Data( value = "three-2", - origin = Cache + origin = ResponseOrigin.Cache ) ) } @@ -108,55 +109,54 @@ class FlowStoreTest { 3 to "three-2" ) val persister = InMemoryPersister() - val pipeline = build( - nonFlowingFetcher = fetcher::fetch, - persisterReader = persister::read, - persisterWriter = persister::write, - enableCache = true - ) + val pipeline = StoreBuilder.from( + fetcher = fetcher, + sourceOfTruth = persister.asSourceOfTruth() + ).buildWithTestScope() + assertThat(pipeline.stream(StoreRequest.cached(3, refresh = false))) .emitsExactly( Loading( - origin = Fetcher + origin = ResponseOrigin.Fetcher ), Data( value = "three-1", - origin = Fetcher + origin = ResponseOrigin.Fetcher ) ) assertThat(pipeline.stream(StoreRequest.cached(3, refresh = false))) .emitsExactly( Data( value = "three-1", - origin = Cache + origin = ResponseOrigin.Cache ), // note that we still get the data from persister as well as we don't listen to // the persister for the cached items unless there is an active stream, which // means cache can go out of sync w/ the persister Data( value = "three-1", - origin = Persister + origin = ResponseOrigin.SourceOfTruth ) ) assertThat(pipeline.stream(StoreRequest.fresh(3))) .emitsExactly( Loading( - origin = Fetcher + origin = ResponseOrigin.Fetcher ), Data( value = "three-2", - origin = Fetcher + origin = ResponseOrigin.Fetcher ) ) assertThat(pipeline.stream(StoreRequest.cached(3, refresh = false))) .emitsExactly( Data( value = "three-2", - origin = Cache + origin = ResponseOrigin.Cache ), Data( value = "three-2", - origin = Persister + origin = ResponseOrigin.SourceOfTruth ) ) } @@ -169,21 +169,19 @@ class FlowStoreTest { ) val persister = InMemoryPersister() - val pipeline = build( - nonFlowingFetcher = fetcher::fetch, - persisterReader = persister::read, - persisterWriter = persister::write, - enableCache = true - ) + val pipeline = StoreBuilder.from( + fetcher = fetcher, + sourceOfTruth = persister.asSourceOfTruth() + ).buildWithTestScope() assertThat(pipeline.stream(StoreRequest.cached(3, refresh = true))) .emitsExactly( Loading( - origin = Fetcher + origin = ResponseOrigin.Fetcher ), Data( value = "three-1", - origin = Fetcher + origin = ResponseOrigin.Fetcher ) ) @@ -191,18 +189,18 @@ class FlowStoreTest { .emitsExactly( Data( value = "three-1", - origin = Cache + origin = ResponseOrigin.Cache ), Data( value = "three-1", - origin = Persister + origin = ResponseOrigin.SourceOfTruth ), Loading( - origin = Fetcher + origin = ResponseOrigin.Fetcher ), Data( value = "three-2", - origin = Fetcher + origin = ResponseOrigin.Fetcher ) ) } @@ -213,19 +211,17 @@ class FlowStoreTest { 3 to "three-1", 3 to "three-2" ) - val pipeline = build( - nonFlowingFetcher = fetcher::fetch, - enableCache = true - ) + val pipeline = StoreBuilder.from(fetcher = fetcher) + .buildWithTestScope() assertThat(pipeline.stream(StoreRequest.cached(3, refresh = true))) .emitsExactly( Loading( - origin = Fetcher + origin = ResponseOrigin.Fetcher ), Data( value = "three-1", - origin = Fetcher + origin = ResponseOrigin.Fetcher ) ) @@ -233,14 +229,14 @@ class FlowStoreTest { .emitsExactly( Data( value = "three-1", - origin = Cache + origin = ResponseOrigin.Cache ), Loading( - origin = Fetcher + origin = ResponseOrigin.Fetcher ), Data( value = "three-2", - origin = Fetcher + origin = ResponseOrigin.Fetcher ) ) } @@ -251,79 +247,77 @@ class FlowStoreTest { 3 to "three-1", 3 to "three-2" ) - val pipeline = build( - nonFlowingFetcher = fetcher::fetch, - enableCache = true - ) + val pipeline = StoreBuilder.from(fetcher = fetcher) + .buildWithTestScope() assertThat(pipeline.stream(StoreRequest.skipMemory(3, refresh = false))) .emitsExactly( Loading( - origin = Fetcher + origin = ResponseOrigin.Fetcher ), Data( value = "three-1", - origin = Fetcher + origin = ResponseOrigin.Fetcher ) ) assertThat(pipeline.stream(StoreRequest.skipMemory(3, refresh = false))) .emitsExactly( Loading( - origin = Fetcher + origin = ResponseOrigin.Fetcher ), Data( value = "three-2", - origin = Fetcher + origin = ResponseOrigin.Fetcher ) ) } @Test fun flowingFetcher() = testScope.runBlockingTest { - val fetcher = FlowingFakeFetcher( + val fetcher = FakeFlowingFetcher( 3 to "three-1", 3 to "three-2" ) val persister = InMemoryPersister() - val pipeline = build( - flowingFetcher = fetcher::createFlow, - persisterReader = persister::read, - persisterWriter = persister::write, - enableCache = false + val pipeline = StoreBuilder.from( + fetcher = fetcher, + sourceOfTruth = persister.asSourceOfTruth() ) + .disableCache() + .buildWithTestScope() assertThat(pipeline.stream(StoreRequest.fresh(3))) .emitsExactly( Loading( - origin = Fetcher + origin = ResponseOrigin.Fetcher ), Data( value = "three-1", - origin = Fetcher + origin = ResponseOrigin.Fetcher ), Data( value = "three-2", - origin = Fetcher + origin = ResponseOrigin.Fetcher ) ) assertThat(pipeline.stream(StoreRequest.cached(3, refresh = true))) .emitsExactly( Data( value = "three-2", - origin = Persister + origin = ResponseOrigin.SourceOfTruth ), Loading( - origin = Fetcher + origin = ResponseOrigin.Fetcher ), Data( value = "three-1", - origin = Fetcher + origin = ResponseOrigin.Fetcher ), Data( value = "three-2", - origin = Fetcher + origin = ResponseOrigin.Fetcher ) ) } @@ -331,15 +325,12 @@ class FlowStoreTest { @Test fun diskChangeWhileNetworkIsFlowing_simple() = testScope.runBlockingTest { val persister = InMemoryPersister().asFlowable() - val pipeline = build( - flowingFetcher = { - flow { - } - }, - flowingPersisterReader = persister::flowReader, - persisterWriter = persister::flowWriter, - enableCache = false + val pipeline = StoreBuilder.from( + valueFetcher { flow {} }, + sourceOfTruth = persister.asSourceOfTruth() ) + .disableCache() + .buildWithTestScope() launch { delay(10) @@ -348,11 +339,11 @@ class FlowStoreTest { assertThat(pipeline.stream(StoreRequest.cached(3, refresh = true))) .emitsExactly( Loading( - origin = Fetcher + origin = ResponseOrigin.Fetcher ), Data( value = "local-1", - origin = Persister + origin = ResponseOrigin.SourceOfTruth ) ) } @@ -360,8 +351,8 @@ class FlowStoreTest { @Test fun diskChangeWhileNetworkIsFlowing_overwrite() = testScope.runBlockingTest { val persister = InMemoryPersister().asFlowable() - val pipeline = build( - flowingFetcher = { + val pipeline = StoreBuilder.from( + fetcher = valueFetcher { flow { delay(10) emit("three-1") @@ -369,10 +360,11 @@ class FlowStoreTest { emit("three-2") } }, - flowingPersisterReader = persister::flowReader, - persisterWriter = persister::flowWriter, - enableCache = false + sourceOfTruth = persister.asSourceOfTruth() ) + .disableCache() + .buildWithTestScope() + launch { delay(5) persister.flowWriter(3, "local-1") @@ -382,23 +374,23 @@ class FlowStoreTest { assertThat(pipeline.stream(StoreRequest.cached(3, refresh = true))) .emitsExactly( Loading( - origin = Fetcher + origin = ResponseOrigin.Fetcher ), Data( value = "local-1", - origin = Persister + origin = ResponseOrigin.SourceOfTruth ), Data( value = "three-1", - origin = Fetcher + origin = ResponseOrigin.Fetcher ), Data( value = "local-2", - origin = Persister + origin = ResponseOrigin.SourceOfTruth ), Data( value = "three-2", - origin = Fetcher + origin = ResponseOrigin.Fetcher ) ) } @@ -407,14 +399,15 @@ class FlowStoreTest { fun errorTest() = testScope.runBlockingTest { val exception = IllegalArgumentException("wow") val persister = InMemoryPersister().asFlowable() - val pipeline = build( - nonFlowingFetcher = { + val pipeline = StoreBuilder.from( + nonFlowValueFetcher { throw exception }, - flowingPersisterReader = persister::flowReader, - persisterWriter = persister::flowWriter, - enableCache = false + sourceOfTruth = persister.asSourceOfTruth() ) + .disableCache() + .buildWithTestScope() + launch { delay(10) persister.flowWriter(3, "local-1") @@ -422,29 +415,29 @@ class FlowStoreTest { assertThat(pipeline.stream(StoreRequest.cached(key = 3, refresh = true))) .emitsExactly( Loading( - origin = Fetcher + origin = ResponseOrigin.Fetcher ), - StoreResponse.Error( + StoreResponse.Error.Exception( error = exception, - origin = Fetcher + origin = ResponseOrigin.Fetcher ), Data( value = "local-1", - origin = Persister + origin = ResponseOrigin.SourceOfTruth ) ) assertThat(pipeline.stream(StoreRequest.cached(key = 3, refresh = true))) .emitsExactly( Data( value = "local-1", - origin = Persister + origin = ResponseOrigin.SourceOfTruth ), Loading( - origin = Fetcher + origin = ResponseOrigin.Fetcher ), - StoreResponse.Error( + StoreResponse.Error.Exception( error = exception, - origin = Fetcher + origin = ResponseOrigin.Fetcher ) ) } @@ -456,10 +449,9 @@ class FlowStoreTest { 3 to "three-1", 3 to "three-2" ) - val store = build( - nonFlowingFetcher = fetcher::fetch, - enableCache = true - ) + val store = StoreBuilder.from(fetcher = fetcher) + .buildWithTestScope() + val firstFetch = store.fresh(3) assertThat(firstFetch).isEqualTo("three-1") val secondCollect = mutableListOf>() @@ -472,7 +464,7 @@ class FlowStoreTest { assertThat(secondCollect).containsExactly( Data( value = "three-1", - origin = Cache + origin = ResponseOrigin.Cache ) ) // trigger another fetch from network @@ -483,11 +475,11 @@ class FlowStoreTest { assertThat(secondCollect).containsExactly( Data( value = "three-1", - origin = Cache + origin = ResponseOrigin.Cache ), Data( value = "three-2", - origin = Fetcher + origin = ResponseOrigin.Fetcher ) ) collection.cancelAndJoin() @@ -501,12 +493,11 @@ class FlowStoreTest { 3 to "three-2" ) val persister = InMemoryPersister() - val pipeline = build( - nonFlowingFetcher = fetcher::fetch, - persisterReader = persister::read, - persisterWriter = persister::write, - enableCache = true - ) + val pipeline = StoreBuilder.from( + fetcher = fetcher, + sourceOfTruth = persister.asSourceOfTruth() + ).buildWithTestScope() + val firstFetch = pipeline.fresh(3) assertThat(firstFetch).isEqualTo("three-1") val secondCollect = mutableListOf>() @@ -519,11 +510,11 @@ class FlowStoreTest { assertThat(secondCollect).containsExactly( Data( value = "three-1", - origin = Cache + origin = ResponseOrigin.Cache ), Data( value = "three-1", - origin = Persister + origin = ResponseOrigin.SourceOfTruth ) ) // trigger another fetch from network @@ -534,15 +525,15 @@ class FlowStoreTest { assertThat(secondCollect).containsExactly( Data( value = "three-1", - origin = Cache + origin = ResponseOrigin.Cache ), Data( value = "three-1", - origin = Persister + origin = ResponseOrigin.SourceOfTruth ), Data( value = "three-2", - origin = Fetcher + origin = ResponseOrigin.Fetcher ) ) collection.cancelAndJoin() @@ -556,10 +547,10 @@ class FlowStoreTest { 3 to "three-2", 3 to "three-3" ) - val pipeline = build( - nonFlowingFetcher = fetcher::fetch, - enableCache = true - ) + val pipeline = StoreBuilder.from( + fetcher = fetcher + ).buildWithTestScope() + val fetcher1Collected = mutableListOf>() val fetcher1Job = async { pipeline.stream(StoreRequest.cached(3, refresh = true)).collect { @@ -570,29 +561,29 @@ class FlowStoreTest { testScope.advanceUntilIdle() assertThat(fetcher1Collected).isEqualTo( listOf( - Loading(origin = Fetcher), - Data(origin = Fetcher, value = "three-1") + Loading(origin = ResponseOrigin.Fetcher), + Data(origin = ResponseOrigin.Fetcher, value = "three-1") ) ) assertThat(pipeline.stream(StoreRequest.cached(3, refresh = true))) .emitsExactly( - Data(origin = Cache, value = "three-1"), - Loading(origin = Fetcher), - Data(origin = Fetcher, value = "three-2") + Data(origin = ResponseOrigin.Cache, value = "three-1"), + Loading(origin = ResponseOrigin.Fetcher), + Data(origin = ResponseOrigin.Fetcher, value = "three-2") ) assertThat(pipeline.stream(StoreRequest.cached(3, refresh = true))) .emitsExactly( - Data(origin = Cache, value = "three-2"), - Loading(origin = Fetcher), - Data(origin = Fetcher, value = "three-3") + Data(origin = ResponseOrigin.Cache, value = "three-2"), + Loading(origin = ResponseOrigin.Fetcher), + Data(origin = ResponseOrigin.Fetcher, value = "three-3") ) testScope.advanceUntilIdle() assertThat(fetcher1Collected).isEqualTo( listOf( - Loading(origin = Fetcher), - Data(origin = Fetcher, value = "three-1"), - Data(origin = Fetcher, value = "three-2"), - Data(origin = Fetcher, value = "three-3") + Loading(origin = ResponseOrigin.Fetcher), + Data(origin = ResponseOrigin.Fetcher, value = "three-1"), + Data(origin = ResponseOrigin.Fetcher, value = "three-2"), + Data(origin = ResponseOrigin.Fetcher, value = "three-3") ) ) fetcher1Job.cancelAndJoin() @@ -605,10 +596,9 @@ class FlowStoreTest { 3 to "three-1", 3 to "three-2" ) - val pipeline = build( - nonFlowingFetcher = fetcher::fetch, - enableCache = true - ) + val pipeline = StoreBuilder.from(fetcher = fetcher) + .buildWithTestScope() + val fetcher1Collected = mutableListOf>() val fetcher1Job = async { pipeline.stream(StoreRequest.cached(3, refresh = true)).collect { @@ -618,22 +608,22 @@ class FlowStoreTest { testScope.runCurrent() assertThat(fetcher1Collected).isEqualTo( listOf( - Loading(origin = Fetcher), - Data(origin = Fetcher, value = "three-1") + Loading(origin = ResponseOrigin.Fetcher), + Data(origin = ResponseOrigin.Fetcher, value = "three-1") ) ) assertThat(pipeline.stream(StoreRequest.cached(3, refresh = true))) .emitsExactly( - Data(origin = Cache, value = "three-1"), - Loading(origin = Fetcher), - Data(origin = Fetcher, value = "three-2") + Data(origin = ResponseOrigin.Cache, value = "three-1"), + Loading(origin = ResponseOrigin.Fetcher), + Data(origin = ResponseOrigin.Fetcher, value = "three-2") ) testScope.runCurrent() assertThat(fetcher1Collected).isEqualTo( listOf( - Loading(origin = Fetcher), - Data(origin = Fetcher, value = "three-1"), - Data(origin = Fetcher, value = "three-2") + Loading(origin = ResponseOrigin.Fetcher), + Data(origin = ResponseOrigin.Fetcher, value = "three-1"), + Data(origin = ResponseOrigin.Fetcher, value = "three-2") ) ) fetcher1Job.cancelAndJoin() @@ -649,71 +639,6 @@ class FlowStoreTest { ) ) - private class FlowingFakeFetcher( - vararg val responses: Pair - ) { - fun createFlow(key: Key) = flow { - responses.filter { - it.first == key - }.forEach { - // we delay here to avoid collapsing fetcher values, otherwise, there is a - // possibility that consumer won't be fast enough to get both values before new - // value overrides the previous one. - delay(1) - emit(it.second) - } - } - } - - private fun build( - nonFlowingFetcher: (suspend (Key) -> Input)? = null, - flowingFetcher: ((Key) -> Flow)? = null, - persisterReader: (suspend (Key) -> Output?)? = null, - flowingPersisterReader: ((Key) -> Flow)? = null, - persisterWriter: (suspend (Key, Input) -> Unit)? = null, - persisterDelete: (suspend (Key) -> Unit)? = null, - enableCache: Boolean - ): Store { - check(nonFlowingFetcher != null || flowingFetcher != null) { - "need to provide a fetcher" - } - check(nonFlowingFetcher == null || flowingFetcher == null) { - "need 1 fetcher" - } - check(persisterReader == null || flowingPersisterReader == null) { - "need 0 or 1 persister" - } - - return if (nonFlowingFetcher != null) { - StoreBuilder.fromNonFlow( - nonFlowingFetcher - ) - } else { - StoreBuilder.from( - flowingFetcher!! - ) - }.scope(testScope) - .let { - if (enableCache) { - it - } else { - it.disableCache() - } - }.let { - @Suppress("UNCHECKED_CAST") - when { - flowingPersisterReader != null -> it.persister( - reader = flowingPersisterReader, - writer = persisterWriter!!, - delete = persisterDelete - ) - persisterReader != null -> it.nonFlowingPersister( - reader = persisterReader, - writer = persisterWriter!!, - delete = persisterDelete - ) - else -> it - } as StoreBuilder - }.build() - } + private fun StoreBuilder.buildWithTestScope() = + scope(testScope).build() } diff --git a/store/src/test/java/com/dropbox/android/external/store4/impl/HotFlowStoreTest.kt b/store/src/test/java/com/dropbox/android/external/store4/impl/HotFlowStoreTest.kt index de17af249..55c1d8d12 100644 --- a/store/src/test/java/com/dropbox/android/external/store4/impl/HotFlowStoreTest.kt +++ b/store/src/test/java/com/dropbox/android/external/store4/impl/HotFlowStoreTest.kt @@ -1,5 +1,7 @@ package com.dropbox.android.external.store4.impl +import com.dropbox.android.external.store4.Fetcher +import com.dropbox.android.external.store4.FetcherResult import com.dropbox.android.external.store4.ResponseOrigin import com.dropbox.android.external.store4.StoreBuilder import com.dropbox.android.external.store4.StoreRequest @@ -21,6 +23,7 @@ import org.junit.runners.JUnit4 @RunWith(JUnit4::class) class HotFlowStoreTest { private val testScope = TestCoroutineScope() + @Test fun `GIVEN a hot fetcher WHEN two cached and one fresh call THEN fetcher is only called twice`() = testScope.runBlockingTest { @@ -28,7 +31,8 @@ class HotFlowStoreTest { 3 to "three-1", 3 to "three-2" ) - val pipeline = StoreBuilder.from { fetcher.fetch(it) } + val pipeline = StoreBuilder + .from(fetcher) .scope(testScope) .build() @@ -63,17 +67,18 @@ class HotFlowStoreTest { } } -class FakeFlowFetcher( +class FakeFlowFetcher( vararg val responses: Pair -) { +) : Fetcher { private var index = 0 + @Suppress("RedundantSuspendModifier") // needed for function reference - fun fetch(key: Key): Flow { + override fun invoke(key: Key): Flow> { if (index >= responses.size) { throw AssertionError("unexpected fetch request") } val pair = responses[index++] assertThat(pair.first).isEqualTo(key) - return flowOf(pair.second) + return flowOf(FetcherResult.Data(pair.second)) } } diff --git a/store/src/test/java/com/dropbox/android/external/store4/impl/StoreWithInMemoryCacheTest.kt b/store/src/test/java/com/dropbox/android/external/store4/impl/StoreWithInMemoryCacheTest.kt index bf36d8aab..437979434 100644 --- a/store/src/test/java/com/dropbox/android/external/store4/impl/StoreWithInMemoryCacheTest.kt +++ b/store/src/test/java/com/dropbox/android/external/store4/impl/StoreWithInMemoryCacheTest.kt @@ -2,6 +2,7 @@ package com.dropbox.android.external.store4.impl import com.dropbox.android.external.store4.MemoryPolicy import com.dropbox.android.external.store4.StoreBuilder +import com.dropbox.android.external.store4.nonFlowValueFetcher import com.dropbox.android.external.store4.get import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -14,7 +15,6 @@ import kotlin.time.minutes @FlowPreview @ExperimentalTime -@ExperimentalStdlibApi @ExperimentalCoroutinesApi @RunWith(JUnit4::class) class StoreWithInMemoryCacheTest { @@ -22,7 +22,7 @@ class StoreWithInMemoryCacheTest { @Test fun `store requests can complete when its in-memory cache (with access expiry) is at the maximum size`() { val store = StoreBuilder - .fromNonFlow { _: Int -> "result" } + .from(nonFlowValueFetcher { _: Int -> "result" }) .cachePolicy( MemoryPolicy .builder() diff --git a/store/src/test/java/com/dropbox/android/external/store4/impl/StreamWithoutSourceOfTruthTest.kt b/store/src/test/java/com/dropbox/android/external/store4/impl/StreamWithoutSourceOfTruthTest.kt index bfc8b0d8d..9b4640ae1 100644 --- a/store/src/test/java/com/dropbox/android/external/store4/impl/StreamWithoutSourceOfTruthTest.kt +++ b/store/src/test/java/com/dropbox/android/external/store4/impl/StreamWithoutSourceOfTruthTest.kt @@ -48,7 +48,7 @@ class StreamWithoutSourceOfTruthTest( 3 to "three-1", 3 to "three-2" ) - val pipeline = StoreBuilder.fromNonFlow(fetcher::fetch) + val pipeline = StoreBuilder.from(fetcher) .scope(testScope) .let { if (enableCache) { diff --git a/store/src/test/java/com/dropbox/android/external/store4/impl/ValueFetcherTest.kt b/store/src/test/java/com/dropbox/android/external/store4/impl/ValueFetcherTest.kt new file mode 100644 index 000000000..337cef43d --- /dev/null +++ b/store/src/test/java/com/dropbox/android/external/store4/impl/ValueFetcherTest.kt @@ -0,0 +1,62 @@ +package com.dropbox.android.external.store4.impl + +import com.dropbox.android.external.store4.FetcherResult +import com.dropbox.android.external.store4.nonFlowValueFetcher +import com.dropbox.android.external.store4.testutil.assertThat +import com.dropbox.android.external.store4.valueFetcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Test + +@ExperimentalCoroutinesApi +@FlowPreview +class ValueFetcherTest { + + private val testScope = TestCoroutineScope() + + @Test + fun `GIVEN valueFetcher WHEN invoke THEN result is wrapped`() = + testScope.runBlockingTest { + val fetcher = valueFetcher { flowOf(it * it) } + + assertThat(fetcher(3)) + .emitsExactly(FetcherResult.Data(value = 9)) + } + + @Test + fun `GIVEN valueFetcher WHEN exception in flow THEN exception returned as result`() = + testScope.runBlockingTest { + val e = Exception() + val fetcher = valueFetcher { + flow { + throw e + } + } + assertThat(fetcher(3)) + .emitsExactly(FetcherResult.Error.Exception(e)) + } + + @Test + fun `GIVEN nonFlowValueFetcher WHEN invoke THEN result is wrapped`() = + testScope.runBlockingTest { + val fetcher = nonFlowValueFetcher { it * it } + + assertThat(fetcher(3)) + .emitsExactly(FetcherResult.Data(value = 9)) + } + + @Test + fun `GIVEN nonFlowValueFetcher WHEN exception in flow THEN exception returned as result`() = + testScope.runBlockingTest { + val e = Exception() + val fetcher = nonFlowValueFetcher { + throw e + } + assertThat(fetcher(3)) + .emitsExactly(FetcherResult.Error.Exception(e)) + } +} diff --git a/store/src/test/java/com/dropbox/android/external/store4/testutil/FakeFetcher.kt b/store/src/test/java/com/dropbox/android/external/store4/testutil/FakeFetcher.kt index cfe53fe33..f53db4741 100644 --- a/store/src/test/java/com/dropbox/android/external/store4/testutil/FakeFetcher.kt +++ b/store/src/test/java/com/dropbox/android/external/store4/testutil/FakeFetcher.kt @@ -15,19 +15,40 @@ */ package com.dropbox.android.external.store4.testutil +import com.dropbox.android.external.store4.Fetcher +import com.dropbox.android.external.store4.FetcherResult import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf -class FakeFetcher( - vararg val responses: Pair -) { +class FakeFetcher( + private vararg val responses: Pair +) : Fetcher { private var index = 0 - @Suppress("RedundantSuspendModifier") // needed for function reference - suspend fun fetch(key: Key): Output { + override operator fun invoke(key: Key): Flow> { if (index >= responses.size) { throw AssertionError("unexpected fetch request") } val pair = responses[index++] assertThat(pair.first).isEqualTo(key) - return pair.second + return flowOf(FetcherResult.Data(pair.second)) + } +} + +class FakeFlowingFetcher( + private vararg val responses: Pair +) : Fetcher { + override operator fun invoke(key: Key) = flow { + responses.filter { + it.first == key + }.forEach { + // we delay here to avoid collapsing fetcher values, otherwise, there is a + // possibility that consumer won't be fast enough to get both values before new + // value overrides the previous one. + delay(1) + emit(FetcherResult.Data(it.second)) + } } } diff --git a/store/src/test/java/com/dropbox/android/external/store4/testutil/InMemoryPersister.kt b/store/src/test/java/com/dropbox/android/external/store4/testutil/InMemoryPersister.kt index 81267a004..07ba55fa5 100644 --- a/store/src/test/java/com/dropbox/android/external/store4/testutil/InMemoryPersister.kt +++ b/store/src/test/java/com/dropbox/android/external/store4/testutil/InMemoryPersister.kt @@ -1,9 +1,11 @@ package com.dropbox.android.external.store4.testutil +import com.dropbox.android.external.store4.SourceOfTruth + /** * An in-memory non-flowing persister for testing. */ -class InMemoryPersister { +class InMemoryPersister { private val data = mutableMapOf() @Suppress("RedundantSuspendModifier") // for function reference @@ -28,3 +30,11 @@ class InMemoryPersister { return data[key] } } + +fun InMemoryPersister.asSourceOfTruth() = + SourceOfTruth.fromNonFlow( + reader = ::read, + writer = ::write, + delete = ::deleteByKey, + deleteAll = ::deleteAll + ) diff --git a/store/src/test/java/com/dropbox/android/external/store4/testutil/SimplePersisterAsFlowable.kt b/store/src/test/java/com/dropbox/android/external/store4/testutil/SimplePersisterAsFlowable.kt index bc79ea550..eb49b3420 100644 --- a/store/src/test/java/com/dropbox/android/external/store4/testutil/SimplePersisterAsFlowable.kt +++ b/store/src/test/java/com/dropbox/android/external/store4/testutil/SimplePersisterAsFlowable.kt @@ -15,6 +15,7 @@ */ package com.dropbox.android.external.store4.testutil +import com.dropbox.android.external.store4.SourceOfTruth import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.BroadcastChannel import kotlinx.coroutines.channels.Channel @@ -34,6 +35,10 @@ class SimplePersisterAsFlowable( private val writer: suspend (Key, Input) -> Unit, private val delete: (suspend (Key) -> Unit)? = null ) { + + val supportsDelete: Boolean + get() = delete != null + private val versionTracker = KeyTracker() fun flowReader(key: Key): Flow = flow { @@ -55,6 +60,14 @@ class SimplePersisterAsFlowable( } } +@ExperimentalCoroutinesApi +fun SimplePersisterAsFlowable.asSourceOfTruth() = + SourceOfTruth.from( + reader = ::flowReader, + writer = ::flowWriter, + delete = ::flowDelete.takeIf { supportsDelete } + ) + /** * helper class which provides Flows for Keys that can be tracked. */ @@ -132,7 +145,8 @@ internal class KeyTracker { } @ExperimentalCoroutinesApi -suspend fun InMemoryPersister.asFlowable() = SimplePersisterAsFlowable( - reader = this::read, - writer = this::write -) +fun InMemoryPersister.asFlowable() = + SimplePersisterAsFlowable( + reader = this::read, + writer = this::write + )