Android Stock App 3: Retrofit to Room with TDD, Relation, and Join Tables
Part TWO < ===
Nowadays, we use SQLite as our main database for Android OS. Google Room helps us to save a lot of work. However, it still not compatible with the Object type variable. There are a lot of bugs created during the coding process. So we need to use TDD to minimize the debug time. You can try without TDD and TDD to build a RoomDB with Object. You will know what I am talking about. Let’s start to work.
— ===Menu === —
🙄1. Solutions for Object => Room
🔌2. Gradle Dependencies For Test
🏠3. Add Entity to Data Class
☎️4. Your Commando: DAO
💒5. Room&Hilt: Database and Repository
☂️6. Prepare the Sample Data for Test
🚸7. Test Case: BloombergDaoTest
🕛8. Test StoryDao
🕜9. Test StoryXDao
🤗10. Join the Tables
🙄1. Solutions for Object => Room
< = Menu
Take a look at Story.kt from Bloomberg API.
data class Story (
@SerializedName("title")
val title: String, @SerializedName("stories")
var stories: List<StoryX>
}
The Room cannot read the List<StoryX>. There are two choices:
- TypeConverter: Transfer the list to GSON and return as a string.
- Another: Create another DAO and save the list into that table. After you test OK for the extra DAO, you can link two tables together.
The first choice is simple but it lost the benefits of a database. The second choice is buggy. You need to do some tests to make the method work. In Java database, each table is commanded by its own DAO. If you have ten tables, you shall have ten DAOs. So you need to test @Insert, @Update, @Delete, and @Query of each DAO. 10 DAO x 4 = 40 tests. It will be messy to test on the release version.
In the Stock project, I will use the androidTest folder as my testing ground.
🔌2. Gradle Dependencies For Test
< = Menu
Gradle App Module:
...
android {
...
sourceSets {
String sharedTestDir = 'src/sharedTest/java'
test {
java.srcDir sharedTestDir
}
androidTest {
java.srcDir sharedTestDir
}
}
...
useLibrary 'android.test.runner'
useLibrary 'android.test.base'
useLibrary 'android.test.mock'
packagingOptions {
exclude 'META-INF/DEPENDENCIES'
exclude 'META-INF/LICENSE'
exclude 'META-INF/LICENSE.txt'
exclude 'META-INF/license.txt'
exclude 'META-INF/NOTICE'
exclude 'META-INF/NOTICE.txt'
exclude 'META-INF/notice.txt'
exclude 'META-INF/AL2.0'
exclude 'META-INF/LGPL2.1'
exclude("META-INF/*.kotlin_module")
}
}
dependencies {
// UI
implementation "androidx.appcompat:appcompat:$appCompat_version"
implementation "androidx.constraintlayout:constraintlayout:$constraintLayout_version"
implementation "com.google.android.material:material:$material_version"
// LifeCycle
implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "android.arch.lifecycle:extensions:$lifecycle_version"
// annotation
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
// LiveData
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// ViewModel
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
// kotlin
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "androidx.core:core-ktx:$kotlinCore"
implementation "androidx.activity:activity-ktx:$activity_version"
implementation "androidx.fragment:fragment-ktx:$fragment_version"
// Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
// room
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
// unit test
testImplementation "junit:junit:$junit_version"
testImplementation "org.mockito:mockito-core:$mockito_version"
testImplementation "android.arch.core:core-testing:$androidx_coretest_version"
// androidTest
// Test: Core library
androidTestImplementation "androidx.arch.core:core-testing:$androidx_coretest_version"
androidTestImplementation "androidx.test:core:$testcore_version"
// Test: AndroidJUnitRunner and JUnit Rules
androidTestImplementation "androidx.test:runner:$test_runner_version"
androidTestImplementation "androidx.test:rules:$test_runner_version"
// Test: Assertions
androidTestImplementation "androidx.test.ext:junit:$test_ext_junit"
androidTestImplementation "androidx.test.ext:truth:$test_ext_truth"
androidTestImplementation "com.google.truth:truth:$truth_version"
// Test: Hamcrest Assertion
androidTestImplementation "org.hamcrest:hamcrest-library:$hamcrest_version"
// Test: Espresso dependencies
androidTestImplementation ("androidx.test.espresso:espresso-core:$espresso_version", {
exclude group: 'com.android.support', module: 'support-annotations'
})
androidTestImplementation "androidx.test.espresso:espresso-contrib:$espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-intents:$espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-accessibility:$espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-web:$espresso_version"
androidTestImplementation "androidx.test.espresso.idling:idling-concurrent:$espresso_version"
// Test: Dagger
kaptAndroidTest "com.google.dagger:dagger-compiler:$dagger_version"
// Test: Coroutines
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_test_version"
// Hilt dependencies
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
// Hilt ViewModel extension
implementation "androidx.hilt:hilt-lifecycle-viewmodel:$hilt_jetpack_version"
kapt "androidx.hilt:hilt-compiler:$hilt_jetpack_version"
// Hilt testing dependencies
androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version"
kaptAndroidTest "androidx.hilt:hilt-compiler:$hilt_jetpack_version"
// Retrofit
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.google.code.gson:gson:$gson_version"
implementation "com.squareup.retrofit2:converter-gson:$converter_version"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version"
// paging
implementation "androidx.paging:paging-runtime-ktx:$paging_version"
testImplementation "androidx.paging:paging-common-ktx:$paging_version"
}
Gradle Project:
ext {
// UI
appCompat_version = '1.2.0'
recyclerView_version = '1.1.0'
cardView_version = '1.0.0'
constraintLayout_version = '2.0.0'
legacySupport_version = '1.0.0'
material_version = '1.2.0'
// test
espresso_version = '3.2.0'
junit_version = '4.13'
hamcrest_version = '1.3'
androidx_coretest_version = '2.1.0'
testcore_version = '1.3.0'
test_runner_version = '1.3.0'
test_ext_junit = '1.1.1'
test_ext_truth = '1.2.0'
truth_version = '0.42'
// mockito
mockito_version = '3.3.1'
// jetpack
lifecycle_version = '2.3.0-alpha07'
lifecycleExtensions_version = '2.2.0'
room_version = '2.3.0-alpha02'//'2.2.5'
savedState_version = '2.2.0'
// kotlin
kotlinCore = '1.3.1'
kotlinVM = '2.3.0-alpha06'
activity_version = '1.2.0-alpha07'
fragment_version = '1.3.0-alpha08'
coroutines_version = '1.3.7'
coroutines_test_version = '1.3.9'
// dagger hilt
dagger_version = '2.27'
hilt_jetpack_version = '1.0.0-alpha02'
hilt_version = '2.28-alpha'
// retrofit
retrofit_version = '2.7.2'
okhttp_version = '4.0.0'
gson_version = '2.8.5'
converter_version = '2.7.2'
// paging
paging_version = '3.0.0-alpha05'
}
🏠3. Add Entity to Data Class
< = Menu
Let’s reconstruct the data package:
I move API data into “entity” and create “dao”(DAO) and “room”(database and repository). In AndroidX, you can turn API data class into an entity of the database.
Story.kt
@Entity(
tableName = "story",
indices = [Index(value = ["title"], unique = true)],
)
data class Story(
@SerializedName("title")
val title: String
) {
@PrimaryKey(autoGenerate = true)
var id: Long? = null
@SerializedName("stories") @Ignore
var stories: List<StoryX>? = null
}
“SerializedName” refers to the JSON variable. You don’t have to use the same variable as JSON does. The API Data doesn’t always have an ID column, but you can add an ID as your primary key.
Furthermore, the Room has a problem taking in a List object. You have to ignore the “stories”. Note: @Ignore must be in the body{}, not in a constructor(). I hope the new Room will change the situation.
The “title” is set to unique to avoid duplication. And you shall know what the “tableName” is. You may also notice that “val” is using in the constructor() and “var” is using in the body{}.
StoryX.kt
Sample Data:
{
"resourceType":"Story",
"card":"article",
"title":"Hurricane Laura ...",
"published":1598400292,
"internalID":"QFKK31T0AFB401",
"label":"Weather",
"thumbnailImage":"https://.../600x-1.jpg",
"primarySite":"green",
"shortURL":"https://www.bloomberg.com/news/...",
"longURL":"https://www.bloomberg.com/news/..."
}
The “internalID” is unique according to the RapidAPI.
@Entity(
tableName = "storyx",
indices = [Index(value = ["internal_id"], unique = true)],
)
data class StoryX(
@SerializedName("resourceType") @Expose
val resource_type: String,
@SerializedName("card") @Expose
val card: String,
@SerializedName("title") @Expose
val title: String,
@SerializedName("published") @Expose
val published: Int,
@SerializedName("internalID") @Expose
val internal_id: String,
@SerializedName("primarySite") @Expose
val primary_site: String,
@SerializedName("shortURL") @Expose
val short_url: String,
@SerializedName("longURL") @Expose
val long_url: String,
@SerializedName("label") @Expose
val label: String,
@SerializedName("thumbnailImage") @Expose
val thumbnail_image: String
) {
@PrimaryKey(autoGenerate = true)
var id: Long? = null
// story origin id
var type_id: Long? = null
}
You shall place None-API related item into the body{}, such automated key, and foreign key. This is not a Google example. This is a real example.
☎️4. Your Commando: DAO
< = Menu
As usual, the Room has three parts: Entity, DAO, and Database. Each entity has its own DAO. So the Story entity has StoryDao, and StoryX has StoryXDao. I put them into “data/bloomberg/dao” package.
StoryDao.kt,
@Dao
interface StoryDao {
// coroutines: insert one
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertStoryKtx(story: Story): Long
// regular: insert one
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insertStory(story: Story): Long
// coroutines: insert all
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAllStoriesKtx(story: List<Story>)
// regular: insert all
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insertAllStories(story: List<Story>)
// coroutines: delete one
@Delete
suspend fun deleteByIdKtx(id: Long)
// regular: delete one
@Delete
fun deleteById(id: Long)
// coroutines: delete all
@Query("DELETE FROM story")
suspend fun deleteAllKtx()
// regular: delete all
@Query("DELETE FROM story")
fun deleteAll()
// coroutines: get id by title
@Query("SELECT id FROM story WHERE title = :storytype")
suspend fun getIdKtx(storytype: String): Long
// regular: get id by title
@Query("SELECT id FROM story WHERE title = :storytype")
fun getId(storytype: String): Long
// coroutines: count all rows
@Query("SELECT COUNT(id) FROM story")
suspend fun getCountKtx(): Int
// regular: count all rows
@Query("SELECT COUNT(id) FROM story")
fun getCount(): Int
}
You cannot use suspend function like a regular function, so I make every command has a twin.
StoryXDao.kt
@Dao
interface StoryXDao {
// coroutines: insert one
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertStoryxKtx(item: StoryX): Long
// regular: insert one
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insertStoryx(item: StoryX): Long
// coroutines: insert all
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertStoryxAllKtx(items: List<StoryX>)
// regular: insert all
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insertStoryxAll(items: List<StoryX>)
// coroutines: delete one
@Delete
suspend fun deleteByIdKtx(item_id: Long?)
// regular: delete one
@Delete
fun deleteById(id: Long)
// coroutines: delete all
@Query("DELETE FROM storyx")
suspend fun deleteAllKtx()
// regular: delete all
@Query("DELETE FROM storyx")
fun deleteAll()
// coroutines: count all rows
@Query("SELECT COUNT(*) FROM storyx")
suspend fun getCountKtx(): Int
// regular: count all rows
@Query("SELECT COUNT(*) FROM storyx")
fun getCount(): Int
}
💒5. Room&Hilt: Database and Repository
< = Menu
Database: BloombergDatabase.kt
You have two entities, so you have to insert them all into the database. There is no automated insertion, yet. Type, type, type…
@Database(
entities = [
Story::class,
StoryX::class
],
version = 1,
exportSchema = false
)
abstract class BloombergDatabase: RoomDatabase() {
Insert DAOs:
abstract val storyDao: StoryDao
abstract val storyXDao: StoryXDao
Static vars and functions:
companion object {
private val TAG = "MYLOG BloombergDb"
fun lgd(s: String) = Log.d(TAG, s)
@Volatile
private var INSTANCE: BloombergDatabase? = null
const val DB_NAME = "bloomberg_currency_story"
fun getDatabase(context: Context): BloombergDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
BloombergDatabase::class.java,
DB_NAME
).fallbackToDestructiveMigration()
//.addCallback(BloombergDbCallback())
.build()
INSTANCE = instance
instance
}
}
}
You need context+class+name to build the database.
Repository: BloombergRepository.kt
@Singleton
class BloombergRepository @Inject constructor(
@BloombergNetwork(APIHELPER)
private val bloombergApiHelper: BloombergApiServiceHelper,
@BloombergDB(DAO)
private val storyDao: StoryDao,
@BloombergDB(DAO2)
private val storyXDao: StoryXDao
) {
suspend fun getCurrencyStory(storyType: String):
Story = bloombergApiHelper.getBloombergStory(storyType)
@WorkerThread
suspend fun insertStoryData(apiStory: Story) {
lgd("Insert Api data to room.")
}
companion object {
private val TAG = "MYLOG BbRepo"
fun lgd(s: String) = Log.d(TAG, s)
}
}
You see. I will insert data from here. But I need to test first.
Dagger: BloombergDbModule
To inject the DAOs, I need to set up a database qualifier at dagger/qualifier.
DatabaseTypeEnum.kt:
enum class DatabaseTypeEnum {
DATABASE, DAO, DAO2, DAO3, DAO4, DAO5, DAO6, DAO7, DAO8,
REPOSITORY
}
Eight is a good number.
BloombergDb.kt:
@Qualifier
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
annotation class BloombergDB(val value: DatabaseTypeEnum)
At “dagger” package, BloombergDbModule.kt:
@Module
@InstallIn(ApplicationComponent::class)
class BloombergDBModule {
@Provides @BloombergDB(DATABASE)
fun provideprovideBloombergDatabase(@ApplicationContext context: Context):
BloombergDatabase = BloombergDatabase.getDatabase(context)
@Provides @BloombergDB(DAO)
fun provideStoryDao(
@BloombergDB(DATABASE) database: BloombergDatabase
):
StoryDao = database.storyDao
@Provides @BloombergDB(DAO2)
fun provideStoryXDao(
@BloombergDB(DATABASE) database: BloombergDatabase
):
StoryXDao = database.storyXDao
}
I provide two DAO injections. You need to check the Hilt linkage.
☂️6. Prepare the Sample Data for Test
< = Menu
Curl: Grab Data
User Curl to get the data. According to the description of RapidAPI, I can get 5 types of story: COMMODITY, CURRENCY, FUTURE, RATE, and STOCK. I add an enum class StoryType to Story.kt entity.
@Entity(...)
data class Story(
...
) {
...
}
enum class StoryType(val type: String) {
COMMODITY("Commodities"),
CURRENCY("Currencies"),
FUTURE("Futures"),
RATE("Rates"),
STOCK("Stocks")
}
Curl command (STOCK example):
curl -v "https://bloomberg-market-and-financial-news.p.rapidapi.com/stories/list?template=STOCK&id=ushxh" ^
-H "x-rapidapi-host: bloomberg-market-and-financial-news.p.rapidapi.com" ^
-H "x-rapidapi-key:Your Key" > stock.json
“ > stock.json” will save JSON data into that file.
Format JSON
The stock.json is unformatted. Please copy all of the content and paste to an online formatter, like this: https://jsonformatter.curiousconcept.com/#
Copy, paste, and save. In your project, you need to create an asset folder.
Copy all JSON files into the asset folder.
🚸7. Test Case: BloombergDaoTest
< = Menu
At androidTest folder, I create BloombergDaoTest.kt.
@RunWith(AndroidJUnit4ClassRunner::class)
class BloombergDaoTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
private lateinit var context:Context
private lateinit var storyDao: StoryDao
private lateinit var storyXDao: StoryXDao
var sampleStoryXList = mutableListOf<StoryX>()
var sampleStoryList = mutableListOf<Story>()
private lateinit var db: BloombergDatabase
companion object {
const val STORY_SIZE = 5
private val TAG = "MYLOG androidTest"
fun lgd(s: String) = Log.d(TAG, s)
}
}
@Before: Prepare the Database
@Before
fun createDb() {
context = ApplicationProvider.getApplicationContext()
// Using an in-memory database because the information stored
// here disappears when the process is killed.
db = Room.inMemoryDatabaseBuilder(
context, BloombergDatabase::class.java)
// Allowing main thread queries, just for testing.
.allowMainThreadQueries()
.build()
storyDao = db.storyDao
storyXDao = db.storyXDao
// create sample
sampleStoryList.add(getStory(context, "commodity.json")!!)
sampleStoryList.add(getStory(context, "currency.json")!!)
sampleStoryList.add(getStory(context, "future.json")!!)
sampleStoryList.add(getStory(context, "rate.json")!!)
sampleStoryList.add(getStory(context, "stock.json")!!)
}
> getStory(): Read JSON string into a list.
fun getStory(context:Context, fileName:String): Story? {
val jStr: String
try {
jStr = context.assets.open(fileName)
.bufferedReader().use { it.readText() }
val gson = Gson()
val storyType = object : TypeToken<Story>() {}.type
return gson.fromJson(jStr, storyType)
} catch (ioException: IOException) {
ioException.printStackTrace()
return null
}
}
@After
@After
@Throws(IOException::class)
fun closeDb() {
db.close()
}
🕛8. Test StoryDao
< = Menu
Test function: insertAndGetStoryX
@Test
@Throws(Exception::class)
fun insertAndGetStoryX() {}
Test 1: Check the sample story list size
assertEquals(sampleStoryList.size, STORY_SIZE)
Passed.
Test 2: Insert all
storyDao.insertAllStories(sampleStoryList)
val rowCount = storyDao.getCount()
assertEquals(sampleStoryList.size, rowCount)
Passed.
Test 3: Delete one
// delete one: Currency
var typeId = getStoryId(StoryType.CURRENCY.type)
lgd("Found Currency at ID: $typeId")
storyDao.deleteById(typeId)
typeId = getStoryId(StoryType.CURRENCY.type)
assertEquals(0, typeId)
Error:
Execution failed for task ':app:kaptDebugKotlin'.
> A failure occurred while executing org.jetbrains.kotlin.gradle.internal.KaptExecution
> java.lang.reflect.InvocationTargetException (no error message)
@Delete is not working. I don’t know why. At StoryDao.kt, I replace @Delete with @Query.
// coroutines: delete one
@Query("DELETE FROM story WHERE id = :storyId")
suspend fun deleteByIdKtx(storyId: Long?)
// regular: delete one
@Query("DELETE FROM story WHERE id = :storyId")
fun deleteById(storyId: Long?)
Passed.
Test 4: Insert one
Let’s insert the deleted one. I will use the assertThat() of Hamcrest.
storyDao.insertStory(Story(StoryType.CURRENCY.type))
typeId = getStoryId(StoryType.CURRENCY.type)
lgd("Found Currency at ID: $typeId")
assertThat( typeId, greaterThan(0) )
Passed.
Test 5: Delete all
storyDao.deleteAll()
rowCount = storyDao.getCount()
assertEquals(0, rowCount)
Passed.
🕜9. Test StoryXDao
< = Menu
I create another test function called insertAndGetStoryX():
> prepareSampleX(): Prepare the samples of storyX.
fun prepareSampleX() {
// Insert Stories
storyDao.insertAllStories(sampleStoryList)
// test 1: insert one
for (i in sampleStoryList.indices) {
val typeId = getStoryId(sampleStoryList[i].title)
for (j in sampleStoryList[i].stories?.indices!!) {
val storyx = sampleStoryList[i].stories!![j]
storyx.type_id = typeId
// insert one
val revId = storyXDao.insertStoryx(storyx)
lgd("$i : ${j+1}\t==> id: $revId")
// add one to the list
sampleStoryXList.add(storyx)
}
}
}@Test
@Throws(Exception::class)
fun insertAndGetStoryX() {
// prepare the samples storyX data
prepareSampleX()
>getStoryId(): If id is not found, id = 0.
fun getStoryId(s: String): Long {
var id = 0L
when (s) {
StoryType.COMMODITY.type -> {
id = storyDao.getId(StoryType.COMMODITY.type)
lgd("Story Type: ${StoryType.COMMODITY.type}; id: $id")
}
StoryType.CURRENCY.type -> {
id = storyDao.getId(StoryType.CURRENCY.type)
lgd("Story Type: ${StoryType.CURRENCY.type}; id: $id")
}
StoryType.STOCK.type -> {
id = storyDao.getId(StoryType.STOCK.type)
lgd("Story Type: ${StoryType.STOCK.type}; id: $id")
}
StoryType.RATE.type -> {
id = storyDao.getId(StoryType.RATE.type)
lgd("Story Type: ${StoryType.RATE.type}; id: $id")
}
StoryType.FUTURE.type -> {
id = storyDao.getId(StoryType.FUTURE.type)
lgd("Story Type: ${StoryType.FUTURE.type}; id: $id")
}
}
return id
}
Test 1: Insert one
val storyxCount = storyXDao.getCount()
assertEquals(sampleStoryXList.size, storyxCount)
Result: Inserted 11 pieces 👎🏻
junit.framework.AssertionFailedError: expected:<50> but was:<11>
Logcat:
D/MYLOG androidTest: Story Type: Commodities; id: 1
D/MYLOG androidTest: 0 : 1 ==> id: 1
D/MYLOG androidTest: 0 : 2 ==> id: 2
D/MYLOG androidTest: 0 : 3 ==> id: -1
D/MYLOG androidTest: 0 : 4 ==> id: -1
Problem at commodity.json
Let’s check the commodity.json at the 3rd and 4th stories.
{
"resourceType":"Story",
"card":"article",
"title":"Hurricane Laura ..."
"published":1598400292,
"internalID":"QFKK31T0AFB401",
"label":"Weather",
"thumbnailImage":"https://...",
"primarySite":"green",
"shortURL":"https://...",
"longURL":"https://..."
},
{
"resourceType":"Story",
"card":"article",
"title":"U.S., China Signal ...",
"published":1598341753,
"internalID":"QF1JD4T1UM0X01",
"thumbnailImage":"https://...",
"primarySite":"markets",
"shortURL":"https://...",
"longURL":"https://..."
},
The 2nd one has a “label” variable but 3rd one doesn’t.
Fix
StoryX.kt: Move “label” from the constructor() to the body{}.
...) {
@PrimaryKey(autoGenerate = true)
var id: Long? = null
// story origin id
var type_id: Long? = null
@SerializedName("label") @Expose
var label: String? = null
}
Result
junit.framework.AssertionFailedError: expected:<50> but was:<45>
Now, the count returns in <45>. Let’s check Logcat again.
D/MYLOG androidTest: Story Type: Stocks; id: 5
...
D/MYLOG androidTest: 4 : 9 ==> id: 49
D/MYLOG androidTest: 4 : 10 ==> id: -1
Problem at stock.json.
{
"resourceType":"Story",
"card":"article",
"title":"This Ant Will Be an Undisputed Market Heavyweight",
"published":1598350841,
"internalID":"QFM7EHDWRGG001",
"label":"Markets",
"thumbnailImage":"https://...",
"primarySite":"bview",
"shortURL":"https://...",
"longURL":"https://..."
},
{
"resourceType":"Story",
"card":"article",
"title":"JPMorgan ...",
"published":1598256975,
"internalID":"QFJMEMDWX2PS01",
"thumbnailImage":"https://...",
"primarySite":"markets",
"shortURL":"https://...",
"longURL":"https://..."
}
I can’t found any problems with the JSON variables. Then I change the internalID and run the test again. It inserted <46> instead of <45>. It’s a distinction problem because internalID is unique.
Fix
StoryX.kt.
@Entity(
tableName = "storyx",
indices = [Index(value = ["internal_id", "type_id"],
unique = true)],
)
data class StoryX(...) { ... }
Insert only when “internal_id” and “type_id” are different.
Result
junit.framework.AssertionFailedError: expected:<50> but was:<49>
Problem at currency.json
Logcat:
D/MYLOG androidTest: Story Type: Currencies; id: 2
...
D/MYLOG androidTest: 1 : 7 ==> id: 17
D/MYLOG androidTest: 1 : 8 ==> id: -1
currency.json:
{
"resourceType":"Story",
"card":"article",
"title":"EM Review: ...",
"published":1598246896,
"internalID":"QFAQ59T0G1L001",
"primarySite":"bbiz",
"shortURL":"https://...",
"longURL":"https://..."
},
{
"resourceType":"Story",
"card":"article",
"title":"Gold...",
"published":1597914095,
"internalID":"QFAEOXDWRGG101",
"thumbnailImage":"https://...",
"primarySite":"markets",
"shortURL":"https://...",
"longURL":"https://..."
}
The 8th story has no “thumbnailImage” variable.
Fix
StoryX.kt: Move “thumbnailImage” from the constructor() to the body{}.
@Entity(...)
data class StoryX(...) {
...
@SerializedName("label") @Expose
var label: String? = "none"
@SerializedName("thumbnailImage") @Expose
var thumbnail_image: String? = "none"
}
Passed.
Test 2: Delete all storyx
storyXDao.deleteAll()
var rowCount = storyXDao.getCount()
assertEquals(0, rowCount)
Passed.
Test 3: Insert all storyx
storyXDao.insertStoryxAll(sampleStoryXList)
rowCount = storyXDao.getCount()
assertEquals(sampleStoryXList.size, rowCount)
Passed.
Test 4: Find storyx by internal id
Add one command in StoryXDao.kt:
// coroutines: find by internal id
@Query("SELECT * FROM storyx WHERE internal_id = :internalId")
suspend fun getStoryxByInternalIdKtx(internalId: String): List<StoryX>
// regular: find by internal id
@Query("SELECT * FROM storyx WHERE internal_id = :internalId")
fun getStoryxByInternalId(internalId: String): List<StoryX>
Test:
val internalId = "QFJMEMDWX2PS01"
val foundList = storyXDao.getStoryxByInternalId(internalId)
lgd ("Search result (\"internalID\":\"QFJMEMDWX2PS01\"): ${foundList.size}")
assertThat(foundList.size, greaterThan(0))
Passed.
Test 5: Delete one storyx
Replace @Delete in StoryXDao.kt:
// coroutines: delete one
@Query("DELETE FROM storyx WHERE id = :item_id")
suspend fun deleteByIdKtx(item_id: Long?)
// regular: delete one
@Query("DELETE FROM storyx WHERE id = :item_id")
fun deleteById(item_id: Long?)
Test:
val deleteId = foundList[0].id
storyXDao.deleteById(deleteId)
rowCount = storyXDao.getCount()
assertEquals(sampleStoryXList.size-1, rowCount)
Passed.
🤗10. Join the Tables
< = Menu
Now, we know those Tables and DAOs are working fine. Let’s join them to save some codes.
You shall have noticed that type_id in StoryX entity is pointing to the id of Story. Let’s link them by ForeignKey.
@Entity(
tableName = "storyx",
indices = [Index(value = ["internal_id", "type_id"],
unique = true)],
foreignKeys = [
ForeignKey(
entity = Story::class,
parentColumns = ["id"],
childColumns = ["type_id"])
]
)
data class StoryX(
Join the entities into a class.
StoryWithStoryX.kt, at /data/bloomberg/entity:
class StoryWithStoryx(
@Embedded val category: Story,
@Relation(
parentColumn = "id",
entityColumn = "type_id"
)
val storyXList: List<StoryX>
)
StoryDao.kt, Let’s add a @Transaction.
// coroutines: get all of Story+Storyx
@Transaction
@Query("SELECT * FROM story")
suspend fun getAllStoryNxKtx(): List<StoryWithStoryx>
// regular: get all of Story+Storyx
@Transaction
@Query("SELECT * FROM story")
fun getAllStoryNx(): List<StoryWithStoryx>
Test: joinStoryAndStoryX()
Add a new test as joinStoryAndStoryX() .
@Test
@Throws(Exception::class)
fun joinStoryAndStoryX() {
prepareSampleX()
Test 1: Get all items of the Story with StoryX.
val uList = storyDao.getStoryAndStoryx()
lgd("union list: ${uList.toString()}")
assertEquals(5, uList.size)
assertEquals(10, uList[0].storyXList.size)
Passed.
Test 2: Find one Story with StoryX by title
StoryDao.kt,
// coroutines: get Story+Storyx by title
@Transaction
@Query("SELECT * FROM story WHERE title = :category")
suspend fun getStoryNxByTitleKtx(category: String): StoryWithStoryx
// regular: get Story+Storyx by title
@Transaction
@Query("SELECT * FROM story WHERE title = :category")
fun getStoryNxByTitle(category: String): StoryWithStoryx
joinStoryAndStoryX(),
// Test 2: Find one Story with StoryX by title
val joinStory = storyDao.getStoryNxByTitle(StoryType.COMMODITY.type)
assertEquals(StoryType.COMMODITY.type, joinStory.category.title)
assertEquals(10, joinStory.storyXList.size)
Passed. This test proves that we don’t need to write extra code to get StoryX list. The linkage is working well. Up to this point, you may understand that you shall not test the join table at the beginning, because you can not detect the bugs among the DAOs.
Final files structure.