diff --git a/CLAUDE.md b/CLAUDE.md index cea3944bd..6170fc105 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,5 +29,5 @@ Capy Reader is an RSS reader for Android split into several gradle modules - When naming accessors, prefer "savedSearches" over `getSavedSearches` unless there's a parameter, in which case use "get" - Prefer explicit named parameters when passing arguments to Jetpack Compose functions over positional arguments. - JavaScript files are written using JSDoc to ensure typechecking without the overhead of TypeScript. +- Where possible, prefer functional iteration (map, forEach) as opposed to for-loops - Prefer `orEmpty()` instead of `?: ""` -- Prefer functional iteration (map, forEach) as opposed to for-loops diff --git a/app/src/main/java/com/capyreader/app/ui/articles/AccountBuildArticlePagerExt.kt b/app/src/main/java/com/capyreader/app/ui/articles/AccountBuildArticlePagerExt.kt index 31de8e666..78588612c 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/AccountBuildArticlePagerExt.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/AccountBuildArticlePagerExt.kt @@ -13,11 +13,12 @@ fun Account.buildArticlePager( query: String? = null, sortOrder: SortOrder = SortOrder.NEWEST_FIRST, since: OffsetDateTime = OffsetDateTime.now() -): Pager { +): Pager { return Pager( config = PagingConfig( pageSize = 100, prefetchDistance = 10, + initialLoadSize = 50, ), pagingSourceFactory = { ArticlePagerFactory(database).findArticles( diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticlePagerFactory.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticlePagerFactory.kt index a1db6e400..8a2faa147 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticlePagerFactory.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticlePagerFactory.kt @@ -19,7 +19,7 @@ class ArticlePagerFactory(private val database: Database) { query: String?, sortOrder: SortOrder, since: OffsetDateTime - ): PagingSource { + ): PagingSource { return when (filter) { is ArticleFilter.Articles -> articleSource(filter, query, sortOrder, since) is ArticleFilter.Feeds -> feedSource(filter, query, sortOrder, since) @@ -35,25 +35,21 @@ class ArticlePagerFactory(private val database: Database) { query: String?, sortOrder: SortOrder, since: OffsetDateTime - ): PagingSource { + ): PagingSource { return QueryPagingSource( - countQuery = articles.byStatus.count( + transacter = database.articlesQueries, + context = Dispatchers.IO, + pageBoundariesProvider = articles.byStatus.pageBoundaries( status = filter.status, query = query, - since = since + since = since, + ), + queryProvider = articles.byStatus.keyed( + status = filter.status, + query = query, + sortOrder = sortOrder, + since = since, ), - transacter = database.articlesQueries, - context = Dispatchers.IO, - queryProvider = { limit, offset -> - articles.byStatus.all( - status = filter.status, - query = query, - since = since, - limit = limit, - sortOrder = sortOrder, - offset = offset, - ) - } ) } @@ -62,7 +58,7 @@ class ArticlePagerFactory(private val database: Database) { query: String?, sortOrder: SortOrder, since: OffsetDateTime, - ): PagingSource { + ): PagingSource { val feedIDs = listOf(filter.feedID) return feedsSource( @@ -80,7 +76,7 @@ class ArticlePagerFactory(private val database: Database) { query: String?, sortOrder: SortOrder, since: OffsetDateTime - ): PagingSource { + ): PagingSource { val feedIDs = database .taggingsQueries .findFeedIDs(folderTitle = filter.folderTitle) @@ -103,29 +99,25 @@ class ArticlePagerFactory(private val database: Database) { sortOrder: SortOrder, priority: FeedPriority, since: OffsetDateTime - ): PagingSource { + ): PagingSource { return QueryPagingSource( - countQuery = articles.byFeed.count( + transacter = database.articlesQueries, + context = Dispatchers.IO, + pageBoundariesProvider = articles.byFeed.pageBoundaries( feedIDs = feedIDs, status = filter.status, query = query, since = since, priority = priority, ), - transacter = database.articlesQueries, - context = Dispatchers.IO, - queryProvider = { limit, offset -> - articles.byFeed.all( - feedIDs = feedIDs, - status = filter.status, - query = query, - since = since, - limit = limit, - sortOrder = sortOrder, - offset = offset, - priority = priority, - ) - } + queryProvider = articles.byFeed.keyed( + feedIDs = feedIDs, + status = filter.status, + query = query, + sortOrder = sortOrder, + since = since, + priority = priority, + ), ) } @@ -134,27 +126,23 @@ class ArticlePagerFactory(private val database: Database) { query: String?, sortOrder: SortOrder, since: OffsetDateTime - ): PagingSource { + ): PagingSource { return QueryPagingSource( - countQuery = articles.bySavedSearch.count( + transacter = database.articlesQueries, + context = Dispatchers.IO, + pageBoundariesProvider = articles.bySavedSearch.pageBoundaries( savedSearchID = filter.savedSearchID, status = filter.status, query = query, - since = since + since = since, + ), + queryProvider = articles.bySavedSearch.keyed( + savedSearchID = filter.savedSearchID, + status = filter.status, + query = query, + sortOrder = sortOrder, + since = since, ), - transacter = database.articlesQueries, - context = Dispatchers.IO, - queryProvider = { limit, offset -> - articles.bySavedSearch.all( - savedSearchID = filter.savedSearchID, - status = filter.status, - query = query, - since = since, - limit = limit, - sortOrder = sortOrder, - offset = offset, - ) - } ) } @@ -189,25 +177,21 @@ class ArticlePagerFactory(private val database: Database) { query: String?, sortOrder: SortOrder, since: OffsetDateTime - ): PagingSource { + ): PagingSource { return QueryPagingSource( - countQuery = articles.byToday.count( + transacter = database.articlesQueries, + context = Dispatchers.IO, + pageBoundariesProvider = articles.byToday.pageBoundaries( status = filter.status, query = query, - since = since + since = since, + ), + queryProvider = articles.byToday.keyed( + status = filter.status, + query = query, + sortOrder = sortOrder, + since = since, ), - transacter = database.articlesQueries, - context = Dispatchers.IO, - queryProvider = { limit, offset -> - articles.byToday.all( - status = filter.status, - query = query, - limit = limit, - sortOrder = sortOrder, - offset = offset, - since = since, - ) - } ) } } diff --git a/bench/src/main/kotlin/com/jocmp/bench/Commands.kt b/bench/src/main/kotlin/com/jocmp/bench/Commands.kt index cdb10f3b9..cdd5752bd 100644 --- a/bench/src/main/kotlin/com/jocmp/bench/Commands.kt +++ b/bench/src/main/kotlin/com/jocmp/bench/Commands.kt @@ -89,30 +89,34 @@ suspend fun commandSelectProfile(account: Account) { val total = account.countAllByStatus(ArticleStatus.ALL).first() + val boundariesProvider = records.byStatus.pageBoundaries( + status = ArticleStatus.ALL, + ) + val queryProvider = records.byStatus.keyed( + status = ArticleStatus.ALL, + sortOrder = SortOrder.NEWEST_FIRST, + ) + val (pageCount, pageDuration) = measureTimedValue { - var offset = 0L + val boundaries = boundariesProvider(null, pageSize).executeAsList() var pages = 0 - while (offset < total) { - records.byStatus.all( - status = ArticleStatus.ALL, - limit = pageSize, - offset = offset, - sortOrder = SortOrder.NEWEST_FIRST, - ).executeAsList() - offset += pageSize + for (i in boundaries.indices) { + val begin = boundaries[i] + val end = boundaries.getOrNull(i + 1) + queryProvider(begin, end).executeAsList() pages++ } pages } - val firstArticleID = records.byStatus.all( - status = ArticleStatus.ALL, - limit = 1, - offset = 0, - sortOrder = SortOrder.NEWEST_FIRST, - ).executeAsOneOrNull()?.id + val firstPage = boundariesProvider(null, 1).executeAsList() + val firstArticleID = if (firstPage.isNotEmpty()) { + queryProvider(firstPage.first(), null).executeAsList().firstOrNull()?.id + } else { + null + } val (_, findDuration) = measureTimedValue { if (firstArticleID != null) { diff --git a/capy/src/main/java/com/jocmp/capy/AccountLatestArticlesExt.kt b/capy/src/main/java/com/jocmp/capy/AccountLatestArticlesExt.kt index 076bb855d..3552adc52 100644 --- a/capy/src/main/java/com/jocmp/capy/AccountLatestArticlesExt.kt +++ b/capy/src/main/java/com/jocmp/capy/AccountLatestArticlesExt.kt @@ -7,16 +7,22 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow fun Account.latestArticles(limit: Long = 30): Flow> { - return articleRecords + val boundariesProvider = articleRecords .byStatus - .all( + .pageBoundaries(status = ArticleStatus.UNREAD) + + val queryProvider = articleRecords + .byStatus + .keyed( status = ArticleStatus.UNREAD, - query = null, - since = null, - limit = limit, sortOrder = SortOrder.NEWEST_FIRST, - offset = 0, ) + + val boundaries = boundariesProvider(null, limit).executeAsList() + val begin = boundaries.firstOrNull() ?: return kotlinx.coroutines.flow.flowOf(emptyList()) + val end = boundaries.getOrNull(1) + + return queryProvider(begin, end) .asFlow() .mapToList(Dispatchers.IO) } diff --git a/capy/src/main/java/com/jocmp/capy/persistence/articles/ByArticleStatus.kt b/capy/src/main/java/com/jocmp/capy/persistence/articles/ByArticleStatus.kt index 9a16fd72c..7b7378f6f 100644 --- a/capy/src/main/java/com/jocmp/capy/persistence/articles/ByArticleStatus.kt +++ b/capy/src/main/java/com/jocmp/capy/persistence/articles/ByArticleStatus.kt @@ -72,6 +72,68 @@ class ByArticleStatus(private val database: Database) { return database.articlesQueries.lastUpdatedAt().executeAsOne().MAX } + fun pageBoundaries( + status: ArticleStatus, + query: String? = null, + since: OffsetDateTime? = null, + ): (anchor: Long?, limit: Long) -> Query { + val (read, starred) = status.toStatusPair + + return { anchor, limit -> + database.articlesByStatusQueries.pageBoundaries( + read = read, + starred = starred, + lastReadAt = mapLastRead(read, since), + lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = null, + query = query, + anchor = anchor, + limit = limit, + mapper = { publishedAt -> publishedAt ?: 0L } + ) + } + } + + fun keyed( + status: ArticleStatus, + query: String? = null, + sortOrder: SortOrder, + since: OffsetDateTime? = null, + ): (beginInclusive: Long, endExclusive: Long?) -> Query
{ + val (read, starred) = status.toStatusPair + val queries = database.articlesByStatusQueries + + return if (isNewestFirst(sortOrder)) { + { begin, end -> + queries.keyedNewestFirst( + read = read, + starred = starred, + lastReadAt = mapLastRead(read, since), + lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = null, + query = query, + beginInclusive = begin, + endExclusive = end, + mapper = ::listMapper, + ) + } + } else { + { begin, end -> + queries.keyedOldestFirst( + read = read, + starred = starred, + lastReadAt = mapLastRead(read, since), + lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = null, + query = query, + beginInclusive = begin, + endExclusive = end, + mapper = ::listMapper, + ) + } + } + } + fun count( status: ArticleStatus, query: String? = null, diff --git a/capy/src/main/java/com/jocmp/capy/persistence/articles/ByFeed.kt b/capy/src/main/java/com/jocmp/capy/persistence/articles/ByFeed.kt index 3b62f9f07..3b018fc90 100644 --- a/capy/src/main/java/com/jocmp/capy/persistence/articles/ByFeed.kt +++ b/capy/src/main/java/com/jocmp/capy/persistence/articles/ByFeed.kt @@ -23,7 +23,6 @@ class ByFeed(private val database: Database) { priority: FeedPriority, ): Query
{ val (read, starred) = status.toStatusPair - val queries = database.articlesByFeedQueries return if (isDescendingOrder(sortOrder)) { @@ -80,6 +79,78 @@ class ByFeed(private val database: Database) { ) } + fun pageBoundaries( + feedIDs: List, + status: ArticleStatus, + query: String? = null, + since: OffsetDateTime? = null, + priority: FeedPriority, + ): (anchor: Long?, limit: Long) -> Query { + val (read, starred) = status.toStatusPair + + return { anchor, limit -> + database.articlesByFeedQueries.pageBoundaries( + feedIDs = feedIDs, + read = read, + starred = starred, + lastReadAt = mapLastRead(read, since), + lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = null, + query = query, + priorities = priority.inclusivePriorities, + anchor = anchor, + limit = limit, + mapper = { publishedAt -> publishedAt ?: 0L } + ) + } + } + + fun keyed( + feedIDs: List, + status: ArticleStatus, + query: String? = null, + sortOrder: SortOrder, + since: OffsetDateTime? = null, + priority: FeedPriority, + ): (beginInclusive: Long, endExclusive: Long?) -> Query
{ + val (read, starred) = status.toStatusPair + val queries = database.articlesByFeedQueries + + return if (isDescendingOrder(sortOrder)) { + { begin, end -> + queries.keyedNewestFirst( + feedIDs = feedIDs, + read = read, + starred = starred, + lastReadAt = mapLastRead(read, since), + lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = null, + query = query, + priorities = priority.inclusivePriorities, + beginInclusive = begin, + endExclusive = end, + mapper = ::listMapper, + ) + } + } else { + { begin, end -> + queries.keyedOldestFirst( + feedIDs = feedIDs, + read = read, + starred = starred, + lastReadAt = mapLastRead(read, since), + lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = null, + query = query, + priorities = priority.inclusivePriorities, + beginInclusive = begin, + endExclusive = end, + mapper = ::listMapper, + ) + } + } + } + fun count( feedIDs: List, status: ArticleStatus, diff --git a/capy/src/main/java/com/jocmp/capy/persistence/articles/BySavedSearch.kt b/capy/src/main/java/com/jocmp/capy/persistence/articles/BySavedSearch.kt index daea1233a..0f469d046 100644 --- a/capy/src/main/java/com/jocmp/capy/persistence/articles/BySavedSearch.kt +++ b/capy/src/main/java/com/jocmp/capy/persistence/articles/BySavedSearch.kt @@ -11,48 +11,6 @@ import com.jocmp.capy.persistence.toStatusPair import java.time.OffsetDateTime class BySavedSearch(private val database: Database) { - fun all( - savedSearchID: String, - status: ArticleStatus, - query: String? = null, - since: OffsetDateTime, - limit: Long, - sortOrder: SortOrder, - offset: Long, - ): Query
{ - val (read, starred) = status.toStatusPair - - val queries = database.articlesBySavedSearchQueries - - return if (isDescendingOrder(sortOrder)) { - queries.allNewestFirst( - savedSearchID = savedSearchID, - query = query, - read = read, - starred = starred, - limit = limit, - offset = offset, - lastReadAt = mapLastRead(read, since), - lastUnstarredAt = mapLastUnstarred(starred, since), - publishedSince = null, - mapper = ::listMapper - ) - } else { - queries.allOldestFirst( - savedSearchID = savedSearchID, - query = query, - read = read, - starred = starred, - limit = limit, - offset = offset, - lastReadAt = mapLastRead(read, since), - lastUnstarredAt = mapLastUnstarred(starred, since), - publishedSince = null, - mapper = ::listMapper - ) - } - } - fun unreadArticleIDs( status: ArticleStatus, savedSearchID: String, @@ -75,6 +33,73 @@ class BySavedSearch(private val database: Database) { ) } + fun pageBoundaries( + savedSearchID: String, + status: ArticleStatus, + query: String? = null, + since: OffsetDateTime? = null, + ): (anchor: Long?, limit: Long) -> Query { + val (read, starred) = status.toStatusPair + + return { anchor, limit -> + database.articlesBySavedSearchQueries.pageBoundaries( + savedSearchID = savedSearchID, + read = read, + starred = starred, + lastReadAt = mapLastRead(read, since), + lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = null, + query = query, + anchor = anchor, + limit = limit, + mapper = { publishedAt -> publishedAt ?: 0L } + ) + } + } + + fun keyed( + savedSearchID: String, + status: ArticleStatus, + query: String? = null, + sortOrder: SortOrder, + since: OffsetDateTime? = null, + ): (beginInclusive: Long, endExclusive: Long?) -> Query
{ + val (read, starred) = status.toStatusPair + val queries = database.articlesBySavedSearchQueries + + return if (isDescendingOrder(sortOrder)) { + { begin, end -> + queries.keyedNewestFirst( + savedSearchID = savedSearchID, + read = read, + starred = starred, + lastReadAt = mapLastRead(read, since), + lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = null, + query = query, + beginInclusive = begin, + endExclusive = end, + mapper = ::listMapper, + ) + } + } else { + { begin, end -> + queries.keyedOldestFirst( + savedSearchID = savedSearchID, + read = read, + starred = starred, + lastReadAt = mapLastRead(read, since), + lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = null, + query = query, + beginInclusive = begin, + endExclusive = end, + mapper = ::listMapper, + ) + } + } + } + fun count( savedSearchID: String, status: ArticleStatus, diff --git a/capy/src/main/java/com/jocmp/capy/persistence/articles/ByToday.kt b/capy/src/main/java/com/jocmp/capy/persistence/articles/ByToday.kt index 03a23b9e2..cb8eb5c98 100644 --- a/capy/src/main/java/com/jocmp/capy/persistence/articles/ByToday.kt +++ b/capy/src/main/java/com/jocmp/capy/persistence/articles/ByToday.kt @@ -11,61 +11,85 @@ import com.jocmp.capy.persistence.toStatusPair import java.time.OffsetDateTime class ByToday(private val database: Database) { - fun all( + fun unreadArticleIDs( status: ArticleStatus, - query: String? = null, - limit: Long, - offset: Long, + range: MarkRead, sortOrder: SortOrder, - since: OffsetDateTime?, - ): Query
{ + query: String?, + ): Query { + val (_, starred) = status.toStatusPair + val (afterArticleID, beforeArticleID) = range.toPair + + return database.articlesByStatusQueries.findArticleIDs( + starred = starred, + afterArticleID = afterArticleID, + beforeArticleID = beforeArticleID, + publishedSince = mapTodayStartDate(), + newestFirst = isNewestFirst(sortOrder), + query = query, + ) + } + + fun pageBoundaries( + status: ArticleStatus, + query: String? = null, + since: OffsetDateTime? = null, + ): (anchor: Long?, limit: Long) -> Query { val (read, starred) = status.toStatusPair - val queries = database.articlesByStatusQueries - return if (isNewestFirst(sortOrder)) { - queries.allNewestFirst( + return { anchor, limit -> + database.articlesByStatusQueries.pageBoundaries( read = read, starred = starred, - limit = limit, - offset = offset, lastReadAt = mapLastRead(read, since), lastUnstarredAt = mapLastUnstarred(starred, since), publishedSince = mapTodayStartDate(), query = query, - mapper = ::listMapper - ) - } else { - queries.allOldestFirst( - read = read, - starred = starred, + anchor = anchor, limit = limit, - offset = offset, - lastReadAt = mapLastRead(read, since), - lastUnstarredAt = mapLastUnstarred(starred, since), - publishedSince = mapTodayStartDate(), - query = query, - mapper = ::listMapper + mapper = { publishedAt -> publishedAt ?: 0L } ) } } - fun unreadArticleIDs( + fun keyed( status: ArticleStatus, - range: MarkRead, + query: String? = null, sortOrder: SortOrder, - query: String?, - ): Query { - val (_, starred) = status.toStatusPair - val (afterArticleID, beforeArticleID) = range.toPair + since: OffsetDateTime? = null, + ): (beginInclusive: Long, endExclusive: Long?) -> Query
{ + val (read, starred) = status.toStatusPair + val queries = database.articlesByStatusQueries - return database.articlesByStatusQueries.findArticleIDs( - starred = starred, - afterArticleID = afterArticleID, - beforeArticleID = beforeArticleID, - publishedSince = mapTodayStartDate(), - newestFirst = isNewestFirst(sortOrder), - query = query, - ) + return if (isNewestFirst(sortOrder)) { + { begin, end -> + queries.keyedNewestFirst( + read = read, + starred = starred, + lastReadAt = mapLastRead(read, since), + lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = mapTodayStartDate(), + query = query, + beginInclusive = begin, + endExclusive = end, + mapper = ::listMapper, + ) + } + } else { + { begin, end -> + queries.keyedOldestFirst( + read = read, + starred = starred, + lastReadAt = mapLastRead(read, since), + lastUnstarredAt = mapLastUnstarred(starred, since), + publishedSince = mapTodayStartDate(), + query = query, + beginInclusive = begin, + endExclusive = end, + mapper = ::listMapper, + ) + } + } } fun count( diff --git a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByFeed.sq b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByFeed.sq index 1e4f47fd3..34efdd329 100644 --- a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByFeed.sq +++ b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByFeed.sq @@ -70,6 +70,91 @@ AND (:query IS NULL OR articles.title LIKE '%' || :query || '%' OR articles.summ AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) AND (feeds.priority IN :priorities OR feeds.priority IS NULL); +pageBoundaries: +SELECT published_at +FROM ( + SELECT + articles.published_at, + CASE + WHEN ((row_number() OVER(ORDER BY articles.published_at DESC) - 1) % :limit) = 0 THEN 1 + WHEN articles.published_at = :anchor THEN 1 + ELSE 0 + END page_boundary + FROM articles + JOIN feeds ON articles.feed_id = feeds.id + JOIN article_statuses ON articles.id = article_statuses.article_id + WHERE articles.feed_id IN :feedIDs + AND ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) + AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) + AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) + AND (articles.title LIKE '%' || :query || '%' OR articles.summary LIKE '%' || :query || '%' OR :query IS NULL) + AND (feeds.priority IN :priorities OR feeds.priority IS NULL) + ORDER BY articles.published_at DESC +) +WHERE page_boundary = 1; + +keyedNewestFirst: +SELECT + articles.id, + articles.feed_id, + articles.title, + articles.author, + articles.url, + articles.summary, + articles.image_url, + articles.published_at, + articles.enclosure_type, + feeds.title AS feed_title, + feeds.favicon_url, + feeds.open_articles_in_browser, + feeds.read_later, + article_statuses.updated_at, + article_statuses.starred, + article_statuses.read +FROM articles +JOIN feeds ON articles.feed_id = feeds.id +JOIN article_statuses ON articles.id = article_statuses.article_id +WHERE articles.feed_id IN :feedIDs +AND ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) +AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) +AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) +AND (articles.title LIKE '%' || :query || '%' OR articles.summary LIKE '%' || :query || '%' OR :query IS NULL) +AND (feeds.priority IN :priorities OR feeds.priority IS NULL) +AND articles.published_at <= :beginInclusive +AND (articles.published_at > :endExclusive OR :endExclusive IS NULL) +ORDER BY articles.published_at DESC; + +keyedOldestFirst: +SELECT + articles.id, + articles.feed_id, + articles.title, + articles.author, + articles.url, + articles.summary, + articles.image_url, + articles.published_at, + articles.enclosure_type, + feeds.title AS feed_title, + feeds.favicon_url, + feeds.open_articles_in_browser, + feeds.read_later, + article_statuses.updated_at, + article_statuses.starred, + article_statuses.read +FROM articles +JOIN feeds ON articles.feed_id = feeds.id +JOIN article_statuses ON articles.id = article_statuses.article_id +WHERE articles.feed_id IN :feedIDs +AND ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) +AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) +AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) +AND (articles.title LIKE '%' || :query || '%' OR articles.summary LIKE '%' || :query || '%' OR :query IS NULL) +AND (feeds.priority IN :priorities OR feeds.priority IS NULL) +AND articles.published_at >= :beginInclusive +AND (articles.published_at < :endExclusive OR :endExclusive IS NULL) +ORDER BY articles.published_at ASC; + findArticleIDs: SELECT articles.id FROM articles diff --git a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesBySavedSearch.sq b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesBySavedSearch.sq index d3a41d6bb..c1876b9b5 100644 --- a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesBySavedSearch.sq +++ b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesBySavedSearch.sq @@ -1,4 +1,38 @@ -allNewestFirst: +countAll: +SELECT COUNT(*) +FROM articles +JOIN article_statuses ON articles.id = article_statuses.article_id +JOIN saved_search_articles ON articles.id = saved_search_articles.article_id +WHERE saved_search_id = :savedSearchID +AND ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) +AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) +AND (:query IS NULL OR articles.title LIKE '%' || :query || '%' OR articles.summary LIKE '%' || :query || '%') +AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL); + +pageBoundaries: +SELECT published_at +FROM ( + SELECT + articles.published_at, + CASE + WHEN ((row_number() OVER(ORDER BY articles.published_at DESC) - 1) % :limit) = 0 THEN 1 + WHEN articles.published_at = :anchor THEN 1 + ELSE 0 + END page_boundary + FROM articles + JOIN feeds ON articles.feed_id = feeds.id + JOIN article_statuses ON articles.id = article_statuses.article_id + JOIN saved_search_articles ON articles.id = saved_search_articles.article_id + WHERE saved_search_id = :savedSearchID + AND ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) + AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) + AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) + AND (articles.title LIKE '%' || :query || '%' OR articles.summary LIKE '%' || :query || '%' OR :query IS NULL) + ORDER BY articles.published_at DESC +) +WHERE page_boundary = 1; + +keyedNewestFirst: SELECT articles.id, articles.feed_id, @@ -24,11 +58,12 @@ WHERE saved_search_id = :savedSearchID AND ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) -AND (:query IS NULL OR articles.title LIKE '%' || :query || '%' OR articles.summary LIKE '%' || :query || '%') -ORDER BY articles.published_at DESC -LIMIT :limit OFFSET :offset; +AND (articles.title LIKE '%' || :query || '%' OR articles.summary LIKE '%' || :query || '%' OR :query IS NULL) +AND articles.published_at <= :beginInclusive +AND (articles.published_at > :endExclusive OR :endExclusive IS NULL) +ORDER BY articles.published_at DESC; -allOldestFirst: +keyedOldestFirst: SELECT articles.id, articles.feed_id, @@ -54,20 +89,10 @@ WHERE saved_search_id = :savedSearchID AND ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) -AND (:query IS NULL OR articles.title LIKE '%' || :query || '%' OR articles.summary LIKE '%' || :query || '%') -ORDER BY articles.published_at ASC -LIMIT :limit OFFSET :offset; - -countAll: -SELECT COUNT(*) -FROM articles -JOIN article_statuses ON articles.id = article_statuses.article_id -JOIN saved_search_articles ON articles.id = saved_search_articles.article_id -WHERE saved_search_id = :savedSearchID -AND ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) -AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) -AND (:query IS NULL OR articles.title LIKE '%' || :query || '%' OR articles.summary LIKE '%' || :query || '%') -AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL); +AND (articles.title LIKE '%' || :query || '%' OR articles.summary LIKE '%' || :query || '%' OR :query IS NULL) +AND articles.published_at >= :beginInclusive +AND (articles.published_at < :endExclusive OR :endExclusive IS NULL) +ORDER BY articles.published_at ASC; findArticleIDs: SELECT articles.id diff --git a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByStatus.sq b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByStatus.sq index 47119b5eb..211570996 100644 --- a/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByStatus.sq +++ b/capy/src/main/sqldelight/com/jocmp/capy/db/articlesByStatus.sq @@ -70,6 +70,91 @@ AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) AND (:query IS NULL OR articles.title LIKE '%' || :query || '%' OR articles.summary LIKE '%' || :query || '%'); +pageBoundaries: +SELECT published_at +FROM ( + SELECT + articles.published_at, + CASE + WHEN ((row_number() OVER(ORDER BY articles.published_at DESC) - 1) % :limit) = 0 THEN 1 + WHEN articles.published_at = :anchor THEN 1 + ELSE 0 + END page_boundary + FROM articles + JOIN feeds ON articles.feed_id = feeds.id + JOIN article_statuses ON articles.id = article_statuses.article_id + WHERE ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) + AND (feeds.priority IS NULL OR feeds.priority IN ('main', 'important')) + AND feeds.read_later = 0 + AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) + AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) + AND (articles.title LIKE '%' || :query || '%' OR articles.summary LIKE '%' || :query || '%' OR :query IS NULL) + ORDER BY articles.published_at DESC +) +WHERE page_boundary = 1; + +keyedNewestFirst: +SELECT + articles.id, + articles.feed_id, + articles.title, + articles.author, + articles.url, + articles.summary, + articles.image_url, + articles.published_at, + articles.enclosure_type, + feeds.title AS feed_title, + feeds.favicon_url, + feeds.open_articles_in_browser, + feeds.read_later, + article_statuses.updated_at, + article_statuses.starred, + article_statuses.read +FROM articles +JOIN feeds ON articles.feed_id = feeds.id +JOIN article_statuses ON articles.id = article_statuses.article_id +WHERE ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) +AND (feeds.priority IS NULL OR feeds.priority IN ('main', 'important')) +AND feeds.read_later = 0 +AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) +AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) +AND (articles.title LIKE '%' || :query || '%' OR articles.summary LIKE '%' || :query || '%' OR :query IS NULL) +AND articles.published_at <= :beginInclusive +AND (articles.published_at > :endExclusive OR :endExclusive IS NULL) +ORDER BY articles.published_at DESC; + +keyedOldestFirst: +SELECT + articles.id, + articles.feed_id, + articles.title, + articles.author, + articles.url, + articles.summary, + articles.image_url, + articles.published_at, + articles.enclosure_type, + feeds.title AS feed_title, + feeds.favicon_url, + feeds.open_articles_in_browser, + feeds.read_later, + article_statuses.updated_at, + article_statuses.starred, + article_statuses.read +FROM articles +JOIN feeds ON articles.feed_id = feeds.id +JOIN article_statuses ON articles.id = article_statuses.article_id +WHERE ((article_statuses.read = :read AND article_statuses.last_read_at IS NULL OR article_statuses.last_read_at >= :lastReadAt) OR :read IS NULL) +AND (feeds.priority IS NULL OR feeds.priority IN ('main', 'important')) +AND feeds.read_later = 0 +AND ((article_statuses.starred = :starred AND article_statuses.last_unstarred_at IS NULL OR article_statuses.last_unstarred_at >= :lastUnstarredAt) OR :starred IS NULL) +AND (articles.published_at >= :publishedSince OR :publishedSince IS NULL) +AND (articles.title LIKE '%' || :query || '%' OR articles.summary LIKE '%' || :query || '%' OR :query IS NULL) +AND articles.published_at >= :beginInclusive +AND (articles.published_at < :endExclusive OR :endExclusive IS NULL) +ORDER BY articles.published_at ASC; + findArticleIDs: SELECT articles.id FROM articles diff --git a/capy/src/test/java/com/jocmp/capy/persistence/ArticleRecordsTest.kt b/capy/src/test/java/com/jocmp/capy/persistence/ArticleRecordsTest.kt index 7343553c1..b09e67be4 100644 --- a/capy/src/test/java/com/jocmp/capy/persistence/ArticleRecordsTest.kt +++ b/capy/src/test/java/com/jocmp/capy/persistence/ArticleRecordsTest.kt @@ -354,6 +354,77 @@ class ArticleRecordsTest { assertEquals(expected = 2, actual = results.size) } + @Test + fun keyedPaging_returnsArticlesInOrder() = runTest { + val now = nowUTC() + val total = 5 + val articles = (1..total).map { i -> + articleFixture.create( + publishedAt = now.minusHours(i.toLong()).toEpochSecond() + ) + } + + val records = ArticleRecords(database) + val pageSize = 2L + + val boundariesProvider = records.byStatus.pageBoundaries( + status = ArticleStatus.ALL, + ) + val queryProvider = records.byStatus.keyed( + status = ArticleStatus.ALL, + sortOrder = SortOrder.NEWEST_FIRST, + ) + + val boundaries = boundariesProvider(null, pageSize).executeAsList() + assertEquals(expected = 3, actual = boundaries.size) + + val allResults = boundaries.flatMapIndexed { i, begin -> + val end = boundaries.getOrNull(i + 1) + queryProvider(begin, end).executeAsList() + } + + assertEquals(expected = 5, actual = allResults.size) + assertEquals( + expected = articles.map { it.id }, + actual = allResults.map { it.id }, + ) + } + + @Test + fun keyedPaging_oldestFirst() = runTest { + val now = nowUTC() + val total = 5 + val articles = (1..total).map { i -> + articleFixture.create( + publishedAt = now.minusHours(i.toLong()).toEpochSecond() + ) + } + + val records = ArticleRecords(database) + val pageSize = 2L + + val boundariesProvider = records.byStatus.pageBoundaries( + status = ArticleStatus.ALL, + ) + val queryProvider = records.byStatus.keyed( + status = ArticleStatus.ALL, + sortOrder = SortOrder.OLDEST_FIRST, + ) + + val boundaries = boundariesProvider(null, pageSize).executeAsList() + + val allResults = boundaries.flatMapIndexed { i, begin -> + val end = boundaries.getOrNull(i + 1) + queryProvider(begin, end).executeAsList() + } + + assertEquals(expected = total, actual = allResults.size) + assertEquals( + expected = articles.reversed().map { it.id }, + actual = allResults.map { it.id }, + ) + } + @Test fun markAllUnread() = runTest { val articleIDs = 3.repeated { RandomUUID.generate() }