diff --git a/core-database/schemas/com.google.samples.apps.nowinandroid.core.database.NiADatabase/4.json b/core-database/schemas/com.google.samples.apps.nowinandroid.core.database.NiADatabase/4.json new file mode 100644 index 000000000..f8d5d3c66 --- /dev/null +++ b/core-database/schemas/com.google.samples.apps.nowinandroid.core.database.NiADatabase/4.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/core-database/src/androidTest/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt b/core-database/src/androidTest/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt index 2fb5c7b3c..287aaeb47 100644 --- a/core-database/src/androidTest/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt +++ b/core-database/src/androidTest/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt @@ -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, +) diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiADatabase.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiADatabase.kt index 068533855..270ddb418 100644 --- a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiADatabase.kt +++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiADatabase.kt @@ -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, ) diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/AuthorDao.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/AuthorDao.kt index 6fb70e56e..5df59b0de 100644 --- a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/AuthorDao.kt +++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/AuthorDao.kt @@ -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> + fun getAuthorEntitiesStream(): Flow> - @Insert - suspend fun saveAuthorEntities(entities: List) + /** + * Inserts [authorEntities] into the db if they don't exist, and ignores those that do + */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertOrIgnoreAuthors(authorEntities: List): List + + /** + * Updates [entities] in the db that match the primary key, and no-ops if they don't + */ + @Update + suspend fun updateAuthors(entities: List) + + /** + * Inserts or updates [entities] in the db under the specified primary keys + */ + @Transaction + suspend fun upsertAuthors(entities: List) = upsert( + items = entities, + insertMany = ::insertOrIgnoreAuthors, + updateMany = ::updateAuthors + ) } diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/EpisodeDao.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/EpisodeDao.kt index 267bba130..10f23f430 100644 --- a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/EpisodeDao.kt +++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/EpisodeDao.kt @@ -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> - // TODO: Perform a proper upsert. See: b/226916817 - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun saveEpisodeEntities(entities: List) + /** + * Inserts [episodeEntities] into the db if they don't exist, and ignores those that do + */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertOrIgnoreEpisodes(episodeEntities: List): List + + /** + * Updates [entities] in the db that match the primary key, and no-ops if they don't + */ + @Update + suspend fun updateEpisodes(entities: List) + + /** + * Inserts or updates [entities] in the db under the specified primary keys + */ + @Transaction + suspend fun upsertEpisodes(entities: List) = upsert( + items = entities, + insertMany = ::insertOrIgnoreEpisodes, + updateMany = ::updateEpisodes + ) } diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt index 0d5fbe98e..44713a82e 100644 --- a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt +++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt @@ -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): Flow> - // TODO: Perform a proper upsert. See: b/226916817 - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun saveNewsResourceEntities(entities: List) + /** + * Inserts [entities] into the db if they don't exist, and ignores those that do + */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertOrIgnoreNewsResources(entities: List): List - // TODO: Perform a proper upsert. See: b/226916817 - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun saveTopicCrossRefEntities(entities: List) + /** + * Updates [entities] in the db that match the primary key, and no-ops if they don't + */ + @Update + suspend fun updateNewsResources(entities: List) + + /** + * Inserts or updates [newsResourceEntities] in the db under the specified primary keys + */ + @Transaction + suspend fun upsertNewsResources(newsResourceEntities: List) = upsert( + items = newsResourceEntities, + insertMany = ::insertOrIgnoreNewsResources, + updateMany = ::updateNewsResources + ) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertOrIgnoreTopicCrossRefEntities( + newsResourceTopicCrossReferences: List + ) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertOrIgnoreAuthorCrossRefEntities( + newsResourceAuthorCrossReferences: List + ) } diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt index 17182ec62..ad921b9b5 100644 --- a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt +++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt @@ -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): Flow> - // TODO: Perform a proper upsert. See: b/226916817 - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun saveTopics(entities: List) + /** + * Inserts [topicEntities] into the db if they don't exist, and ignores those that do + */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertOrIgnoreTopics(topicEntities: List): List + + /** + * Updates [entities] in the db that match the primary key, and no-ops if they don't + */ + @Update + suspend fun updateTopics(entities: List) + + /** + * Inserts or updates [entities] in the db under the specified primary keys + */ + @Transaction + suspend fun upsertTopics(entities: List) = upsert( + items = entities, + insertMany = ::insertOrIgnoreTopics, + updateMany = ::updateTopics + ) } diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/UpsertHelper.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/UpsertHelper.kt new file mode 100644 index 000000000..acf076434 --- /dev/null +++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/UpsertHelper.kt @@ -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 upsert( + items: List, + insertMany: suspend (List) -> List, + updateMany: suspend (List) -> Unit, +) { + val insertResults = insertMany(items) + + val updateList = items.zip(insertResults) + .mapNotNull { (item, insertResult) -> + if (insertResult == -1L) item else null + } + if (updateList.isNotEmpty()) updateMany(updateList) +} diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceAuthorCrossRef.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceAuthorCrossRef.kt index fb394159e..397f3d9cc 100644 --- a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceAuthorCrossRef.kt +++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceAuthorCrossRef.kt @@ -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, ) diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceEntity.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceEntity.kt index 06267551f..28d8d858e 100644 --- a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceEntity.kt +++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceEntity.kt @@ -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, -) diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/NewsResource.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/NewsResource.kt index a83768db6..0d6e8d7b7 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/NewsResource.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/NewsResource.kt @@ -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 = topics.map { topicId -> NewsResourceTopicCrossRef( newsResourceId = id, topicId = topicId + ) + } +fun NetworkNewsResource.authorCrossReferences(): List = + authors.map { authorId -> + NewsResourceAuthorCrossRef( + newsResourceId = id, + authorId = authorId ) } diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepository.kt index a28b776c6..e7128a319 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepository.kt @@ -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 } diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepository.kt index 322c9d873..6b12a7a02 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepository.kt @@ -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 } diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepositoryTest.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepositoryTest.kt index 53a1b6310..91cb15e49 100644 --- a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepositoryTest.kt +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepositoryTest.kt @@ -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 + ) + } } diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestAuthorDao.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestAuthorDao.kt new file mode 100644 index 000000000..d3092af45 --- /dev/null +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestAuthorDao.kt @@ -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> = + entitiesStateFlow + + override suspend fun insertOrIgnoreAuthors(authorEntities: List): List { + entitiesStateFlow.value = authorEntities + // Assume no conflicts on insert + return authorEntities.map { it.id.toLong() } + } + + override suspend fun updateAuthors(entities: List) { + throw NotImplementedError("Unused in tests") + } +} diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestEpisodeDao.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestEpisodeDao.kt index 11bbe8179..d0c3b1af8 100644 --- a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestEpisodeDao.kt +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestEpisodeDao.kt @@ -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> = - flowOf(entities.map(EpisodeEntity::asPopulatedEpisode)) + entitiesStateFlow.map { + it.map(EpisodeEntity::asPopulatedEpisode) + } - override suspend fun saveEpisodeEntities(entities: List) { - this.entities = entities + override suspend fun insertOrIgnoreEpisodes(episodeEntities: List): List { + entitiesStateFlow.value = episodeEntities + // Assume no conflicts on insert + return episodeEntities.map { it.id.toLong() } + } + + override suspend fun updateEpisodes(entities: List) { + throw NotImplementedError("Unused in tests") } } diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNewsResourceDao.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNewsResourceDao.kt index b8bb33af9..fba1760dd 100644 --- a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNewsResourceDao.kt +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNewsResourceDao.kt @@ -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 = listOf() + internal var authorCrossReferences: List = listOf() + override fun getNewsResourcesStream(): Flow> = - flowOf(entities.map(NewsResourceEntity::asPopulatedNewsResource)) + entitiesStateFlow.map { + it.map(NewsResourceEntity::asPopulatedNewsResource) + } override fun getNewsResourcesStream( filterTopicIds: Set @@ -65,12 +72,28 @@ class TestNewsResourceDao : NewsResourceDao { } } - override suspend fun saveNewsResourceEntities(entities: List) { - this.entities = entities + override suspend fun insertOrIgnoreNewsResources( + entities: List + ): List { + entitiesStateFlow.value = entities + // Assume no conflicts on insert + return entities.map { it.id.toLong() } + } + + override suspend fun updateNewsResources(entities: List) { + throw NotImplementedError("Unused in tests") + } + + override suspend fun insertOrIgnoreTopicCrossRefEntities( + newsResourceTopicCrossReferences: List + ) { + topicCrossReferences = newsResourceTopicCrossReferences } - override suspend fun saveTopicCrossRefEntities(entities: List) { - topicCrossReferences = entities + override suspend fun insertOrIgnoreAuthorCrossRefEntities( + newsResourceAuthorCrossReferences: List + ) { + authorCrossReferences = newsResourceAuthorCrossReferences } } diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestTopicDao.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestTopicDao.kt index 08af69d93..b9a17d923 100644 --- a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestTopicDao.kt +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestTopicDao.kt @@ -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> = - flowOf(entities) + entitiesStateFlow override fun getTopicEntitiesStream(ids: Set): Flow> = getTopicEntitiesStream() .map { topics -> topics.filter { it.id in ids } } - override suspend fun saveTopics(entities: List) { - this.entities = entities + override suspend fun insertOrIgnoreTopics(topicEntities: List): List { + entitiesStateFlow.value = topicEntities + // Assume no conflicts on insert + return topicEntities.map { it.id.toLong() } + } + + override suspend fun updateTopics(entities: List) { + throw NotImplementedError("Unused in tests") } }