Add support for purging all store entries (#79)

* Fix typos.

* Gradle 6.1, AGP 4.0.0-alpha09, lifecycle 2.2.0.

* Add experimental invalidateAll() support and @ExperimentalStoreAPI annotation.

* Update sample with deleteAll function.

* Update README.md with deleteAll function.

* Add a section to README.md on clearing store entries.

* Rewrite tests without mocking. Move test utils / helpers to a single package.

* Code formatting and cleanups.

* Use StoreResponse.Data instead of DataWithOrigin in ClearAllStoreTest and ClearStoreByKeyTest.

* Simplified samples. Refactor tests.

* Gradle 6.1.1.
This commit is contained in:
Yang 2020-01-29 04:02:28 +11:00 committed by Eyal Guthmann
parent 86f7b1b060
commit 613a5c8296
32 changed files with 628 additions and 310 deletions

View file

@ -58,7 +58,8 @@ StoreBuilder
}.persister(
reader = db.postDao()::loadPosts,
writer = db.postDao()::insertPosts,
delete = db.postDao()::clearFeed
delete = db.postDao()::clearFeed,
deleteAll = db.postDao()::clearAllFeeds
).build()
```
@ -219,7 +220,8 @@ StoreBuilder
).persister(
reader = db.postDao()::loadPosts,
writer = db.postDao()::insertPosts,
delete = db.postDao()::clearFeed
delete = db.postDao()::clearFeed,
deleteAll = db.postDao()::clearAllFeeds
).build()
```
@ -229,3 +231,56 @@ StoreBuilder
* `setExpireAfterTimeUnit(expireAfterTimeUnit: TimeUnit)` sets the time unit used when setting `expireAfterAccess` or `expireAfterWrite`. Default unit is `TimeUnit.SECONDS`.
Note that `setExpireAfterAccess` and `setExpireAfterWrite` **cannot** both be set at the same time.
### Clearing store entries
You can delete a specific entry by key from a store, or clear all entries in a store.
#### Store with no persister
```kotlin
val store = StoreBuilder
.fromNonFlow<String, Int> { key: String ->
api.fetchData(key)
}.build()
```
The following will clear the entry associated with the key from the in-memory cache:
```kotlin
store.clear("10")
```
The following will clear all entries from the in-memory cache:
```kotlin
store.clearAll()
```
#### Store with persister
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:
```kotlin
StoreBuilder
.fromNonFlow<String, Int> { key: String ->
api.fetchData(key)
}.persister(
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):
```kotlin
store.clear("10")
```
The following will clear all entries from both the in-memory cache and the persister (source of truth):
```kotlin
store.clearAll()
```

View file

@ -38,23 +38,28 @@ object Graph {
.fromNonFlow { key: String ->
provideRetrofit().fetchSubreddit(key, 10).data.children.map(::toPosts)
}
.persister(reader = db.postDao()::loadPosts,
.persister(
reader = db.postDao()::loadPosts,
writer = db.postDao()::insertPosts,
delete = db.postDao()::clearFeed)
delete = db.postDao()::clearFeedBySubredditName,
deleteAll = db.postDao()::clearAllFeeds
)
.build()
}
fun provideRoomStoreMultiParam(context: SampleApp): Store<Pair<String, RedditConfig>, List<Post>> {
val db = provideRoom(context)
return StoreBuilder
.fromNonFlow<Pair<String, RedditConfig>, List<Post>> { (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().clearFeed(query) })
.build()
.fromNonFlow<Pair<String, RedditConfig>, List<Post>> { (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
)
.build()
}
private fun provideRoom(context: SampleApp): RedditDb {
@ -72,29 +77,34 @@ object Graph {
fun provideConfigStore(context: Context): Store<Unit, RedditConfig> {
val fileSystem = FileSystemFactory.create(context.cacheDir)
val fileSystemPersister = FileSystemPersister.create(fileSystem, object : PathResolver<Unit> {
override fun resolve(key: Unit) = "config.json"
})
val fileSystemPersister =
FileSystemPersister.create(fileSystem, object : PathResolver<Unit> {
override fun resolve(key: Unit) = "config.json"
})
val adapter = moshi.adapter<RedditConfig>(RedditConfig::class.java)
return StoreBuilder
.fromNonFlow<Unit, RedditConfig> {
delay(500)
RedditConfig(10)
.fromNonFlow<Unit, RedditConfig> {
delay(500)
RedditConfig(10)
}
.nonFlowingPersister(
reader = {
runCatching {
val source = fileSystemPersister.read(Unit)
source?.let { adapter.fromJson(it) }
}.getOrNull()
},
writer = { _, _ ->
val buffer = Buffer()
fileSystemPersister.write(Unit, buffer)
}
.nonFlowingPersister(
reader = {
runCatching {
val source = fileSystemPersister.read(Unit)
source?.let { adapter.fromJson(it) }
}.getOrNull()
},
writer = { _, _ ->
val buffer = Buffer()
fileSystemPersister.write(Unit, buffer)
}
)
.cachePolicy(MemoryPolicy.builder().setExpireAfterWrite(10).setExpireAfterTimeUnit(TimeUnit.SECONDS).build())
.build()
)
.cachePolicy(
MemoryPolicy.builder().setExpireAfterWrite(10).setExpireAfterTimeUnit(
TimeUnit.SECONDS
).build()
)
.build()
}
private fun provideRetrofit(): Api {

View file

@ -57,7 +57,7 @@ abstract class PostDao {
@Transaction
open suspend fun insertPosts(subredditName: String, posts: List<Post>) {
// first clear the feed
clearFeed(subredditName)
clearFeedBySubredditName(subredditName)
// convert them into database models
val feedEntities = posts.mapIndexed { index: Int, post: Post ->
FeedEntity(
@ -76,7 +76,10 @@ abstract class PostDao {
}
@Query("DELETE FROM FeedEntity WHERE subredditName = :subredditName")
abstract suspend fun clearFeed(subredditName: String)
abstract suspend fun clearFeedBySubredditName(subredditName: String)
@Query("DELETE FROM FeedEntity")
abstract suspend fun clearAllFeeds()
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insertPosts(

View file

@ -9,7 +9,7 @@ buildscript {
}
ext.versions = [
androidGradlePlugin : '4.0.0-alpha08',
androidGradlePlugin : '4.0.0-alpha09',
dexcountGradlePlugin: '1.0.2',
kotlin : '1.3.61',
dokkaGradlePlugin : '0.10.0',

View file

@ -18,7 +18,7 @@ ext.versions = [
javax : '1',
room : '2.2.2',
coreKtx : '1.1.0',
lifecycle : '2.2.0-rc03',
lifecycle : '2.2.0',
// Testing.
junit : '4.13',

4
cache/README.md vendored
View file

@ -69,7 +69,7 @@ cache.get(1) // returns "bird"
### Cache Loader
**Cache** provides an API for get cached value by key and using the provided `loader: () -> Value` lambda to compute and cache the value automatically if none exists.
**Cache** provides an API for getting cached value by key and using the provided `loader: () -> Value` lambda to compute and cache the value automatically if none exists.
```kotlin
val cache = Cache.Builder.newBuilder().build<Long, User>()
@ -156,7 +156,7 @@ cache.invalidateAll()
### Unit Testing Cache Expirations
To make it easier for testing logics that depend on cache expirations, `Cache.Builder` provides an API for setting a fake implementation of [Clock] for controlling (virtual) time in tests.
To make it easier for testing logics that depend on cache expirations, `Cache.Builder` provides an API for setting a fake implementation of `Clock` for controlling (virtual) time in tests.
First define a custom `Clock` implementation:

