mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
index the album, artist and playlist list sorts
This commit is contained in:
@@ -3,16 +3,21 @@ package com.subtracks.data.db
|
||||
import android.content.Context
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useReaderConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.Source
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -108,6 +113,192 @@ class LibraryDaoTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun starredSortsKeepUnstarredLastInBothDirections() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "a1", "Alpha", year = null, starred = 100),
|
||||
album(sourceId, "a2", "Beta", year = null, starred = 200),
|
||||
album(sourceId, "a3", "Gamma", year = null, starred = null),
|
||||
album(sourceId, "a4", "Delta", year = null, starred = null),
|
||||
),
|
||||
)
|
||||
dao.upsertArtists(
|
||||
listOf(
|
||||
artist(sourceId, "r1", "Alpha", albumCount = 1).copy(starred = 100),
|
||||
artist(sourceId, "r2", "Beta", albumCount = 1).copy(starred = 200),
|
||||
artist(sourceId, "r3", "Gamma", albumCount = 1).copy(starred = null),
|
||||
artist(sourceId, "r4", "Delta", albumCount = 1).copy(starred = null),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("Beta", "Alpha", "Delta", "Gamma"),
|
||||
dao.albumsByStarred(sourceId, 0, "").page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Alpha", "Beta", "Gamma", "Delta"),
|
||||
dao.albumsByStarredReversed(sourceId, 0, "").page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beta", "Alpha", "Delta", "Gamma"),
|
||||
dao.artistsByStarred(sourceId, 0, "").page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Alpha", "Beta", "Gamma", "Delta"),
|
||||
dao.artistsByStarredReversed(sourceId, 0, "").page().map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reversedAlbumSortsReverseEveryTiebreaker() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "a1", "Same", year = 2000, starred = null, created = 100).copy(albumArtist = "Zed"),
|
||||
album(sourceId, "a2", "Same", year = 2000, starred = null, created = 100).copy(albumArtist = "Amy"),
|
||||
album(sourceId, "a3", "Same", year = 1990, starred = null, created = 200).copy(albumArtist = "Amy"),
|
||||
album(sourceId, "a4", "Other", year = 2000, starred = null, created = 100).copy(albumArtist = "Amy"),
|
||||
),
|
||||
)
|
||||
|
||||
val pairs =
|
||||
listOf(
|
||||
dao.albumsByName(sourceId, 0, "") to dao.albumsByNameReversed(sourceId, 0, ""),
|
||||
dao.albumsByArtist(sourceId, 0, "") to dao.albumsByArtistReversed(sourceId, 0, ""),
|
||||
dao.albumsByYear(sourceId, 0, "") to dao.albumsByYearReversed(sourceId, 0, ""),
|
||||
dao.albumsByRecentlyAdded(sourceId, 0, "") to dao.albumsByRecentlyAddedReversed(sourceId, 0, ""),
|
||||
)
|
||||
for ((base, reversed) in pairs) {
|
||||
val ids = base.page().map { it.id }
|
||||
assertEquals(ids.reversed(), reversed.page().map { it.id })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reversedArtistAndPlaylistSortsReverseEveryTiebreaker() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(
|
||||
listOf(
|
||||
artist(sourceId, "r1", "Same", albumCount = 2),
|
||||
artist(sourceId, "r2", "Other", albumCount = 1),
|
||||
artist(sourceId, "r3", "Same", albumCount = 1),
|
||||
),
|
||||
)
|
||||
dao.upsertPlaylists(
|
||||
listOf(
|
||||
playlist(sourceId, "l1", "Same", created = 100, changed = 100),
|
||||
playlist(sourceId, "l2", "Other", created = 100, changed = 200),
|
||||
playlist(sourceId, "l3", "Same", created = 200, changed = 100),
|
||||
),
|
||||
)
|
||||
|
||||
val artistPairs =
|
||||
listOf(
|
||||
dao.artistsByName(sourceId, 0, "").page().map { it.id } to
|
||||
dao.artistsByNameReversed(sourceId, 0, "").page().map { it.id },
|
||||
dao.artistsByAlbumCount(sourceId, 0, "").page().map { it.id } to
|
||||
dao.artistsByAlbumCountReversed(sourceId, 0, "").page().map { it.id },
|
||||
)
|
||||
val playlistPairs =
|
||||
listOf(
|
||||
dao.playlistsByName(sourceId, "").page().map { it.id } to
|
||||
dao.playlistsByNameReversed(sourceId, "").page().map { it.id },
|
||||
dao.playlistsByAdded(sourceId, "").page().map { it.id } to
|
||||
dao.playlistsByAddedReversed(sourceId, "").page().map { it.id },
|
||||
dao.playlistsByUpdated(sourceId, "").page().map { it.id } to
|
||||
dao.playlistsByUpdatedReversed(sourceId, "").page().map { it.id },
|
||||
)
|
||||
for ((base, reversed) in artistPairs + playlistPairs) {
|
||||
assertEquals(base.reversed(), reversed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumOrdersAreIndexBacked() =
|
||||
runTest {
|
||||
val orders =
|
||||
listOf(
|
||||
ALBUM_ORDER_BY_NAME to "index_albums_name",
|
||||
ALBUM_ORDER_BY_NAME_REVERSED to "index_albums_name",
|
||||
ALBUM_ORDER_BY_ARTIST to "index_albums_artist",
|
||||
ALBUM_ORDER_BY_ARTIST_REVERSED to "index_albums_artist",
|
||||
ALBUM_ORDER_BY_YEAR to "index_albums_year",
|
||||
ALBUM_ORDER_BY_YEAR_REVERSED to "index_albums_year",
|
||||
ALBUM_ORDER_BY_ADDED to "index_albums_added",
|
||||
ALBUM_ORDER_BY_ADDED_REVERSED to "index_albums_added",
|
||||
ALBUM_ORDER_BY_STARRED to "index_albums_starred",
|
||||
ALBUM_ORDER_BY_STARRED_REVERSED to "index_albums_starred",
|
||||
)
|
||||
for ((order, index) in orders) {
|
||||
val plan = plan("SELECT * $ALBUMS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
|
||||
assertTrue(plan, plan.contains("USING INDEX $index"))
|
||||
assertFalse(plan, plan.contains("TEMP B-TREE"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistOrdersAreIndexBacked() =
|
||||
runTest {
|
||||
val orders =
|
||||
listOf(
|
||||
ARTIST_ORDER_BY_NAME to "index_artists_name",
|
||||
ARTIST_ORDER_BY_NAME_REVERSED to "index_artists_name",
|
||||
ARTIST_ORDER_BY_ALBUM_COUNT to "index_artists_albumCount",
|
||||
ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED to "index_artists_albumCount",
|
||||
ARTIST_ORDER_BY_STARRED to "index_artists_starred",
|
||||
ARTIST_ORDER_BY_STARRED_REVERSED to "index_artists_starred",
|
||||
)
|
||||
for ((order, index) in orders) {
|
||||
val plan = plan("SELECT * $ARTISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
|
||||
assertTrue(plan, plan.contains("USING INDEX $index"))
|
||||
assertFalse(plan, plan.contains("TEMP B-TREE"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playlistOrdersAreIndexBacked() =
|
||||
runTest {
|
||||
val orders =
|
||||
listOf(
|
||||
PLAYLIST_ORDER_BY_NAME to "index_playlists_name",
|
||||
PLAYLIST_ORDER_BY_NAME_REVERSED to "index_playlists_name",
|
||||
PLAYLIST_ORDER_BY_ADDED to "index_playlists_added",
|
||||
PLAYLIST_ORDER_BY_ADDED_REVERSED to "index_playlists_added",
|
||||
PLAYLIST_ORDER_BY_UPDATED to "index_playlists_updated",
|
||||
PLAYLIST_ORDER_BY_UPDATED_REVERSED to "index_playlists_updated",
|
||||
)
|
||||
for ((order, index) in orders) {
|
||||
val plan = plan("SELECT * $PLAYLISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
|
||||
assertTrue(plan, plan.contains("USING INDEX $index"))
|
||||
assertFalse(plan, plan.contains("TEMP B-TREE"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnindexedAlbumOrderStillShowsATempSort() =
|
||||
runTest {
|
||||
val plan = plan("SELECT * $ALBUMS_FILTER ORDER BY songCount, id LIMIT 20 OFFSET 0")
|
||||
|
||||
assertTrue(plan, plan.contains("TEMP B-TREE"))
|
||||
}
|
||||
|
||||
private suspend fun plan(sql: String): String =
|
||||
db.useReaderConnection { connection ->
|
||||
connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement ->
|
||||
val details = mutableListOf<String>()
|
||||
while (statement.step()) details += statement.getText(3)
|
||||
details.joinToString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun source(): Long =
|
||||
db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0))
|
||||
|
||||
@@ -139,6 +330,38 @@ class LibraryDaoTest {
|
||||
songCount = 1,
|
||||
)
|
||||
|
||||
private fun artist(
|
||||
sourceId: Long,
|
||||
id: String,
|
||||
name: String,
|
||||
albumCount: Long,
|
||||
) = Artist(
|
||||
sourceId = sourceId,
|
||||
id = id,
|
||||
name = name,
|
||||
albumCount = albumCount,
|
||||
starred = null,
|
||||
coverArt = null,
|
||||
)
|
||||
|
||||
private fun playlist(
|
||||
sourceId: Long,
|
||||
id: String,
|
||||
name: String,
|
||||
created: Long,
|
||||
changed: Long,
|
||||
) = Playlist(
|
||||
sourceId = sourceId,
|
||||
id = id,
|
||||
name = name,
|
||||
comment = null,
|
||||
coverArt = null,
|
||||
songCount = 1,
|
||||
created = created,
|
||||
changed = changed,
|
||||
duration = 100,
|
||||
)
|
||||
|
||||
private fun song(
|
||||
sourceId: Long,
|
||||
id: String,
|
||||
|
||||
@@ -47,6 +47,109 @@ class MigrationsTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theLibrarySortIndicesInheritCaseInsensitiveCollationAndDirection() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, versions.first())
|
||||
MIGRATIONS.sortedBy { it.startVersion }.forEach { it.migrate(migrated) }
|
||||
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_name", "name").collation)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "albumArtist").collation)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "name").collation)
|
||||
assertEquals(1, indexColumn(migrated, "index_albums_year", "year").descending)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_year", "name").collation)
|
||||
assertEquals(0, indexColumn(migrated, "index_albums_year", "name").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_albums_added", "created").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_albums_starred", "starred").descending)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "name").collation)
|
||||
assertEquals(1, indexColumn(migrated, "index_artists_albumCount", "albumCount").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_artists_starred", "starred").descending)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_playlists_name", "name").collation)
|
||||
assertEquals(1, indexColumn(migrated, "index_playlists_added", "created").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_playlists_updated", "changed").descending)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thePlaylistChangedAndDurationColumnsSurviveTheRebuild() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, versions.first())
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) " +
|
||||
"VALUES (1, 'server', 'http://localhost', 1, 100)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
|
||||
"VALUES (1, 'ar1', 'The Artist', 1, NULL)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, " +
|
||||
"starred, songCount, frequentRank, recentRank) " +
|
||||
"VALUES (1, 'al1', 'ar1', 'The Album', 'The Artist', 101, NULL, NULL, 1999, NULL, 1, NULL, NULL)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO playlists (sourceId, id, name, comment, coverArt, songCount, created) " +
|
||||
"VALUES (1, 'pl1', 'The Playlist', NULL, NULL, 3, 102)",
|
||||
)
|
||||
|
||||
MIGRATIONS.sortedBy { it.startVersion }.forEach { migration ->
|
||||
migration.migrate(migrated)
|
||||
when (migration.endVersion) {
|
||||
5 -> migrated.execSQL("UPDATE playlists SET duration = 987654 WHERE id = 'pl1'")
|
||||
8 -> migrated.execSQL("UPDATE playlists SET changed = 123456 WHERE id = 'pl1'")
|
||||
11 -> migrated.execSQL("INSERT INTO discs (sourceId, albumId, disc, title) VALUES (1, 'al1', 2, 'The Disc')")
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals("The Playlist", text(migrated, "SELECT name FROM playlists WHERE id = 'pl1'"))
|
||||
assertEquals(123456L, long(migrated, "SELECT changed FROM playlists WHERE id = 'pl1'"))
|
||||
assertEquals(987654L, long(migrated, "SELECT duration FROM playlists WHERE id = 'pl1'"))
|
||||
assertEquals("The Album", text(migrated, "SELECT name FROM albums WHERE id = 'al1'"))
|
||||
assertEquals("The Artist", text(migrated, "SELECT name FROM artists WHERE id = 'ar1'"))
|
||||
assertEquals("The Disc", text(migrated, "SELECT title FROM discs WHERE albumId = 'al1' AND disc = 2"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun text(
|
||||
connection: SQLiteConnection,
|
||||
sql: String,
|
||||
): String =
|
||||
connection.prepare(sql).use { statement ->
|
||||
check(statement.step()) { "No row for $sql" }
|
||||
statement.getText(0)
|
||||
}
|
||||
|
||||
private fun long(
|
||||
connection: SQLiteConnection,
|
||||
sql: String,
|
||||
): Long =
|
||||
connection.prepare(sql).use { statement ->
|
||||
check(statement.step()) { "No row for $sql" }
|
||||
statement.getLong(0)
|
||||
}
|
||||
|
||||
private fun indexColumn(
|
||||
connection: SQLiteConnection,
|
||||
index: String,
|
||||
column: String,
|
||||
): IndexColumn {
|
||||
connection.prepare("PRAGMA index_xinfo(`$index`)").use { statement ->
|
||||
while (statement.step()) {
|
||||
if (!statement.isNull(2) && statement.getText(2) == column) {
|
||||
return IndexColumn(collation = statement.getText(4), descending = statement.getInt(3))
|
||||
}
|
||||
}
|
||||
}
|
||||
error("Column $column not found in index $index")
|
||||
}
|
||||
|
||||
private data class IndexColumn(
|
||||
val collation: String,
|
||||
val descending: Int,
|
||||
)
|
||||
|
||||
private fun applySchema(
|
||||
connection: SQLiteConnection,
|
||||
version: Int,
|
||||
|
||||
@@ -182,6 +182,59 @@ class SyncServiceTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCaseOnlyNameChangeRewritesTheRow() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1", name = "the beatles")),
|
||||
albums = listOf(album("al1").copy(name = "abbey road", albumArtist = "the beatles")),
|
||||
playlists = listOf(playlist("p1").copy(name = "driving")),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
source.artists = listOf(artist("a1", name = "The Beatles"))
|
||||
source.albums = listOf(album("al1").copy(name = "Abbey Road", albumArtist = "The Beatles"))
|
||||
source.playlists = listOf(playlist("p1").copy(name = "Driving"))
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
assertEquals(
|
||||
"The Beatles",
|
||||
db
|
||||
.libraryDao()
|
||||
.artist(1, "a1")
|
||||
.first()
|
||||
?.name,
|
||||
)
|
||||
assertEquals(
|
||||
"Abbey Road",
|
||||
db
|
||||
.libraryDao()
|
||||
.album(1, "al1")
|
||||
.first()
|
||||
?.name,
|
||||
)
|
||||
assertEquals(
|
||||
"The Beatles",
|
||||
db
|
||||
.libraryDao()
|
||||
.album(1, "al1")
|
||||
.first()
|
||||
?.albumArtist,
|
||||
)
|
||||
assertEquals(
|
||||
"Driving",
|
||||
db
|
||||
.libraryDao()
|
||||
.playlist(1, "p1")
|
||||
.first()
|
||||
?.name,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncStoresStarredAndAddedTimestamps() =
|
||||
runTest {
|
||||
|
||||
Reference in New Issue
Block a user