Building an RSS Reader for Android #3: Setting up a relational database, many-to-many relationships, and Room

Previously in this series, I wrote about FeedParser and FeedSearcher, each representing a data source for the app. The former is responsible for fetching and parsing RSS feeds, while the latter is responsible for fetching a collection of information about RSS feeds that can be subscribed to. To clarify, they are not true data sources, since they don’t store or provide data themselves; each one is simply a layer meant to access data from somewhere else, although the rest of the app has no knowledge of this. In both cases, the actual data source is remote: for FeedParser, raw XML pages from the web; and for FeedSearcher, Feedly’s search engine.

So we have a way of fetching basic information about an RSS feed (its URL, title, description, etc.), and a way to to actually retrieve the contents of the feed itself, including a collection of entries. But to actually enable subscription to a feed, the app needs a third, local data source that it can use to access previously parsed feeds—otherwise, to read a feed that we’ve already subscribed to, we would have to keep fetching and parsing it from the internet. This is where a local database comes in.

Room, entities, and many-to-many relationships

Room is Android’s standard library for local data persistence. According to the documentation, it “provides an abstraction layer over SQLite to allow fluent database access while harnessing the full power of SQLite.” This greatly reduces the amount of work required for database access, as opposed to using the SQLIte APIs directly, which is more error-prone and involves significant boilerplate code. (Read the documentation for more information.)

In my first post, I hinted that NiceFeed is concerned mainly with two kinds of data objects: Feed and Entry. We define these as data classes, specifying all the relevant properties, and annotating each of them with @Entity. This tells Room that each of these data classes represents a table. Additionally, for each entity we specify a @PrimaryKey, signifying that the annotated property is an item’s unique identifier; for Feed and Entry, each one’s primary key is its URL.

@Entity
data class Feed(
    @PrimaryKey val url: String, // Doubles as Feed ID
    var title: String,
    val website: String,
    val description: String? = null,
    val imageUrl: String? = null,
    var category: String = "Uncategorized",
    var unreadCount: Int
) : Serializable

@Entity
data class Entry(
    @PrimaryKey val url: String, // Doubles as Entry ID
    val title: String,
    val website: String,
    val author: String?,
    val date: Date?,
    val content: String?,
    val image: String?,
    var isStarred: Boolean = false,
    var isRead: Boolean = false
) : Serializable {

    ...
}

I also mentioned before that the relationship between Feed and Entry is many-to-many. This simply means that a Feed can be associated with multiple entries, which is usually the case. Occasionally, a single Entry will be part of more than one Feed. For example, an article from the New York Times may be included both in the Latest News and Travel feeds. This is important to note so that we don’t end up with duplicate articles in our database.

To create an association between one Feed and one Entry, we define a third data class and entity: FeedEntryCrossRef, which simply contains the URLs of one Feed and one Entry. This entity represents a junction by which two other tables can be joined; it tells us that that the Feed whose URL is stored here is associated with the Entry whose URL is also stored here. (Check out this tutorial on SQLite joins for more information.)

@Entity(
    primaryKeys = ["feedUrl", "entryUrl"],
    indices = [(Index(value = ["entryUrl"]))]
)
data class FeedEntryCrossRef(
    val feedUrl: String,
    val entryUrl: String
)

Defining the database

Now to create our database. We define NiceFeedDatabase as an abstract class which extends Room’s predefined RoomDatabase; this is because Room itself handles the building. Within the @Database annotation for Room, we pass in our three entities—Feed, Entry, and FeedEntryCrossRef—and mark it as version 1. And within the companion object, we define a static method for actually building the database, so that we can call on the class directly upon starting the app to initialize our database.

@Database(
    entities = [
        Feed::class,
        Entry::class,
        FeedEntryCrossRef::class
    ],
    version = 1
)
@TypeConverters(com.joshuacerdenia.android.nicefeed.data.local.database.TypeConverters::class)
abstract class NiceFeedDatabase : RoomDatabase() {

    abstract fun combinedDao(): CombinedDao

    companion object {

        private const val DATABASE_NAME = "database"

        fun build(context: Context): NiceFeedDatabase {
            return Room.databaseBuilder(
                context.applicationContext,
                NiceFeedDatabase::class.java,
                DATABASE_NAME
            ).build()
        }
    }
}

Type converters

Additionally, notice that there’s a second annotation, @TypeConverters. This is because SQLite only directly handles basic data types like strings and integers. But Room provides a way to easily convert others into types that SQLite will handle. Our Entry data class includes a Date, representing the date it was published. So we define a TypeConverters class to contain methods that tell Room what to do when it receives a Date to store into our database (fromDate), and when we ask for one (toDate). Then we pass this class into our @TypeConverters annotation above.

class TypeConverters {

    @TypeConverter
    fun fromDate(date: Date?): Long? {
        return date?.time
    }

    @TypeConverter
    fun toDate(millisSinceEpoch: Long?): Date? {
        return millisSinceEpoch?.let {
            Date(it)
        }
    }
}

Data access objects—to be continued

And with that, our database setup is nearly complete. The one final thing to note is the abstract function that I have named combinedDao. This represents our data access objects (or DAO, for short)—basically, an interface or intermediately layer through which our app can access data from our database. Within combinedDao (so named because it’s a combination of multiple smaller interfaces, each having to do with one of our three entities), I have defined all methods necessary for the app to get the data that it needs in various cases. NiceFeed is a mildly complex app, so I ended up needing a significant number of these methods, some involving complex SQL queries. I’ll write about this in a future post.