View file

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.1-rc-2-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip

View file

@ -38,6 +38,9 @@ repositories {
compileKotlin {
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs += [
'-Xuse-experimental=kotlin.Experimental',
]
}
}
compileTestKotlin {

View file

@ -0,0 +1,10 @@
package com.dropbox.android.external.store4
/**
* Marks declarations that are still **experimental** in store API.
* Declarations marked with this annotation are unstable and subject to change.
*/
@MustBeDocumented
@Retention(value = AnnotationRetention.BINARY)
@Experimental(level = Experimental.Level.WARNING)
annotation class ExperimentalStoreApi

View file

@ -21,14 +21,16 @@ import kotlinx.coroutines.flow.transform
* }
* .persister(reader = { (query, _) -> db.postDao().loadData(query) },
* writer = { (query, _), posts -> db.dataDAO().insertData(query, posts) },
* delete = { (query, _) -> db.dataDAO().clearData(query) })
* delete = { (query, _) -> db.dataDAO().clearData(query) },
* deleteAll = db.postDao()::clearAllFeeds)
* .build()
* //single shot response
*
* // single shot response
* viewModelScope.launch {
* val data = store.fresh(key)
* }
*
* //get cached data and collect future emissions as well
* // get cached data and collect future emissions as well
* viewModelScope.launch {
* val data = store.cached(key, refresh=true)
* .collect{data.value=it }
@ -49,6 +51,14 @@ interface Store<Key : Any, Output : Any> {
* [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store].
*/
suspend fun clear(key: Key)
/**
* Purge all entries from memory and disk cache.
* Persistent storage will only be cleared if a deleteAll function was passed to
* [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store].
*/
@ExperimentalStoreApi
suspend fun clearAll()
}
@ExperimentalCoroutinesApi

View file

@ -55,15 +55,16 @@ interface StoreBuilder<Key : Any, Output : Any> {
fun disableCache(): StoreBuilder<Key, Output>
/**
* Connects a (non-[Flow]) source of truth that is accessible via [reader], [writer] and
* [delete].
* Connects a (non-[Flow]) source of truth that is accessible via [reader], [writer],
* [delete], and [deleteAll].
*
* @see persister
*/
fun <NewOutput : Any> nonFlowingPersister(
reader: suspend (Key) -> NewOutput?,
writer: suspend (Key, Output) -> Unit,
delete: (suspend (Key) -> Unit)? = null
delete: (suspend (Key) -> Unit)? = null,
deleteAll: (suspend () -> Unit)? = null
): StoreBuilder<Key, NewOutput>
/**
@ -93,13 +94,15 @@ interface StoreBuilder<Key : Any, Output : Any> {
* @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
* @param delete deletes records in the source of truth for the give key
* @param deleteAll deletes all records in the source of truth
*
*/
fun <NewOutput : Any> persister(
reader: (Key) -> Flow<NewOutput?>,
writer: suspend (Key, Output) -> Unit,
delete: (suspend (Key) -> Unit)? = null
delete: (suspend (Key) -> Unit)? = null,
deleteAll: (suspend () -> Unit)? = null
): StoreBuilder<Key, NewOutput>
companion object {
@ -187,13 +190,15 @@ private class BuilderImpl<Key : Any, Output : Any>(
override fun <NewOutput : Any> nonFlowingPersister(
reader: suspend (Key) -> NewOutput?,
writer: suspend (Key, Output) -> Unit,
delete: (suspend (Key) -> Unit)?
delete: (suspend (Key) -> Unit)?,
deleteAll: (suspend () -> Unit)?
): BuilderWithSourceOfTruth<Key, Output, NewOutput> {
return withSourceOfTruth(
PersistentNonFlowingSourceOfTruth(
realReader = reader,
realWriter = writer,
realDelete = delete
realDelete = delete,
realDeleteAll = deleteAll
)
)
}
@ -205,7 +210,8 @@ private class BuilderImpl<Key : Any, Output : Any>(
PersistentNonFlowingSourceOfTruth(
realReader = { key -> persister.read(key) },
realWriter = { key, input -> persister.write(key, input) },
realDelete = { error("Delete is not implemented in legacy persisters") }
realDelete = { error("Delete is not implemented in legacy persisters") },
realDeleteAll = { error("Delete all is not implemented in legacy persisters") }
)
return withLegacySourceOfTruth(sourceOfTruth)
}
@ -213,13 +219,15 @@ private class BuilderImpl<Key : Any, Output : Any>(
override fun <NewOutput : Any> persister(
reader: (Key) -> Flow<NewOutput?>,
writer: suspend (Key, Output) -> Unit,
delete: (suspend (Key) -> Unit)?
delete: (suspend (Key) -> Unit)?,
deleteAll: (suspend () -> Unit)?
): BuilderWithSourceOfTruth<Key, Output, NewOutput> {
return withSourceOfTruth(
PersistentSourceOfTruth(
realReader = reader,
realWriter = writer,
realDelete = delete
realDelete = delete,
realDeleteAll = deleteAll
)
)
}
@ -266,13 +274,15 @@ private class BuilderWithSourceOfTruth<Key : Any, Input : Any, Output : Any>(
override fun <NewOutput : Any> persister(
reader: (Key) -> Flow<NewOutput?>,
writer: suspend (Key, Output) -> Unit,
delete: (suspend (Key) -> Unit)?
delete: (suspend (Key) -> Unit)?,
deleteAll: (suspend () -> Unit)?
): StoreBuilder<Key, NewOutput> = error("Multiple persisters are not supported")
override fun <NewOutput : Any> nonFlowingPersister(
reader: suspend (Key) -> NewOutput?,
writer: suspend (Key, Output) -> Unit,
delete: (suspend (Key) -> Unit)?
delete: (suspend (Key) -> Unit)?,
deleteAll: (suspend () -> Unit)?
): StoreBuilder<Key, NewOutput> = error("Multiple persisters are not supported")
override fun nonFlowingPersisterLegacy(persister: Persister<Output, Key>): StoreBuilder<Key, Output> =

View file

@ -17,6 +17,7 @@ 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.MemoryPolicy
import com.dropbox.android.external.store4.ResponseOrigin
import com.dropbox.android.external.store4.Store
@ -120,6 +121,12 @@ internal class RealStore<Key : Any, Input : Any, Output : Any>(
sourceOfTruth?.delete(key)
}
@ExperimentalStoreApi
override suspend fun clearAll() {
memCache?.invalidateAll()
sourceOfTruth?.deleteAll()
}
/**
* We want to stream from disk but also want to refresh. If requested or necessary.
*

View file

@ -30,6 +30,7 @@ internal interface SourceOfTruth<Key, Input, Output> {
fun reader(key: Key): Flow<Output?>
suspend fun write(key: Key, value: Input)
suspend fun delete(key: Key)
suspend fun deleteAll()
// for testing
suspend fun getSize(): Int
}
@ -37,15 +38,23 @@ internal interface SourceOfTruth<Key, Input, Output> {
internal class PersistentSourceOfTruth<Key, Input, Output>(
private val realReader: (Key) -> Flow<Output?>,
private val realWriter: suspend (Key, Input) -> Unit,
private val realDelete: (suspend (Key) -> Unit)? = null
private val realDelete: (suspend (Key) -> Unit)? = null,
private val realDeleteAll: (suspend () -> Unit)? = null
) : SourceOfTruth<Key, Input, Output> {
override val defaultOrigin = ResponseOrigin.Persister
override fun reader(key: Key): Flow<Output?> = realReader(key)
override suspend fun write(key: Key, value: Input) = realWriter(key, value)
override suspend fun delete(key: Key) {
realDelete?.invoke(key)
}
override suspend fun deleteAll() {
realDeleteAll?.invoke()
}
// for testing
override suspend fun getSize(): Int {
throw UnsupportedOperationException("not supported for persistent")
@ -55,18 +64,25 @@ internal class PersistentSourceOfTruth<Key, Input, Output>(
internal class PersistentNonFlowingSourceOfTruth<Key, Input, Output>(
private val realReader: suspend (Key) -> Output?,
private val realWriter: suspend (Key, Input) -> Unit,
private val realDelete: (suspend (Key) -> Unit)? = null
private val realDelete: (suspend (Key) -> Unit)? = null,
private val realDeleteAll: (suspend () -> Unit)?
) : SourceOfTruth<Key, Input, Output> {
override val defaultOrigin = ResponseOrigin.Persister
override fun reader(key: Key): Flow<Output?> = flow {
emit(realReader(key))
}
override suspend fun write(key: Key, value: Input) = realWriter(key, value)
override suspend fun delete(key: Key) {
realDelete?.invoke(key)
}
override suspend fun deleteAll() {
realDeleteAll?.invoke()
}
// for testing
override suspend fun getSize(): Int {
throw UnsupportedOperationException("not supported for persistent")

View file

@ -103,6 +103,10 @@ internal class SourceOfTruthWithBarrier<Key, Input, Output>(
delegate.delete(key)
}
suspend fun deleteAll() {
delegate.deleteAll()
}
private sealed class BarrierMsg(
val version: Long
) {

View file

@ -1,63 +0,0 @@
package com.dropbox.android.external.store3
import com.dropbox.android.external.store4.get
import com.dropbox.android.external.store4.legacy.BarCode
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.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@FlowPreview
@ExperimentalCoroutinesApi
@RunWith(Parameterized::class)
class ClearStoreMemoryTest(
storeType: TestStoreType
) {
private val testScope = TestCoroutineScope()
private var networkCalls = 0
private val store = TestStoreBuilder.from<BarCode, Int>(testScope) {
networkCalls++
}.build(storeType)
@Test
fun testClearSingleBarCode() = testScope.runBlockingTest {
// one request should produce one call
val barcode = BarCode("type", "key")
store.get(barcode)
assertThat(networkCalls).isEqualTo(1)
// after clearing the memory another call should be made
store.clear(barcode)
store.get(barcode)
assertThat(networkCalls).isEqualTo(2)
}
@Test
fun testClearAllBarCodes() = testScope.runBlockingTest {
val b1 = BarCode("type1", "key1")
val b2 = BarCode("type2", "key2")
// each request should produce one call
store.get(b1)
store.get(b2)
assertThat(networkCalls).isEqualTo(2)
store.clear(b1)
store.clear(b2)
// after everything is cleared each request should produce another 2 calls
store.get(b1)
store.get(b2)
assertThat(networkCalls).isEqualTo(4)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun params() = TestStoreType.values()
}
}

View file

@ -1,100 +0,0 @@
package com.dropbox.android.external.store3
import com.dropbox.android.external.store4.get
import com.dropbox.android.external.store4.legacy.BarCode
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.whenever
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.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.mockito.Mockito.verify
import java.util.concurrent.atomic.AtomicInteger
@FlowPreview
@ExperimentalCoroutinesApi
@RunWith(Parameterized::class)
class ClearStoreTest(
storeType: TestStoreType
) {
private val testScope = TestCoroutineScope()
private val persister: ClearingPersister = mock()
private val networkCalls = AtomicInteger(0)
private val store = TestStoreBuilder.from(
scope = testScope,
fetcher = {
networkCalls.incrementAndGet()
},
persister = persister
).build(storeType)
@Test
fun testClearSingleBarCode() = testScope.runBlockingTest {
// one request should produce one call
val barcode = BarCode("type", "key")
whenever(persister.read(barcode))
.thenReturn(null) // read from disk on get
.thenReturn(1) // read from disk after fetching from network
.thenReturn(null) // read from disk after clearing
.thenReturn(1) // read from disk after making additional network call
whenever(persister.write(barcode, 1)).thenReturn(true)
whenever(persister.write(barcode, 2)).thenReturn(true)
store.get(barcode)
assertThat(networkCalls.toInt()).isEqualTo(1)
// after clearing the memory another call should be made
store.clear(barcode)
store.get(barcode)
verify(persister).clear(barcode)
assertThat(networkCalls.toInt()).isEqualTo(2)
}
@Test
fun testClearAllBarCodes() = testScope.runBlockingTest {
val barcode1 = BarCode("type1", "key1")
val barcode2 = BarCode("type2", "key2")
whenever(persister.read(barcode1))
.thenReturn(null) // read from disk
.thenReturn(1) // read from disk after fetching from network
.thenReturn(null) // read from disk after clearing disk cache
.thenReturn(1) // read from disk after making additional network call
whenever(persister.write(barcode1, 1)).thenReturn(true)
whenever(persister.write(barcode1, 2)).thenReturn(true)
whenever(persister.read(barcode2))
.thenReturn(null) // read from disk
.thenReturn(1) // read from disk after fetching from network
.thenReturn(null) // read from disk after clearing disk cache
.thenReturn(1) // read from disk after making additional network call
whenever(persister.write(barcode2, 1)).thenReturn(true)
whenever(persister.write(barcode2, 2)).thenReturn(true)
// each request should produce one call
store.get(barcode1)
store.get(barcode2)
assertThat(networkCalls.toInt()).isEqualTo(2)
store.clear(barcode1)
store.clear(barcode2)
// after everything is cleared each request should produce another 2 calls
store.get(barcode1)
store.get(barcode2)
assertThat(networkCalls.toInt()).isEqualTo(4)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun params() = TestStoreType.values()
}
}

View file

@ -1,11 +0,0 @@
package com.dropbox.android.external.store3
/**
* Persisters should implement Clearable if they want store.clear(key) to also clear the persister
*
* @param <T> Type of key/request param in store
*/
@Deprecated("") // used in tests
interface Clearable<T> {
fun clear(key: T)
}

View file

@ -1,18 +0,0 @@
package com.dropbox.android.external.store3
import com.dropbox.android.external.store4.Persister
import com.dropbox.android.external.store4.legacy.BarCode
open class ClearingPersister : Persister<Int, BarCode>, Clearable<BarCode> {
override suspend fun read(key: BarCode): Int? {
throw RuntimeException()
}
override suspend fun write(key: BarCode, raw: Int): Boolean {
throw RuntimeException()
}
override fun clear(key: BarCode) {
throw RuntimeException()
}
}

View file

@ -144,9 +144,6 @@ data class TestStoreBuilder<Key : Any, Output : Any>(
},
realWriter = { key, value ->
persister.write(key, value)
},
realDelete = { key ->
(persister as? Clearable<Key>)?.clear(key)
}
)
}

View file

@ -15,11 +15,12 @@
*/
package com.dropbox.android.external.store4
import com.dropbox.android.external.store4.impl.FlowStoreTest
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
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
@ -29,14 +30,13 @@ import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.TestCoroutineScope
import kotlinx.coroutines.test.runBlockingTest
import com.google.common.truth.Truth.assertThat
import org.junit.Test
@FlowPreview
@ExperimentalCoroutinesApi
class SourceOfTruthWithBarrierTest {
private val testScope = TestCoroutineScope()
private val persister = FlowStoreTest.InMemoryPersister<Int, String>()
private val persister = InMemoryPersister<Int, String>()
private val delegate: SourceOfTruth<Int, String, String> =
PersistentSourceOfTruth(
realReader = { key ->

View file

@ -0,0 +1,169 @@
package com.dropbox.android.external.store4.impl
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.testutil.InMemoryPersister
import com.dropbox.android.external.store4.testutil.getData
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 org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
@FlowPreview
@ExperimentalCoroutinesApi
@ExperimentalStoreApi
@RunWith(JUnit4::class)
class ClearAllStoreTest {
private val testScope = TestCoroutineScope()
private val key1 = "key1"
private val key2 = "key2"
private val value1 = 1
private val value2 = 2
private val fetcher: suspend (key: String) -> Int = { key: String ->
when (key) {
key1 -> value1
key2 -> value2
else -> throw IllegalStateException("Unknown key")
}
}
private val persister = InMemoryPersister<String, Int>()
@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
).scope(testScope)
.disableCache()
.nonFlowingPersister(
reader = persister::read,
writer = persister::write,
deleteAll = persister::deleteAll
)
.build()
// should receive data from network first time
assertThat(store.getData(key1))
.isEqualTo(
Data(
origin = ResponseOrigin.Fetcher,
value = value1
)
)
assertThat(store.getData(key2))
.isEqualTo(
Data(
origin = ResponseOrigin.Fetcher,
value = value2
)
)
// should receive data from persister
assertThat(store.getData(key1))
.isEqualTo(
Data(
origin = ResponseOrigin.Persister,
value = value1
)
)
assertThat(store.getData(key2))
.isEqualTo(
Data(
origin = ResponseOrigin.Persister,
value = value2
)
)
// clear all entries in store
store.clearAll()
assertThat(persister.peekEntry(key1))
.isNull()
assertThat(persister.peekEntry(key2))
.isNull()
// should fetch data from network again
assertThat(store.getData(key1))
.isEqualTo(
Data(
origin = ResponseOrigin.Fetcher,
value = value1
)
)
assertThat(store.getData(key2))
.isEqualTo(
Data(
origin = ResponseOrigin.Fetcher,
value = value2
)
)
}
@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(
fetcher = fetcher
).scope(testScope).build()
// should receive data from network first time
assertThat(store.getData(key1))
.isEqualTo(
Data(
origin = ResponseOrigin.Fetcher,
value = value1
)
)
assertThat(store.getData(key2))
.isEqualTo(
Data(
origin = ResponseOrigin.Fetcher,
value = value2
)
)
// should receive data from cache
assertThat(store.getData(key1))
.isEqualTo(
Data(
origin = ResponseOrigin.Cache,
value = value1
)
)
assertThat(store.getData(key2))
.isEqualTo(
Data(
origin = ResponseOrigin.Cache,
value = value2
)
)
// clear all entries in store
store.clearAll()
// should fetch data from network again
assertThat(store.getData(key1))
.isEqualTo(
Data(
origin = ResponseOrigin.Fetcher,
value = value1
)
)
assertThat(store.getData(key2))
.isEqualTo(
Data(
origin = ResponseOrigin.Fetcher,
value = value2
)
)
}
}

View file

@ -0,0 +1,171 @@
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.testutil.InMemoryPersister
import com.dropbox.android.external.store4.testutil.getData
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 org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
@FlowPreview
@ExperimentalCoroutinesApi
@RunWith(JUnit4::class)
class ClearStoreByKeyTest {
private val testScope = TestCoroutineScope()
private val persister = InMemoryPersister<String, Int>()
@Test
fun `calling clear(key) on store with persister (no in-memory cache) deletes the entry associated with the key from the persister`() =
testScope.runBlockingTest {
val key = "key"
val value = 1
val store = StoreBuilder.fromNonFlow<String, Int>(
fetcher = { value }
).scope(testScope)
.disableCache()
.nonFlowingPersister(
reader = persister::read,
writer = persister::write,
delete = persister::deleteByKey
)
.build()
// should receive data from network first time
assertThat(store.getData(key))
.isEqualTo(
Data(
origin = ResponseOrigin.Fetcher,
value = value
)
)
// should receive data from persister
assertThat(store.getData(key))
.isEqualTo(
Data(
origin = ResponseOrigin.Persister,
value = value
)
)
// clear store entry by key
store.clear(key)
assertThat(persister.peekEntry(key))
.isNull()
// should fetch data from network again
assertThat(store.getData(key))
.isEqualTo(
Data(
origin = ResponseOrigin.Fetcher,
value = value
)
)
}
@Test
fun `calling clear(key) on store with in-memory cache (no persister) deletes the entry associated with the key from the in-memory cache`() =
testScope.runBlockingTest {
val key = "key"
val value = 1
val store = StoreBuilder.fromNonFlow<String, Int>(
fetcher = { value }
).scope(testScope).build()
// should receive data from network first time
assertThat(store.getData(key))
.isEqualTo(
Data(
origin = ResponseOrigin.Fetcher,
value = value
)
)
// should receive data from cache
assertThat(store.getData(key))
.isEqualTo(
Data(
origin = ResponseOrigin.Cache,
value = value
)
)
// clear store entry by key
store.clear(key)
// should fetch data from network again
assertThat(store.getData(key))
.isEqualTo(
Data(
origin = ResponseOrigin.Fetcher,
value = value
)
)
}
@Test
fun `calling clear(key) on store has no effect on existing entries associated with other keys in the in-memory cache or persister`() =
testScope.runBlockingTest {
val key1 = "key1"
val key2 = "key2"
val value1 = 1
val value2 = 2
val store = StoreBuilder.fromNonFlow<String, Int>(
fetcher = { key ->
when (key) {
key1 -> value1
key2 -> value2
else -> throw IllegalStateException("Unknown key")
}
}
).scope(testScope)
.nonFlowingPersister(
reader = persister::read,
writer = persister::write,
delete = persister::deleteByKey
)
.build()
// get data for both keys
store.getData(key1)
store.getData(key2)
// clear store entry for key1
store.clear(key1)
// entry for key1 is gone
assertThat(persister.peekEntry(key1))
.isNull()
// entry for key2 should still exists
assertThat(persister.peekEntry(key2))
.isEqualTo(value2)
// getting data for key1 should hit the network again
assertThat(store.getData(key1))
.isEqualTo(
Data(
origin = ResponseOrigin.Fetcher,
value = value1
)
)
// getting data for key2 should not hit the network
assertThat(store.getData(key2))
.isEqualTo(
Data(
origin = ResponseOrigin.Cache,
value = value2
)
)
}
}

View file

@ -25,6 +25,10 @@ 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.testutil.FakeFetcher
import com.dropbox.android.external.store4.testutil.InMemoryPersister
import com.dropbox.android.external.store4.testutil.asFlowable
import com.dropbox.android.external.store4.testutil.assertThat
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
@ -42,7 +46,6 @@ import kotlinx.coroutines.test.runBlockingTest
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import kotlin.collections.set
@FlowPreview
@ExperimentalCoroutinesApi
@ -327,7 +330,7 @@ class FlowStoreTest {
@Test
fun diskChangeWhileNetworkIsFlowing_simple() = testScope.runBlockingTest {
val persister = InMemoryPersister<Int, String>().asObservable()
val persister = InMemoryPersister<Int, String>().asFlowable()
val pipeline = build(
flowingFetcher = {
flow {
@ -356,7 +359,7 @@ class FlowStoreTest {
@Test
fun diskChangeWhileNetworkIsFlowing_overwrite() = testScope.runBlockingTest {
val persister = InMemoryPersister<Int, String>().asObservable()
val persister = InMemoryPersister<Int, String>().asFlowable()
val pipeline = build(
flowingFetcher = {
flow {
@ -403,7 +406,7 @@ class FlowStoreTest {
@Test
fun errorTest() = testScope.runBlockingTest {
val exception = IllegalArgumentException("wow")
val persister = InMemoryPersister<Int, String>().asObservable()
val persister = InMemoryPersister<Int, String>().asFlowable()
val pipeline = build(
nonFlowingFetcher = {
throw exception
@ -662,23 +665,6 @@ class FlowStoreTest {
}
}
class InMemoryPersister<Key, Output> {
private val data = mutableMapOf<Key, Output>()
@Suppress("RedundantSuspendModifier") // for function reference
suspend fun read(key: Key) = data[key]
@Suppress("RedundantSuspendModifier") // for function reference
suspend fun write(key: Key, output: Output) {
data[key] = output
}
suspend fun asObservable() = SimplePersisterAsFlowable(
reader = this::read,
writer = this::write
)
}
private fun <Key : Any, Input : Any, Output : Any> build(
nonFlowingFetcher: (suspend (Key) -> Input)? = null,
flowingFetcher: ((Key) -> Flow<Input>)? = null,

View file

@ -15,6 +15,8 @@
*/
package com.dropbox.android.external.store4.impl
import com.dropbox.android.external.store4.testutil.KeyTracker
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
@ -23,7 +25,6 @@ import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
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.JUnit4
@ -40,8 +41,8 @@ class KeyTrackerTest {
fun dontSkipInvalidations() = scope1.runBlockingTest {
val collection = scope2.async {
subject.keyFlow('b')
.take(2)
.toList()
.take(2)
.toList()
}
scope2.advanceUntilIdle()
assertThat(subject.activeKeyCount()).isEqualTo(1)
@ -60,8 +61,8 @@ class KeyTrackerTest {
val collections = keys.associate { key ->
key to scope2.async {
subject.keyFlow(key)
.take(2)
.toList()
.take(2)
.toList()
}
}
scope2.advanceUntilIdle()
@ -83,8 +84,8 @@ class KeyTrackerTest {
val collections = (0..4).map {
scope2.async {
subject.keyFlow('b')
.take(2)
.toList()
.take(2)
.toList()
}
}
scope2.advanceUntilIdle()

View file

@ -19,6 +19,8 @@ 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.testutil.FakeFetcher
import com.dropbox.android.external.store4.testutil.assertThat
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview

View file

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.dropbox.android.external.store4.impl
package com.dropbox.android.external.store4.testutil
import com.google.common.truth.Truth.assertThat

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.dropbox.android.external.store4.impl
package com.dropbox.android.external.store4.testutil
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi

View file

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.dropbox.android.external.store4.impl
package com.dropbox.android.external.store4.testutil
import com.google.common.truth.FailureMetadata
import com.google.common.truth.Subject

View file

@ -0,0 +1,30 @@
package com.dropbox.android.external.store4.testutil
/**
* An in-memory non-flowing persister for testing.
*/
class InMemoryPersister<Key, Output> {
private val data = mutableMapOf<Key, Output>()
@Suppress("RedundantSuspendModifier") // for function reference
suspend fun read(key: Key) = data[key]
@Suppress("RedundantSuspendModifier") // for function reference
suspend fun write(key: Key, output: Output) {
data[key] = output
}
@Suppress("RedundantSuspendModifier") // for function reference
suspend fun deleteByKey(key: Key) {
data.remove(key)
}
@Suppress("RedundantSuspendModifier") // for function reference
suspend fun deleteAll() {
data.clear()
}
fun peekEntry(key: Key): Output? {
return data[key]
}
}

View file

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.dropbox.android.external.store4.impl
package com.dropbox.android.external.store4.testutil
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BroadcastChannel
@ -89,10 +89,10 @@ internal class KeyTracker<Key> {
val keyChannel = lock.withLock {
channels.getOrPut(key) {
KeyChannel(
channel = BroadcastChannel<Unit>(Channel.CONFLATED).apply {
// start w/ an initial value.
offer(Unit)
}
channel = BroadcastChannel<Unit>(Channel.CONFLATED).apply {
// start w/ an initial value.
offer(Unit)
}
)
}.also {
it.acquire() // refcount
@ -130,3 +130,9 @@ internal class KeyTracker<Key> {
}
}
}
@ExperimentalCoroutinesApi
suspend fun <Key, Output> InMemoryPersister<Key, Output>.asFlowable() = SimplePersisterAsFlowable(
reader = this::read,
writer = this::write
)

View file

@ -13,9 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.dropbox.android.external.store4.impl
package com.dropbox.android.external.store4.testutil
import com.dropbox.android.external.store4.legacy.BarCode
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
@ -26,7 +27,6 @@ import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestCoroutineScope
import kotlinx.coroutines.test.runBlockingTest
import com.google.common.truth.Truth.assertThat
import org.junit.Test
@ExperimentalCoroutinesApi
@ -49,14 +49,14 @@ class SimplePersisterAsFlowableTest {
val collectedValues = CompletableDeferred<List<String?>>()
otherScope.launch {
collectedValues.complete(flowable
.flowReader(barcode)
.onEach {
if (collectedFirst.isActive) {
collectedFirst.complete(Unit)
}
.flowReader(barcode)
.onEach {
if (collectedFirst.isActive) {
collectedFirst.complete(Unit)
}
.take(2)
.toList())
}
.take(2)
.toList())
}
collectedFirst.await()
flowable.flowWriter(barcode, "x")
@ -84,18 +84,18 @@ class SimplePersisterAsFlowableTest {
var readIndex = 0
val written = mutableListOf<String>()
return SimplePersisterAsFlowable<BarCode, String, String>(
reader = {
if (readIndex >= values.size) {
throw AssertionError("should not've read this many")
}
values[readIndex++]
},
writer = { _: BarCode, value: String ->
written.add(value)
},
delete = {
TODO("not implemented")
reader = {
if (readIndex >= values.size) {
throw AssertionError("should not've read this many")
}
values[readIndex++]
},
writer = { _: BarCode, value: String ->
written.add(value)
},
delete = {
TODO("not implemented")
}
) to written
}
}

View file

@ -0,0 +1,20 @@
package com.dropbox.android.external.store4.testutil
import com.dropbox.android.external.store4.Store
import com.dropbox.android.external.store4.StoreRequest
import com.dropbox.android.external.store4.StoreResponse
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.first
/**
* Helper factory that will return [StoreResponse.Data] for [key]
* if it is cached otherwise will return fresh/network data (updating your caches)
*/
suspend fun <Key : Any, Output : Any> Store<Key, Output>.getData(key: Key) =
stream(
StoreRequest.cached(key, refresh = false)
).filterNot {
it is StoreResponse.Loading
}.first().let {
StoreResponse.Data(it.requireData(), it.origin)
}