Building an RSS reader for Android #6: Another DAO

May 1, 2021

This is the third and last part of the mini-series within a series which has been my last few posts, which has all to do with data access objects within our app. We've covered three DAOs, each having to do with one of the three entities or tables within our database: Feed, Entry, and FeedEntryCrossRef, the latter a cross-reference table that allows us to maintain many-to-many relationships between Feeds and Entries.

While we've diligently specified each and every interaction we need to have with our database, each pertaining to one or more table, in real-life usage rarely would any of these methods be called in isolation—a point to which I'll come back in a moment. For this reason, I created a fourth DAO called CombinedDao which extends all our previously defined DAOs and implements all methods defined within them. This way we can call on multiple functions across different DAOs in a single transaction, while keeping our code reasonably organized.

This is a small part of the app, but personally the one with which I've had the most fun trying to figure out. This time we annotate the interface declaration with @Dao, since it's the DAO that we end up actually feeding to our database implementation.

@Dao
interface CombinedDao: FeedsDao, EntriesDao, FeedEntryCrossRefsDao {
    ...
}

This is a relatively small class so I can afford to go into a little detail into each of the methods therein.

Saving a new Feed and Entries

Just earlier I mentioned that most of the methods within the previous DAOs would never be called in isolation. For example, there are practically no situations in which we'd need to save a new Entry or list of Entries without an associated Feed: a user subscribes first and foremost to an RSS feed, which, after parsing, results in a separate Feed object and multiple Entry objects, as far as our app is concerned. Even then, before saving anything to our database, a corresponding list of FeedEntryCrossRef objects (as many as there are Entries) must be created. Written as a function, and making use of all our DAOs, this particular operation would look something like this:

@Transaction
fun addFeedAndEntries(feed: Feed, entries: List<Entry>) {
    addFeeds(feed)
    addEntries(entries)
    addFeedEntryCrossRefs(feed.url, entries)
}

So, with one function annotated with @Transaction, we are able to accomplish everything we need not only to save incoming data to our database, but to establish the proper relationships among them across multiple tables.

Fetching specific data across tables

As in the above example, doing things this way allows us plenty of flexibility in our interactions with the database and the data we can retrieve. Another example: let's say there's a situation (which there actually is in our app) where, given one Feed ID (url), we need a list all associated Entries—but only with the url, isStarred, and isRead fields, i.e., variable properties that can be toggled by the user—along with the title of the Feed itself, all in one background task. First, a predefined data class is necessary:

data class FeedTitleWithEntriesToggleable(
    val feedTitle: String,
    val entriesToggleable: List<EntryToggleable>
)

Then the function would be written simply as follows, returning to us our needed object:

@Transaction
fun getFeedTitleAndEntriesToggleableSynchronously(
    feedId: String
): FeedTitleWithEntriesToggleable {
    return FeedTitleWithEntriesToggleable(
        getFeedTitleSynchronously(feedId),
        getEntriesToggleableByFeedSynchronously(feedId)
    )
}

Updating a list of Entries according to Feed

What if we wanted to handle updating the list of Entries associated with one given Feed as a user initiates it? In other words, we want to update a subscription to keep it current. We would need to have a few things on hand: 1) the ID (url) of the Feed itself, 2) any new entries to add, 3) entries that need to be updated, and 4) old entries that should be deleted. All four of these would make their way as arguments into function called handleEntryUpdates:

@Transaction
fun handleEntryUpdates(
    feedId: String,
    entriesToAdd: List<Entry>,
    entriesToUpdate: List<Entry>,
    entriesToDelete: List<Entry>,
) {
    addEntries(entriesToAdd)
    addFeedEntryCrossRefs(feedId, entriesToAdd)
    updateEntries(entriesToUpdate)
    deleteFeedEntryCrossRefs(feedId, entriesToDelete.map { it.url })
    deleteEntries(entriesToDelete)
}

Updating Entries according to Feed: another way

In the last example, we are only concerned with making changes to a list of Entries, and not the Feed itself with which said Entries are associated. But there are cases in which it makes sense to update the Feed too, such as in a background update. Here we would need to update a particular Feed's associated Entries, by adding to them and/or deleting a number of them; then, depending on what was added or deleted, a Feed's unreadCount has to be updated too in the same transaction. Finally, if relevant, we update the Feed's image. Such a task requires a slightly different set of arguments to accomplish:

@Transaction
fun handleBackgroundUpdate(
    feedId: String,
    newEntries: List<Entry>,
    oldEntries: List<EntryToggleable>,
    feedImage: String?
) {
    addEntries(newEntries)
    addFeedEntryCrossRefs(feedId, newEntries)
    oldEntries.map { it.url }.let { entryIds ->
        deleteEntriesById(entryIds)
        deleteFeedEntryCrossRefs(feedId, entryIds)
    }
    incrementFeedUnreadCount(feedId, (newEntries.size - oldEntries.filter { !it.isRead }.size))
    feedImage?.let { updateFeedImage(feedId, it) }
}

As written in the function declaration, the arguments are 1) feedId (a Feeds's url), 2) newEntries (a list of type Entry), 3) oldEntries (a list of type EntryToggleable, i.e., an Entry's url, isRead, and isStarred properties), and 4) a feedImage, representing a URL that points to an image file, which may or may not be null.

