Skip to main content

koan_core/db/queries/
batch.rs

1//! Batched and SQL-filtered reads for API layers.
2//!
3//! Everything here exists so a caller serving many rows at once issues a bounded
4//! number of statements: one query per batch of parents rather than one per
5//! parent, and `WHERE`/`LIMIT` in SQLite rather than `retain()` in Rust.
6
7use std::collections::{HashMap, HashSet};
8
9use rusqlite::{Connection, ToSql, params_from_iter};
10
11use crate::db::connection::DbError;
12
13use super::tracks::row_to_track_row;
14use super::{AlbumRow, TrackRow};
15
16const TRACK_COLUMNS: &str = "t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
17                t.disc, t.track_number, t.title, t.duration_ms, t.path,
18                t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
19                t.genre, t.source, t.remote_id, t.cached_path";
20
21const TRACK_JOINS: &str = "FROM tracks t
22         LEFT JOIN artists a ON t.artist_id = a.id
23         LEFT JOIN albums al ON t.album_id = al.id
24         LEFT JOIN artists aa ON al.artist_id = aa.id";
25
26/// `(?,?,?)` for `n` bound values.
27fn placeholders(n: usize) -> String {
28    let mut s = String::with_capacity(2 + n * 2);
29    s.push('(');
30    for i in 0..n {
31        if i > 0 {
32            s.push(',');
33        }
34        s.push('?');
35    }
36    s.push(')');
37    s
38}
39
40/// Wrap a user substring for `LIKE ... ESCAPE '\'`, neutralising `%` and `_`.
41fn like_contains(needle: &str) -> String {
42    let mut escaped = String::with_capacity(needle.len() + 2);
43    escaped.push('%');
44    for c in needle.chars() {
45        if matches!(c, '\\' | '%' | '_') {
46            escaped.push('\\');
47        }
48        escaped.push(c);
49    }
50    escaped.push('%');
51    escaped
52}
53
54// ---------------------------------------------------------------------------
55// Batched parent → children loads
56// ---------------------------------------------------------------------------
57
58/// Albums for many artists in one query, keyed by artist ID.
59pub fn albums_for_artists(
60    conn: &Connection,
61    artist_ids: &[i64],
62) -> Result<HashMap<i64, Vec<AlbumRow>>, DbError> {
63    if artist_ids.is_empty() {
64        return Ok(HashMap::new());
65    }
66    let sql = format!(
67        "SELECT al.id, al.title, al.artist_id, a.name, al.date,
68                al.total_discs, al.total_tracks, al.codec, al.label, al.remote_id,
69                al.added_at
70         FROM albums al
71         LEFT JOIN artists a ON al.artist_id = a.id
72         WHERE al.artist_id IN {}
73         ORDER BY al.date, al.title COLLATE LIBRARY",
74        placeholders(artist_ids.len())
75    );
76    let mut stmt = conn.prepare(&sql)?;
77    let rows = stmt.query_map(params_from_iter(artist_ids), |row| {
78        Ok(AlbumRow {
79            id: row.get(0)?,
80            title: row.get(1)?,
81            artist_id: row.get(2)?,
82            artist_name: row.get::<_, Option<String>>(3)?.unwrap_or_default(),
83            date: row.get(4)?,
84            total_discs: row.get(5)?,
85            total_tracks: row.get(6)?,
86            codec: row.get(7)?,
87            label: row.get(8)?,
88            remote_id: row.get(9)?,
89            added_at: row.get(10)?,
90        })
91    })?;
92
93    let mut out: HashMap<i64, Vec<AlbumRow>> = HashMap::new();
94    for album in rows {
95        let album = album?;
96        out.entry(album.artist_id).or_default().push(album);
97    }
98    Ok(out)
99}
100
101/// Tracks for many albums in one query, keyed by album ID.
102pub fn tracks_for_albums(
103    conn: &Connection,
104    album_ids: &[i64],
105) -> Result<HashMap<i64, Vec<TrackRow>>, DbError> {
106    if album_ids.is_empty() {
107        return Ok(HashMap::new());
108    }
109    let sql = format!(
110        "SELECT {} {} WHERE t.album_id IN {} ORDER BY t.disc, t.track_number",
111        TRACK_COLUMNS,
112        TRACK_JOINS,
113        placeholders(album_ids.len())
114    );
115    let mut stmt = conn.prepare(&sql)?;
116    let rows = stmt.query_map(params_from_iter(album_ids), row_to_track_row)?;
117
118    let mut out: HashMap<i64, Vec<TrackRow>> = HashMap::new();
119    for track in rows {
120        let track = track?;
121        if let Some(aid) = track.album_id {
122            out.entry(aid).or_default().push(track);
123        }
124    }
125    Ok(out)
126}
127
128/// Tracks for many artists in one query, keyed by the requested artist ID.
129///
130/// A track matches on either its own artist or its album artist, so one row can
131/// land under two keys — the map is built by re-checking each requested ID.
132///
133/// The album-artist half goes through a subquery on `albums` rather than
134/// `al.artist_id IN (...)` on the join: an `OR` spanning two tables cannot use
135/// an index, and the join form read the whole library for it.
136pub fn tracks_for_artists(
137    conn: &Connection,
138    artist_ids: &[i64],
139) -> Result<HashMap<i64, Vec<TrackRow>>, DbError> {
140    if artist_ids.is_empty() {
141        return Ok(HashMap::new());
142    }
143    let ph = placeholders(artist_ids.len());
144    let sql = format!(
145        "SELECT {}, al.artist_id {}
146          WHERE t.artist_id IN {}
147             OR t.album_id IN (SELECT id FROM albums WHERE artist_id IN {})
148          ORDER BY al.date, al.title, t.disc, t.track_number",
149        TRACK_COLUMNS, TRACK_JOINS, ph, ph
150    );
151    let mut stmt = conn.prepare(&sql)?;
152    let bind: Vec<i64> = artist_ids
153        .iter()
154        .chain(artist_ids.iter())
155        .copied()
156        .collect();
157    let rows = stmt.query_map(params_from_iter(&bind), |row| {
158        Ok((row_to_track_row(row)?, row.get::<_, Option<i64>>(20)?))
159    })?;
160
161    let wanted: HashSet<i64> = artist_ids.iter().copied().collect();
162    let mut out: HashMap<i64, Vec<TrackRow>> = HashMap::new();
163    for row in rows {
164        let (track, album_artist_id) = row?;
165        for key in [track.artist_id, album_artist_id].into_iter().flatten() {
166            if wanted.contains(&key)
167                && !out.entry(key).or_default().iter().any(|t| t.id == track.id)
168            {
169                out.entry(key).or_default().push(track.clone());
170            }
171        }
172    }
173    Ok(out)
174}
175
176// ---------------------------------------------------------------------------
177// Aggregates — counted in SQLite rather than by materialising rows
178// ---------------------------------------------------------------------------
179
180#[derive(Debug, Clone, Copy, Default)]
181pub struct AlbumStats {
182    pub track_count: i64,
183    pub total_duration_ms: i64,
184}
185
186/// Track count and summed duration per album, in one query.
187pub fn album_stats(
188    conn: &Connection,
189    album_ids: &[i64],
190) -> Result<HashMap<i64, AlbumStats>, DbError> {
191    if album_ids.is_empty() {
192        return Ok(HashMap::new());
193    }
194    let sql = format!(
195        "SELECT album_id, COUNT(*), COALESCE(SUM(duration_ms), 0)
196         FROM tracks WHERE album_id IN {} GROUP BY album_id",
197        placeholders(album_ids.len())
198    );
199    let mut stmt = conn.prepare(&sql)?;
200    let rows = stmt.query_map(params_from_iter(album_ids), |row| {
201        Ok((
202            row.get::<_, i64>(0)?,
203            AlbumStats {
204                track_count: row.get(1)?,
205                total_duration_ms: row.get(2)?,
206            },
207        ))
208    })?;
209    rows.collect::<Result<HashMap<_, _>, _>>()
210        .map_err(Into::into)
211}
212
213#[derive(Debug, Clone, Copy, Default)]
214pub struct ArtistStats {
215    pub album_count: i64,
216    pub track_count: i64,
217}
218
219/// Album and track counts per artist, in two grouped queries rather than one
220/// full row fetch per artist per field.
221pub fn artist_stats(
222    conn: &Connection,
223    artist_ids: &[i64],
224) -> Result<HashMap<i64, ArtistStats>, DbError> {
225    if artist_ids.is_empty() {
226        return Ok(HashMap::new());
227    }
228    let ph = placeholders(artist_ids.len());
229    let mut out: HashMap<i64, ArtistStats> = artist_ids
230        .iter()
231        .map(|&id| (id, ArtistStats::default()))
232        .collect();
233
234    let album_sql = format!(
235        "SELECT artist_id, COUNT(*) FROM albums WHERE artist_id IN {} GROUP BY artist_id",
236        ph
237    );
238    let mut stmt = conn.prepare(&album_sql)?;
239    let rows = stmt.query_map(params_from_iter(artist_ids), |row| {
240        Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
241    })?;
242    for row in rows {
243        let (id, count) = row?;
244        out.entry(id).or_default().album_count = count;
245    }
246
247    // Mirrors `tracks_for_artist`: own artist or album artist, counted once.
248    let track_sql = format!(
249        "SELECT k.id, COUNT(DISTINCT t.id)
250         FROM artists k
251         JOIN tracks t ON t.artist_id = k.id OR t.album_id IN (
252             SELECT al.id FROM albums al WHERE al.artist_id = k.id
253         )
254         WHERE k.id IN {}
255         GROUP BY k.id",
256        ph
257    );
258    let mut stmt = conn.prepare(&track_sql)?;
259    let rows = stmt.query_map(params_from_iter(artist_ids), |row| {
260        Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
261    })?;
262    for row in rows {
263        let (id, count) = row?;
264        out.entry(id).or_default().track_count = count;
265    }
266
267    Ok(out)
268}
269
270/// Which of the given paths are favourited — one query instead of a full table
271/// scan per track.
272pub fn favourite_paths(conn: &Connection, paths: &[String]) -> Result<HashSet<String>, DbError> {
273    if paths.is_empty() {
274        return Ok(HashSet::new());
275    }
276    let sql = format!(
277        "SELECT track_path FROM favourites WHERE track_path IN {}",
278        placeholders(paths.len())
279    );
280    let mut stmt = conn.prepare(&sql)?;
281    let rows = stmt.query_map(params_from_iter(paths), |row| row.get::<_, String>(0))?;
282    rows.collect::<Result<HashSet<_>, _>>().map_err(Into::into)
283}
284
285/// The album each of the given tracks belongs to, keyed by track ID.
286///
287/// Tracks with no album are absent from the map rather than present with a
288/// `None`; a caller asking which album to draw has the same answer either way.
289pub fn album_ids_for_tracks(
290    conn: &Connection,
291    track_ids: &[i64],
292) -> Result<HashMap<i64, i64>, DbError> {
293    if track_ids.is_empty() {
294        return Ok(HashMap::new());
295    }
296    let sql = format!(
297        "SELECT id, album_id FROM tracks WHERE id IN {} AND album_id IS NOT NULL",
298        placeholders(track_ids.len())
299    );
300    let mut stmt = conn.prepare(&sql)?;
301    let rows = stmt.query_map(params_from_iter(track_ids), |row| {
302        Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
303    })?;
304    rows.collect::<Result<HashMap<_, _>, _>>()
305        .map_err(Into::into)
306}
307
308/// Where each track's bytes are: on the server, on this machine, or both.
309///
310/// One query for a whole queue. The same reading `Track` gives, so a row in the
311/// queue and the same row in an album agree about what they are — asked here
312/// per queue rather than per row, which is what a list of a thousand would
313/// otherwise cost.
314pub fn sources_for_tracks(
315    conn: &Connection,
316    track_ids: &[i64],
317) -> Result<HashMap<i64, (bool, bool)>, DbError> {
318    if track_ids.is_empty() {
319        return Ok(HashMap::new());
320    }
321    let sql = format!(
322        "SELECT id, remote_id IS NOT NULL, COALESCE(cached_path, path) IS NOT NULL \
323         FROM tracks WHERE id IN {}",
324        placeholders(track_ids.len())
325    );
326    let mut stmt = conn.prepare(&sql)?;
327    let rows = stmt.query_map(params_from_iter(track_ids), |row| {
328        Ok((
329            row.get::<_, i64>(0)?,
330            (row.get::<_, bool>(1)?, row.get::<_, bool>(2)?),
331        ))
332    })?;
333    rows.collect::<Result<HashMap<_, _>, _>>()
334        .map_err(Into::into)
335}
336
337// ---------------------------------------------------------------------------
338// SQL-side track filtering
339// ---------------------------------------------------------------------------
340
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum TrackOrder {
343    ArtistAlbumDiscTrack,
344    Title,
345    Artist,
346    Album,
347    Duration,
348}
349
350#[derive(Debug, Clone, Default)]
351pub struct TrackFilter {
352    pub ids: Option<Vec<i64>>,
353    /// FTS5 query. Applied as a subquery so it composes with the other filters.
354    pub search: Option<String>,
355    pub album_id: Option<i64>,
356    pub artist_ids: Option<Vec<i64>>,
357    pub title: Option<String>,
358    pub artist_name: Option<String>,
359    pub album_title: Option<String>,
360    pub genre: Option<String>,
361    pub codec: Option<String>,
362    pub source: Option<String>,
363    pub year_start: Option<i32>,
364    pub year_end: Option<i32>,
365    pub min_sample_rate: Option<i32>,
366    pub min_bit_depth: Option<i32>,
367    pub channels: Option<i32>,
368    pub min_duration_ms: Option<i64>,
369    pub max_duration_ms: Option<i64>,
370    pub favourites_only: bool,
371}
372
373/// Fetch a page of tracks matching `filter`.
374///
375/// Both the filtering and the windowing happen in SQLite: the caller never sees
376/// more than `limit` rows regardless of library size.
377pub fn filter_tracks(
378    conn: &Connection,
379    filter: &TrackFilter,
380    order: TrackOrder,
381    descending: bool,
382    limit: u32,
383    offset: u32,
384) -> Result<Vec<TrackRow>, DbError> {
385    let mut clauses: Vec<String> = Vec::new();
386    let mut binds: Vec<Box<dyn ToSql>> = Vec::new();
387
388    if let Some(ids) = &filter.ids {
389        if ids.is_empty() {
390            return Ok(Vec::new());
391        }
392        clauses.push(format!("t.id IN {}", placeholders(ids.len())));
393        binds.extend(ids.iter().map(|&i| Box::new(i) as Box<dyn ToSql>));
394    }
395
396    if let Some(query) = &filter.search {
397        clauses.push("t.id IN (SELECT rowid FROM tracks_fts WHERE tracks_fts MATCH ?)".to_string());
398        binds.push(Box::new(super::search::sanitize_fts_query(query)));
399    }
400
401    if let Some(album_id) = filter.album_id {
402        clauses.push("t.album_id = ?".to_string());
403        binds.push(Box::new(album_id));
404    }
405
406    if let Some(ids) = &filter.artist_ids {
407        if ids.is_empty() {
408            return Ok(Vec::new());
409        }
410        let ph = placeholders(ids.len());
411        clauses.push(format!("(t.artist_id IN {ph} OR al.artist_id IN {ph})"));
412        for _ in 0..2 {
413            binds.extend(ids.iter().map(|&i| Box::new(i) as Box<dyn ToSql>));
414        }
415    }
416
417    if let Some(title) = &filter.title {
418        clauses.push("t.title LIKE ? ESCAPE '\\'".to_string());
419        binds.push(Box::new(like_contains(title)));
420    }
421
422    if let Some(name) = &filter.artist_name {
423        clauses.push("(a.name LIKE ? ESCAPE '\\' OR aa.name LIKE ? ESCAPE '\\')".to_string());
424        binds.push(Box::new(like_contains(name)));
425        binds.push(Box::new(like_contains(name)));
426    }
427
428    if let Some(title) = &filter.album_title {
429        clauses.push("al.title LIKE ? ESCAPE '\\'".to_string());
430        binds.push(Box::new(like_contains(title)));
431    }
432
433    if let Some(genre) = &filter.genre {
434        clauses.push("t.genre LIKE ? ESCAPE '\\'".to_string());
435        binds.push(Box::new(like_contains(genre)));
436    }
437
438    if let Some(codec) = &filter.codec {
439        clauses.push("t.codec LIKE ? ESCAPE '\\'".to_string());
440        binds.push(Box::new(like_contains(codec)));
441    }
442
443    if let Some(source) = &filter.source {
444        clauses.push("t.source = ?".to_string());
445        binds.push(Box::new(source.clone()));
446    }
447
448    if filter.year_start.is_some() || filter.year_end.is_some() {
449        // A track with no parsable four-digit year is excluded, matching the
450        // behaviour of the year extraction it replaces.
451        clauses.push("substr(al.date, 1, 4) GLOB '[0-9][0-9][0-9][0-9]'".to_string());
452        if let Some(start) = filter.year_start {
453            clauses.push("CAST(substr(al.date, 1, 4) AS INTEGER) >= ?".to_string());
454            binds.push(Box::new(start));
455        }
456        if let Some(end) = filter.year_end {
457            clauses.push("CAST(substr(al.date, 1, 4) AS INTEGER) <= ?".to_string());
458            binds.push(Box::new(end));
459        }
460    }
461
462    for (column, value) in [
463        ("t.sample_rate >= ?", filter.min_sample_rate),
464        ("t.bit_depth >= ?", filter.min_bit_depth),
465        ("t.channels = ?", filter.channels),
466    ] {
467        if let Some(v) = value {
468            clauses.push(column.to_string());
469            binds.push(Box::new(v));
470        }
471    }
472
473    for (column, value) in [
474        ("t.duration_ms >= ?", filter.min_duration_ms),
475        ("t.duration_ms <= ?", filter.max_duration_ms),
476    ] {
477        if let Some(v) = value {
478            clauses.push(column.to_string());
479            binds.push(Box::new(v));
480        }
481    }
482
483    if filter.favourites_only {
484        clauses.push(
485            "EXISTS (SELECT 1 FROM favourites f
486                     WHERE f.track_path = t.path OR f.track_path = t.cached_path)"
487                .to_string(),
488        );
489    }
490
491    let dir = if descending { "DESC" } else { "ASC" };
492    let order_by = match order {
493        TrackOrder::ArtistAlbumDiscTrack => format!(
494            "a.name {dir}, al.date {dir}, al.title {dir}, t.disc {dir}, t.track_number {dir}"
495        ),
496        TrackOrder::Title => format!("t.title {dir}"),
497        TrackOrder::Artist => {
498            format!("a.name {dir}, al.date {dir}, t.disc {dir}, t.track_number {dir}")
499        }
500        TrackOrder::Album => format!("al.title {dir}, t.disc {dir}, t.track_number {dir}"),
501        TrackOrder::Duration => format!("t.duration_ms {dir}"),
502    };
503
504    let where_clause = if clauses.is_empty() {
505        String::new()
506    } else {
507        format!("WHERE {}", clauses.join(" AND "))
508    };
509
510    let sql = format!(
511        "SELECT {TRACK_COLUMNS} {TRACK_JOINS} {where_clause}
512         ORDER BY {order_by}, t.id {dir} LIMIT ? OFFSET ?"
513    );
514
515    binds.push(Box::new(limit));
516    binds.push(Box::new(offset));
517
518    let mut stmt = conn.prepare(&sql)?;
519    let rows = stmt
520        .query_map(params_from_iter(binds.iter()), row_to_track_row)?
521        .collect::<Result<Vec<_>, _>>()?;
522    Ok(rows)
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use crate::db::connection::Database;
529    use crate::db::queries::{add_favourite, sample_meta, upsert_track};
530
531    fn test_db() -> Database {
532        let conn = Connection::open_in_memory().unwrap();
533        conn.pragma_update(None, "foreign_keys", "on").unwrap();
534        crate::db::schema::create_tables(&conn).unwrap();
535        Database { conn }
536    }
537
538    fn seed(db: &Database) {
539        for (title, artist, album) in [
540            ("Vordhosbn", "Aphex Twin", "Drukqs"),
541            ("Avril 14th", "Aphex Twin", "Drukqs"),
542            ("Roygbiv", "Boards of Canada", "MHTRTC"),
543        ] {
544            upsert_track(&db.conn, &sample_meta(title, artist, album)).unwrap();
545        }
546    }
547
548    #[test]
549    fn filter_pushes_limit_into_sql() {
550        let db = test_db();
551        seed(&db);
552        let page = filter_tracks(
553            &db.conn,
554            &TrackFilter::default(),
555            TrackOrder::Title,
556            false,
557            2,
558            0,
559        )
560        .unwrap();
561        assert_eq!(page.len(), 2);
562        let page2 = filter_tracks(
563            &db.conn,
564            &TrackFilter::default(),
565            TrackOrder::Title,
566            false,
567            2,
568            2,
569        )
570        .unwrap();
571        assert_eq!(page2.len(), 1);
572    }
573
574    #[test]
575    fn filter_matches_substrings_and_escapes_wildcards() {
576        let db = test_db();
577        seed(&db);
578        let filter = TrackFilter {
579            title: Some("vril".into()),
580            ..Default::default()
581        };
582        let hits = filter_tracks(&db.conn, &filter, TrackOrder::Title, false, 50, 0).unwrap();
583        assert_eq!(hits.len(), 1);
584        assert_eq!(hits[0].title, "Avril 14th");
585
586        // A bare `%` must not match everything.
587        let filter = TrackFilter {
588            title: Some("%".into()),
589            ..Default::default()
590        };
591        assert!(
592            filter_tracks(&db.conn, &filter, TrackOrder::Title, false, 50, 0)
593                .unwrap()
594                .is_empty()
595        );
596    }
597
598    #[test]
599    fn filter_composes_search_with_other_predicates() {
600        let db = test_db();
601        seed(&db);
602        let filter = TrackFilter {
603            search: Some("Aphex".into()),
604            title: Some("Vordhosbn".into()),
605            ..Default::default()
606        };
607        let hits = filter_tracks(&db.conn, &filter, TrackOrder::Title, false, 50, 0).unwrap();
608        assert_eq!(hits.len(), 1);
609    }
610
611    #[test]
612    fn stats_are_aggregated_not_materialised() {
613        let db = test_db();
614        seed(&db);
615        let album_id = db
616            .conn
617            .query_row("SELECT id FROM albums WHERE title = 'Drukqs'", [], |r| {
618                r.get::<_, i64>(0)
619            })
620            .unwrap();
621        let stats = album_stats(&db.conn, &[album_id]).unwrap();
622        assert_eq!(stats[&album_id].track_count, 2);
623        assert_eq!(stats[&album_id].total_duration_ms, 480_000);
624
625        let artist_id = db
626            .conn
627            .query_row(
628                "SELECT id FROM artists WHERE name = 'Aphex Twin'",
629                [],
630                |r| r.get::<_, i64>(0),
631            )
632            .unwrap();
633        let stats = artist_stats(&db.conn, &[artist_id]).unwrap();
634        assert_eq!(stats[&artist_id].album_count, 1);
635        assert_eq!(stats[&artist_id].track_count, 2);
636    }
637
638    #[test]
639    fn favourite_paths_only_returns_the_requested_paths() {
640        let db = test_db();
641        seed(&db);
642        add_favourite(
643            &db.conn,
644            std::path::Path::new("/music/Drukqs/Vordhosbn.flac"),
645        )
646        .unwrap();
647        let hits = favourite_paths(
648            &db.conn,
649            &[
650                "/music/Drukqs/Vordhosbn.flac".to_string(),
651                "/music/MHTRTC/Roygbiv.flac".to_string(),
652            ],
653        )
654        .unwrap();
655        assert_eq!(hits.len(), 1);
656        assert!(hits.contains("/music/Drukqs/Vordhosbn.flac"));
657    }
658
659    #[test]
660    fn batched_children_are_keyed_by_parent() {
661        let db = test_db();
662        seed(&db);
663        let album_ids: Vec<i64> = db
664            .conn
665            .prepare("SELECT id FROM albums ORDER BY id")
666            .unwrap()
667            .query_map([], |r| r.get(0))
668            .unwrap()
669            .collect::<Result<_, _>>()
670            .unwrap();
671        let map = tracks_for_albums(&db.conn, &album_ids).unwrap();
672        assert_eq!(map.values().map(Vec::len).sum::<usize>(), 3);
673
674        let artist_ids: Vec<i64> = db
675            .conn
676            .prepare("SELECT id FROM artists ORDER BY id")
677            .unwrap()
678            .query_map([], |r| r.get(0))
679            .unwrap()
680            .collect::<Result<_, _>>()
681            .unwrap();
682        let map = albums_for_artists(&db.conn, &artist_ids).unwrap();
683        assert_eq!(map.values().map(Vec::len).sum::<usize>(), 2);
684        let map = tracks_for_artists(&db.conn, &artist_ids).unwrap();
685        assert_eq!(map.values().map(Vec::len).sum::<usize>(), 3);
686    }
687}