Implement upsert and shell entity cnvention in data layer

Change-Id: I200f0bbb22d757484f7beae6d49ccd54ee548a8f
pull/2/head
Adetunji Dahunsi 3 years ago committed by Don Turner
parent ab7b25ef6a
commit 04157c37da

@ -0,0 +1,387 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "f593c030a1a8b5af8e13c6ac6a0926a9",
"entities": [
{
"tableName": "authors",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `image_url` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_authors_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_authors_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
},
{
"tableName": "episodes_authors",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episode_id` INTEGER NOT NULL, `author_id` INTEGER NOT NULL, PRIMARY KEY(`episode_id`, `author_id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "episodeId",
"columnName": "episode_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorId",
"columnName": "author_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"episode_id",
"author_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": [
{
"table": "episodes",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"episode_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "authors",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"author_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "episodes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `publish_date` INTEGER NOT NULL, `alternate_video` TEXT, `alternate_audio` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "publishDate",
"columnName": "publish_date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alternateVideo",
"columnName": "alternate_video",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "alternateAudio",
"columnName": "alternate_audio",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "news_resources_authors",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` INTEGER NOT NULL, `author_id` INTEGER NOT NULL, PRIMARY KEY(`news_resource_id`, `author_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "newsResourceId",
"columnName": "news_resource_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorId",
"columnName": "author_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"news_resource_id",
"author_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": [
{
"table": "news_resources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"news_resource_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "authors",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"author_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "news_resources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `episode_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "episodeId",
"columnName": "episode_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "headerImageUrl",
"columnName": "header_image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "publishDate",
"columnName": "publish_date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": [
{
"table": "episodes",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"episode_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "news_resources_topics",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` INTEGER NOT NULL, `topic_id` INTEGER NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "newsResourceId",
"columnName": "news_resource_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "topicId",
"columnName": "topic_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"news_resource_id",
"topic_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": [
{
"table": "news_resources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"news_resource_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "topics",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"topic_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "topics",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shortDescription",
"columnName": "shortDescription",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "longDescription",
"columnName": "longDescription",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "imageUrl",
"columnName": "imageUrl",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_topics_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_topics_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f593c030a1a8b5af8e13c6ac6a0926a9')"
]
}
}

@ -20,11 +20,11 @@ import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.google.samples.apps.nowinandroid.core.database.NiADatabase
import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.database.model.episodeEntityShell
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
@ -76,10 +76,10 @@ class NewsResourceDaoTest {
.map(NewsResourceEntity::episodeEntityShell)
.distinct()
episodeDao.saveEpisodeEntities(
episodeDao.insertOrIgnoreEpisodes(
episodeEntityShells
)
newsResourceDao.saveNewsResourceEntities(
newsResourceDao.upsertNewsResources(
newsResourceEntities
)
@ -134,16 +134,16 @@ class NewsResourceDaoTest {
)
}
topicDao.saveTopics(
topicEntities
topicDao.insertOrIgnoreTopics(
topicEntities = topicEntities
)
episodeDao.saveEpisodeEntities(
episodeEntityShells
episodeDao.insertOrIgnoreEpisodes(
episodeEntities = episodeEntityShells
)
newsResourceDao.saveNewsResourceEntities(
newsResourceDao.upsertNewsResources(
newsResourceEntities
)
newsResourceDao.saveTopicCrossRefEntities(
newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossRefEntities
)
@ -182,3 +182,11 @@ private fun testNewsResource(
publishDate = Instant.fromEpochMilliseconds(millisSinceEpoch),
type = NewsResourceType.DAC,
)
private fun NewsResourceEntity.episodeEntityShell() = EpisodeEntity(
id = episodeId,
name = "",
publishDate = Instant.fromEpochMilliseconds(0),
alternateVideo = null,
alternateAudio = null,
)

@ -44,10 +44,11 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
NewsResourceTopicCrossRef::class,
TopicEntity::class,
],
version = 3,
version = 4,
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3, spec = DatabaseMigrations.Schema2to3::class),
AutoMigration(from = 3, to = 4),
],
exportSchema = true,
)

@ -18,7 +18,10 @@ package com.google.samples.apps.nowinandroid.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import kotlinx.coroutines.flow.Flow
@ -28,8 +31,27 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface AuthorDao {
@Query(value = "SELECT * FROM authors")
fun getAuthorsStream(): Flow<List<AuthorEntity>>
fun getAuthorEntitiesStream(): Flow<List<AuthorEntity>>
@Insert
suspend fun saveAuthorEntities(entities: List<AuthorEntity>)
/**
* Inserts [authorEntities] into the db if they don't exist, and ignores those that do
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreAuthors(authorEntities: List<AuthorEntity>): List<Long>
/**
* Updates [entities] in the db that match the primary key, and no-ops if they don't
*/
@Update
suspend fun updateAuthors(entities: List<AuthorEntity>)
/**
* Inserts or updates [entities] in the db under the specified primary keys
*/
@Transaction
suspend fun upsertAuthors(entities: List<AuthorEntity>) = upsert(
items = entities,
insertMany = ::insertOrIgnoreAuthors,
updateMany = ::updateAuthors
)
}

@ -20,6 +20,8 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedEpisode
import com.google.samples.apps.nowinandroid.core.model.data.Episode
@ -33,7 +35,25 @@ interface EpisodeDao {
@Query(value = "SELECT * FROM episodes")
fun getEpisodesStream(): Flow<List<PopulatedEpisode>>
// TODO: Perform a proper upsert. See: b/226916817
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveEpisodeEntities(entities: List<EpisodeEntity>)
/**
* Inserts [episodeEntities] into the db if they don't exist, and ignores those that do
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreEpisodes(episodeEntities: List<EpisodeEntity>): List<Long>
/**
* Updates [entities] in the db that match the primary key, and no-ops if they don't
*/
@Update
suspend fun updateEpisodes(entities: List<EpisodeEntity>)
/**
* Inserts or updates [entities] in the db under the specified primary keys
*/
@Transaction
suspend fun upsertEpisodes(entities: List<EpisodeEntity>) = upsert(
items = entities,
insertMany = ::insertOrIgnoreEpisodes,
updateMany = ::updateEpisodes
)
}

@ -20,6 +20,9 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
@ -52,11 +55,35 @@ interface NewsResourceDao {
)
fun getNewsResourcesStream(filterTopicIds: Set<Int>): Flow<List<PopulatedNewsResource>>
// TODO: Perform a proper upsert. See: b/226916817
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveNewsResourceEntities(entities: List<NewsResourceEntity>)
/**
* Inserts [entities] into the db if they don't exist, and ignores those that do
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreNewsResources(entities: List<NewsResourceEntity>): List<Long>
// TODO: Perform a proper upsert. See: b/226916817
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveTopicCrossRefEntities(entities: List<NewsResourceTopicCrossRef>)
/**
* Updates [entities] in the db that match the primary key, and no-ops if they don't
*/
@Update
suspend fun updateNewsResources(entities: List<NewsResourceEntity>)
/**
* Inserts or updates [newsResourceEntities] in the db under the specified primary keys
*/
@Transaction
suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>) = upsert(
items = newsResourceEntities,
insertMany = ::insertOrIgnoreNewsResources,
updateMany = ::updateNewsResources
)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>
)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreAuthorCrossRefEntities(
newsResourceAuthorCrossReferences: List<NewsResourceAuthorCrossRef>
)
}

@ -20,6 +20,8 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import kotlinx.coroutines.flow.Flow
@ -39,7 +41,25 @@ interface TopicDao {
)
fun getTopicEntitiesStream(ids: Set<Int>): Flow<List<TopicEntity>>
// TODO: Perform a proper upsert. See: b/226916817
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveTopics(entities: List<TopicEntity>)
/**
* Inserts [topicEntities] into the db if they don't exist, and ignores those that do
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreTopics(topicEntities: List<TopicEntity>): List<Long>
/**
* Updates [entities] in the db that match the primary key, and no-ops if they don't
*/
@Update
suspend fun updateTopics(entities: List<TopicEntity>)
/**
* Inserts or updates [entities] in the db under the specified primary keys
*/
@Transaction
suspend fun upsertTopics(entities: List<TopicEntity>) = upsert(
items = entities,
insertMany = ::insertOrIgnoreTopics,
updateMany = ::updateTopics
)
}

@ -0,0 +1,37 @@
/*
* Copyright 2022 The Android Open Source Project
*
* 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.google.samples.apps.nowinandroid.core.database.dao
/**
* Performs an upsert by first attempting to insert [items] using [insertMany] with the the result
* of the inserts returned.
*
* Items that were not inserted due to conflicts are then updated using [updateMany]
*/
suspend fun <T> upsert(
items: List<T>,
insertMany: suspend (List<T>) -> List<Long>,
updateMany: suspend (List<T>) -> Unit,
) {
val insertResults = insertMany(items)
val updateList = items.zip(insertResults)
.mapNotNull { (item, insertResult) ->
if (insertResult == -1L) item else null
}
if (updateList.isNotEmpty()) updateMany(updateList)
}

@ -45,5 +45,5 @@ data class NewsResourceAuthorCrossRef(
@ColumnInfo(name = "news_resource_id")
val newsResourceId: Int,
@ColumnInfo(name = "author_id")
val authorId: Long,
val authorId: Int,
)

@ -66,15 +66,3 @@ fun NewsResourceEntity.asExternalModel() = NewsResource(
authors = listOf(),
topics = listOf()
)
/**
* A shell [EpisodeEntity] to fulfill the foreign key constraint when inserting
* a [NewsResourceEntity] into the DB
*/
fun NewsResourceEntity.episodeEntityShell() = EpisodeEntity(
id = episodeId,
name = "",
publishDate = Instant.fromEpochMilliseconds(0),
alternateVideo = null,
alternateAudio = null,
)

@ -16,8 +16,12 @@
package com.google.samples.apps.nowinandroid.core.domain.model
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded
@ -43,11 +47,59 @@ fun NetworkNewsResourceExpanded.asEntity() = NewsResourceEntity(
type = type,
)
/**
* A shell [EpisodeEntity] to fulfill the foreign key constraint when inserting
* a [NewsResourceEntity] into the DB
*/
fun NetworkNewsResource.episodeEntityShell() = EpisodeEntity(
id = episodeId,
name = "",
publishDate = publishDate,
alternateVideo = null,
alternateAudio = null,
)
/**
* A shell [AuthorEntity] to fulfill the foreign key constraint when inserting
* a [NewsResourceEntity] into the DB
*/
fun NetworkNewsResource.authorEntityShells() =
authors.map { authorId ->
AuthorEntity(
id = authorId,
name = "",
imageUrl = "",
)
}
/**
* A shell [TopicEntity] to fulfill the foreign key constraint when inserting
* a [NewsResourceEntity] into the DB
*/
fun NetworkNewsResource.topicEntityShells() =
topics.map { topicId ->
TopicEntity(
id = topicId,
name = "",
url = "",
imageUrl = "",
shortDescription = "",
longDescription = "",
)
}
fun NetworkNewsResource.topicCrossReferences(): List<NewsResourceTopicCrossRef> =
topics.map { topicId ->
NewsResourceTopicCrossRef(
newsResourceId = id,
topicId = topicId
)
}
fun NetworkNewsResource.authorCrossReferences(): List<NewsResourceAuthorCrossRef> =
authors.map { authorId ->
NewsResourceAuthorCrossRef(
newsResourceId = id,
authorId = authorId
)
}

@ -16,15 +16,21 @@
package com.google.samples.apps.nowinandroid.core.domain.repository
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
import com.google.samples.apps.nowinandroid.core.database.dao.EpisodeDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.database.model.episodeEntityShell
import com.google.samples.apps.nowinandroid.core.domain.model.asEntity
import com.google.samples.apps.nowinandroid.core.domain.model.authorCrossReferences
import com.google.samples.apps.nowinandroid.core.domain.model.authorEntityShells
import com.google.samples.apps.nowinandroid.core.domain.model.episodeEntityShell
import com.google.samples.apps.nowinandroid.core.domain.model.topicCrossReferences
import com.google.samples.apps.nowinandroid.core.domain.model.topicEntityShells
import com.google.samples.apps.nowinandroid.core.domain.suspendRunCatching
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.network.NiANetwork
@ -39,6 +45,8 @@ import kotlinx.coroutines.flow.map
class LocalNewsRepository @Inject constructor(
private val newsResourceDao: NewsResourceDao,
private val episodeDao: EpisodeDao,
private val authorDao: AuthorDao,
private val topicDao: TopicDao,
private val network: NiANetwork,
) : NewsRepository {
@ -50,35 +58,44 @@ class LocalNewsRepository @Inject constructor(
newsResourceDao.getNewsResourcesStream(filterTopicIds = filterTopicIds)
.map { it.map(PopulatedNewsResource::asExternalModel) }
// TODO: Pass change list for incremental sync. See b/227206738
override suspend fun sync() = suspendRunCatching {
val networkNewsResources = network.getNewsResources()
val newsResourceEntities = networkNewsResources
.map(NetworkNewsResource::asEntity)
val episodeEntityShells = newsResourceEntities
.map(NewsResourceEntity::episodeEntityShell)
.distinctBy(EpisodeEntity::id)
val topicCrossReferences = networkNewsResources
.map(NetworkNewsResource::topicCrossReferences)
.distinct()
.flatten()
// Order of invocation matters to satisfy id and foreign key constraints!
// TODO: Create a separate method for saving shells with proper conflict resolution
// See: b/226919874
episodeDao.saveEpisodeEntities(
episodeEntityShells
topicDao.insertOrIgnoreTopics(
topicEntities = networkNewsResources
.map(NetworkNewsResource::topicEntityShells)
.flatten()
.distinctBy(TopicEntity::id)
)
newsResourceDao.saveNewsResourceEntities(
newsResourceEntities
authorDao.insertOrIgnoreAuthors(
authorEntities = networkNewsResources
.map(NetworkNewsResource::authorEntityShells)
.flatten()
.distinctBy(AuthorEntity::id)
)
newsResourceDao.saveTopicCrossRefEntities(
topicCrossReferences
episodeDao.insertOrIgnoreEpisodes(
episodeEntities = networkNewsResources
.map(NetworkNewsResource::episodeEntityShell)
.distinctBy(EpisodeEntity::id)
)
newsResourceDao.upsertNewsResources(
newsResourceEntities = networkNewsResources
.map(NetworkNewsResource::asEntity)
)
newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences = networkNewsResources
.map(NetworkNewsResource::topicCrossReferences)
.distinct()
.flatten()
)
newsResourceDao.insertOrIgnoreAuthorCrossRefEntities(
newsResourceAuthorCrossReferences = networkNewsResources
.map(NetworkNewsResource::authorCrossReferences)
.distinct()
.flatten()
)
// TODO: Save author as well
}.isSuccess
}

@ -50,10 +50,11 @@ class LocalTopicsRepository @Inject constructor(
override fun getFollowedTopicIdsStream() = niaPreferences.followedTopicIds
// TODO: Pass change list for incremental sync. See b/227206738
override suspend fun sync(): Boolean = suspendRunCatching {
val networkTopics = network.getTopics()
topicDao.saveTopics(
networkTopics.map(NetworkTopic::asEntity)
topicDao.upsertTopics(
entities = networkTopics.map(NetworkTopic::asEntity)
)
}.isSuccess
}

@ -16,17 +16,24 @@
package com.google.samples.apps.nowinandroid.core.domain.repository
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedEpisode
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.database.model.episodeEntityShell
import com.google.samples.apps.nowinandroid.core.domain.model.asEntity
import com.google.samples.apps.nowinandroid.core.domain.model.authorCrossReferences
import com.google.samples.apps.nowinandroid.core.domain.model.authorEntityShells
import com.google.samples.apps.nowinandroid.core.domain.model.episodeEntityShell
import com.google.samples.apps.nowinandroid.core.domain.model.topicCrossReferences
import com.google.samples.apps.nowinandroid.core.domain.model.topicEntityShells
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestAuthorDao
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestEpisodeDao
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestNewsResourceDao
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestNiaNetwork
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestTopicDao
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.filteredTopicIds
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.nonPresentTopicIds
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
@ -45,18 +52,26 @@ class LocalNewsRepositoryTest {
private lateinit var episodeDao: TestEpisodeDao
private lateinit var authorDao: TestAuthorDao
private lateinit var topicDao: TestTopicDao
private lateinit var network: TestNiaNetwork
@Before
fun setup() {
newsResourceDao = TestNewsResourceDao()
episodeDao = TestEpisodeDao()
authorDao = TestAuthorDao()
topicDao = TestTopicDao()
network = TestNiaNetwork()
subject = LocalNewsRepository(
newsResourceDao = newsResourceDao,
episodeDao = episodeDao,
network = network
authorDao = authorDao,
topicDao = topicDao,
network = network,
)
}
@ -109,6 +124,36 @@ class LocalNewsRepositoryTest {
)
}
@Test
fun localNewsRepository_sync_saves_shell_topic_entities() =
runTest {
subject.sync()
assertEquals(
network.getNewsResources()
.map(NetworkNewsResource::topicEntityShells)
.flatten()
.distinctBy(TopicEntity::id),
topicDao.getTopicEntitiesStream()
.first()
)
}
@Test
fun localNewsRepository_sync_saves_shell_author_entities() =
runTest {
subject.sync()
assertEquals(
network.getNewsResources()
.map(NetworkNewsResource::authorEntityShells)
.flatten()
.distinctBy(AuthorEntity::id),
authorDao.getAuthorEntitiesStream()
.first()
)
}
@Test
fun localNewsRepository_sync_saves_shell_episode_entities() =
runTest {
@ -116,8 +161,7 @@ class LocalNewsRepositoryTest {
assertEquals(
network.getNewsResources()
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::episodeEntityShell)
.map(NetworkNewsResource::episodeEntityShell)
.distinctBy(EpisodeEntity::id),
episodeDao.getEpisodesStream()
.first()
@ -138,4 +182,18 @@ class LocalNewsRepositoryTest {
newsResourceDao.topicCrossReferences
)
}
@Test
fun localNewsRepository_sync_saves_author_cross_references() =
runTest {
subject.sync()
assertEquals(
network.getNewsResources()
.map(NetworkNewsResource::authorCrossReferences)
.distinct()
.flatten(),
newsResourceDao.authorCrossReferences
)
}
}

@ -0,0 +1,51 @@
/*
* Copyright 2022 The Android Open Source Project
*
* 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.google.samples.apps.nowinandroid.core.domain.testdoubles
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Test double for [AuthorDao]
*/
class TestAuthorDao : AuthorDao {
private var entitiesStateFlow = MutableStateFlow(
listOf(
AuthorEntity(
id = 1,
name = "Topic",
imageUrl = "imageUrl",
)
)
)
override fun getAuthorEntitiesStream(): Flow<List<AuthorEntity>> =
entitiesStateFlow
override suspend fun insertOrIgnoreAuthors(authorEntities: List<AuthorEntity>): List<Long> {
entitiesStateFlow.value = authorEntities
// Assume no conflicts on insert
return authorEntities.map { it.id.toLong() }
}
override suspend fun updateAuthors(entities: List<AuthorEntity>) {
throw NotImplementedError("Unused in tests")
}
}

@ -20,7 +20,8 @@ import com.google.samples.apps.nowinandroid.core.database.dao.EpisodeDao
import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedEpisode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.Instant
/**
@ -28,21 +29,31 @@ import kotlinx.datetime.Instant
*/
class TestEpisodeDao : EpisodeDao {
private var entities = listOf(
EpisodeEntity(
id = 1,
name = "Topic",
publishDate = Instant.fromEpochMilliseconds(0),
alternateVideo = null,
alternateAudio = null,
private var entitiesStateFlow = MutableStateFlow(
listOf(
EpisodeEntity(
id = 1,
name = "Episode",
publishDate = Instant.fromEpochMilliseconds(0),
alternateVideo = null,
alternateAudio = null,
)
)
)
override fun getEpisodesStream(): Flow<List<PopulatedEpisode>> =
flowOf(entities.map(EpisodeEntity::asPopulatedEpisode))
entitiesStateFlow.map {
it.map(EpisodeEntity::asPopulatedEpisode)
}
override suspend fun saveEpisodeEntities(entities: List<EpisodeEntity>) {
this.entities = entities
override suspend fun insertOrIgnoreEpisodes(episodeEntities: List<EpisodeEntity>): List<Long> {
entitiesStateFlow.value = episodeEntities
// Assume no conflicts on insert
return episodeEntities.map { it.id.toLong() }
}
override suspend fun updateEpisodes(entities: List<EpisodeEntity>) {
throw NotImplementedError("Unused in tests")
}
}

@ -19,13 +19,14 @@ package com.google.samples.apps.nowinandroid.core.domain.testdoubles
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.Instant
@ -37,23 +38,29 @@ val nonPresentTopicIds = setOf(2)
*/
class TestNewsResourceDao : NewsResourceDao {
private var entities = listOf(
NewsResourceEntity(
id = 1,
episodeId = 0,
title = "news",
content = "Hilt",
url = "url",
headerImageUrl = "headerImageUrl",
type = Video,
publishDate = Instant.fromEpochMilliseconds(1),
private var entitiesStateFlow = MutableStateFlow(
listOf(
NewsResourceEntity(
id = 1,
episodeId = 0,
title = "news",
content = "Hilt",
url = "url",
headerImageUrl = "headerImageUrl",
type = Video,
publishDate = Instant.fromEpochMilliseconds(1),
)
)
)
internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf()
internal var authorCrossReferences: List<NewsResourceAuthorCrossRef> = listOf()
override fun getNewsResourcesStream(): Flow<List<PopulatedNewsResource>> =
flowOf(entities.map(NewsResourceEntity::asPopulatedNewsResource))
entitiesStateFlow.map {
it.map(NewsResourceEntity::asPopulatedNewsResource)
}
override fun getNewsResourcesStream(
filterTopicIds: Set<Int>
@ -65,12 +72,28 @@ class TestNewsResourceDao : NewsResourceDao {
}
}
override suspend fun saveNewsResourceEntities(entities: List<NewsResourceEntity>) {
this.entities = entities
override suspend fun insertOrIgnoreNewsResources(
entities: List<NewsResourceEntity>
): List<Long> {
entitiesStateFlow.value = entities
// Assume no conflicts on insert
return entities.map { it.id.toLong() }
}
override suspend fun updateNewsResources(entities: List<NewsResourceEntity>) {
throw NotImplementedError("Unused in tests")
}
override suspend fun insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>
) {
topicCrossReferences = newsResourceTopicCrossReferences
}
override suspend fun saveTopicCrossRefEntities(entities: List<NewsResourceTopicCrossRef>) {
topicCrossReferences = entities
override suspend fun insertOrIgnoreAuthorCrossRefEntities(
newsResourceAuthorCrossReferences: List<NewsResourceAuthorCrossRef>
) {
authorCrossReferences = newsResourceAuthorCrossReferences
}
}

@ -19,7 +19,7 @@ package com.google.samples.apps.nowinandroid.core.domain.testdoubles
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
/**
@ -27,25 +27,33 @@ import kotlinx.coroutines.flow.map
*/
class TestTopicDao : TopicDao {
private var entities = listOf(
TopicEntity(
id = 1,
name = "Topic",
shortDescription = "short description",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
private var entitiesStateFlow = MutableStateFlow(
listOf(
TopicEntity(
id = 1,
name = "Topic",
shortDescription = "short description",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
)
)
)
override fun getTopicEntitiesStream(): Flow<List<TopicEntity>> =
flowOf(entities)
entitiesStateFlow
override fun getTopicEntitiesStream(ids: Set<Int>): Flow<List<TopicEntity>> =
getTopicEntitiesStream()
.map { topics -> topics.filter { it.id in ids } }
override suspend fun saveTopics(entities: List<TopicEntity>) {
this.entities = entities
override suspend fun insertOrIgnoreTopics(topicEntities: List<TopicEntity>): List<Long> {
entitiesStateFlow.value = topicEntities
// Assume no conflicts on insert
return topicEntities.map { it.id.toLong() }
}
override suspend fun updateTopics(entities: List<TopicEntity>) {
throw NotImplementedError("Unused in tests")
}
}

Loading…
Cancel
Save