At this point things start to get slightly convoluted. Most straightforwardly, newEntries is first passed into the addEntries method, followed by the same, along with the feedId, going into the method addFeedEntryCrossRefs—similar to the earlier method addFeedAndEntries.

addEntries(newEntries)
addFeedEntryCrossRefs(feedId, newEntries)

Then, oldEntries needs to be deleted from the database. As stated earlier, each item in this collection contains the properties url, isRead, and isStarred. To delete a list of Entries at once, we only need their IDs (or urls); so we map oldEntries onto a list of entryIds, passing them into the predefined methods deleteEntriesById and deleteFeedEntryCrossRefs (which additionally requires feedId as an argument).

oldEntries.map { it.url }.let { entryIds ->
    deleteEntriesById(entryIds)
    deleteFeedEntryCrossRefs(feedId, entryIds)
}

That takes care of our Entries. As for updating the Feed's unreadCount, remember that earlier in our FeedsDao we defined a method called incrementFeedUnreadCount, which takes as arguments the Feed's url, and a number by which to adjust the existing unreadCount, whether positive or negative. This is where the isRead property of EntryToggleable comes into play: to determine how much to add or subtract to our Feed's unreadCount, we first get the number of unread Entries based on our oldEntries list, by filtering those items whose value of isRead is false and then sizing the resulting list. Then the resulting number is subtracted from the size of newEntries.

incrementFeedUnreadCount(feedId, (newEntries.size - oldEntries.filter { !it.isRead }.size))

Finally, if the given feedImage is not null, we save it to our Feed as its new imageUrl.

feedImage?.let { updateFeedImage(feedId, it) }

Marking an Entry as read/unread and starred/unstarred, and updating all relevant Feeds' unreadCount

Here we have a pair of functions that have to do with updating entries, particularly their isRead and isStarred properties, and accordingly changing the unreadCount of all associated Feeds, which may be one or more. The first is meant to affect a variable number of Entries, depending on the quantity of entryIds passed into the function. Within the function body, updateEntryIsRead, defined earlier in our EntriesDao, is called, taking the the variable number of entryIds obtained earlier; every Entry in the collection is updated with the given isRead value.

@Transaction
fun updateEntryIsReadAndFeedUnreadCount(vararg entryId: String, isRead: Boolean) {
    updateEntryIsRead(*entryId, isRead = isRead)
    (if (isRead) -1 else 1).let { addend ->
        entryId.forEach { incrementFeedUnreadCountByEntry(it, addend) }
    }
}

We would then need to update each and every Feed possibly associated with every given Entry ID, by either adding or subtracting 1 (representing one Entry) to its unreadCount. First we determine whether to increment positively or negatively using the given isRead—meaning if all given entries are to be marked as read, all relevant Feeds' unreadCount should go down, and vice versa. So we express that using an if-then statement and use the resulting addend, either 1 or -1, to make our updates. We then iterate through entryId (however many there are) and call incrementFeedUnreadCountByEntry with each iteration. In the end, every affected Feed's unreadCount is adjusted by as many given Entry IDs.

(if (isRead) -1 else 1).let { addend ->
    entryId.forEach { incrementFeedUnreadCountByEntry(it, addend) }
}

The second function of the aforementioned pair is much more straightforward, affecting only one Entry at a time. Basically it's a combination of the preceding method and updateEntryIsStarred, which was defined earlier within our EntriesDao.

@Transaction
fun updateEntryAndFeedUnreadCount(
    entryId: String,
    isRead: Boolean,
    isStarred: Boolean
) {
    updateEntryIsStarred(entryId, isStarred = isStarred)
    updateEntryIsReadAndFeedUnreadCount(entryId, isRead = isRead)
}

Deleting items

Another pair of functions represent the last methods within CombinedDao, both having to do with deleting items from our database. Just as in practical usage we would never add an Entry or Entries to our database separate from a Feed, we would never need to delete them in isolation as well. For a user to unsubscribe from an RSS feed, we would have to delete the Feed, all associated Entries, and all relevant cross references (FeedEntryCrossRefs) from our database all at once.

@Transaction
fun deleteFeedAndEntriesById(vararg feedId: String) {
    deleteEntriesByFeed(*feedId)
    deleteCrossRefsByFeed(*feedId)
    deleteFeeds(*feedId)
}

Note the order of sub-methods in the above example. We are given only a variable number of Feed IDs (url) for reference. For the relevant Entries to get deleted, we need to retain all cross references first, which is why the latter should not be removed before the former. When all that is done, we can safely delete Feeds it requires no reference to any other tables.

Finally, we have a method that deletes at once all leftover items in our database, that is, FeedEntryCrossRefs that point to Feeds that have already been removed, then Entries for which there are no longer any cross references to any Feed. Again, this is a safety method: if all goes according to plan we don't expect there to be any such leftover items in our database. But in case there are, it makes sense that they all be deleted at the same time.

@Transaction
fun deleteLeftoverItems() {
    deleteLeftoverCrossRefs()
    deleteLeftoverEntries()
}

Comments are not enabled (yet?). Please email me if you see anything that interests you.