Skip to main content

koan_core/db/queries/
search.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use rusqlite::{Connection, params};
5
6use crate::db::connection::DbError;
7
8use super::TrackRow;
9
10/// Sanitize a user query for FTS5 MATCH: escapes double-quotes and wraps in
11/// a quoted phrase so FTS5 special characters are treated as literals.
12pub(crate) fn sanitize_fts_query(query: &str) -> String {
13    let trimmed = query.trim();
14    if trimmed.is_empty() {
15        return String::new();
16    }
17    // Escape double quotes for FTS5 literal matching
18    let escaped = trimmed.replace('"', "\"\"");
19    format!("\"{}\"*", escaped)
20}
21
22/// Full-text search across track title, artist, album, genre.
23pub fn search_tracks(conn: &Connection, query: &str) -> Result<Vec<TrackRow>, DbError> {
24    search_tracks_paged(conn, query, 100, 0)
25}
26
27/// Full-text search with configurable limit and offset for pagination.
28pub fn search_tracks_paged(
29    conn: &Connection,
30    query: &str,
31    limit: u32,
32    offset: u32,
33) -> Result<Vec<TrackRow>, DbError> {
34    // FTS5 query — sanitize input and append * for prefix matching.
35    let fts_query = sanitize_fts_query(query);
36
37    let mut stmt = conn.prepare(
38        "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
39                t.disc, t.track_number, t.title, t.duration_ms, t.path,
40                t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
41                t.genre, t.source, t.remote_id, t.cached_path
42         FROM tracks_fts f
43         JOIN tracks t ON t.id = f.rowid
44         LEFT JOIN artists a ON t.artist_id = a.id
45         LEFT JOIN albums al ON t.album_id = al.id
46         LEFT JOIN artists aa ON al.artist_id = aa.id
47         WHERE tracks_fts MATCH ?1
48         ORDER BY a.name COLLATE LIBRARY, al.date, al.title COLLATE LIBRARY, t.disc, t.track_number
49         LIMIT ?2 OFFSET ?3",
50    )?;
51
52    let rows = stmt
53        .query_map(params![fts_query, limit, offset], super::row_to_track_row)?
54        .collect::<Result<Vec<_>, _>>()?;
55
56    Ok(rows)
57}
58
59/// What a fuzzy match runs over.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub enum CorpusKind {
62    Track,
63    Album,
64    Artist,
65}
66
67/// Each row's id, and the text a fuzzy match reads.
68pub type Corpus = Arc<Vec<(i64, String)>>;
69
70/// Every row of `kind` as its id and the text a fuzzy match reads: "artist —
71/// album — title" for a track, "artist — album" for an album, the name for an
72/// album artist.
73///
74/// Unordered, and only the matched columns: the matcher ranks what it finds,
75/// so sorting on the LIBRARY collation — a Rust callback per comparison, the
76/// most expensive part of reading the whole library — bought nothing.
77pub fn fuzzy_corpus(conn: &Connection, kind: CorpusKind) -> Result<Vec<(i64, String)>, DbError> {
78    let mut stmt = conn.prepare_cached(match kind {
79        CorpusKind::Track => {
80            "SELECT t.id, COALESCE(a.name, '') || ' — ' || COALESCE(al.title, '') || ' — ' || t.title
81             FROM tracks t
82             LEFT JOIN artists a ON t.artist_id = a.id
83             LEFT JOIN albums al ON t.album_id = al.id"
84        }
85        CorpusKind::Album => {
86            "SELECT al.id, COALESCE(a.name, '') || ' — ' || al.title
87             FROM albums al
88             LEFT JOIN artists a ON al.artist_id = a.id"
89        }
90        CorpusKind::Artist => {
91            "SELECT a.id, a.name FROM artists a
92             WHERE EXISTS (SELECT 1 FROM albums al WHERE al.artist_id = a.id)"
93        }
94    })?;
95    let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
96    rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
97}
98
99/// A corpus per kind, read again only when the library has moved.
100///
101/// A search field asks on every keystroke, and the library is the same between
102/// them.
103#[derive(Default)]
104pub struct CorpusCache {
105    slots: parking_lot::Mutex<HashMap<CorpusKind, (u64, Corpus)>>,
106}
107
108impl CorpusCache {
109    /// The corpus for `kind` as of `version`, a number the caller changes
110    /// whenever library rows may have.
111    pub fn get(
112        &self,
113        conn: &Connection,
114        kind: CorpusKind,
115        version: u64,
116    ) -> Result<Corpus, DbError> {
117        if let Some((at, corpus)) = self.slots.lock().get(&kind)
118            && *at == version
119        {
120            return Ok(Arc::clone(corpus));
121        }
122        // Read without the lock held: a slow read for one kind is no reason to
123        // hold up a cached answer for another.
124        let corpus: Corpus = Arc::new(fuzzy_corpus(conn, kind)?);
125        self.slots
126            .lock()
127            .insert(kind, (version, Arc::clone(&corpus)));
128        Ok(corpus)
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::db::connection::Database;
136    use crate::db::queries::{sample_meta, upsert_track};
137
138    fn test_db() -> Database {
139        let conn = rusqlite::Connection::open_in_memory().unwrap();
140        conn.pragma_update(None, "foreign_keys", "on").unwrap();
141        crate::db::schema::create_tables(&conn).unwrap();
142        Database { conn }
143    }
144
145    #[test]
146    fn test_search_fts() {
147        let db = test_db();
148        upsert_track(&db.conn, &sample_meta("Vordhosbn", "Aphex Twin", "Drukqs")).unwrap();
149        upsert_track(
150            &db.conn,
151            &sample_meta("Roygbiv", "Boards of Canada", "MHTRTC"),
152        )
153        .unwrap();
154        upsert_track(
155            &db.conn,
156            &sample_meta("Tha", "Aphex Twin", "Selected Ambient Works"),
157        )
158        .unwrap();
159
160        // Search by artist.
161        let results = search_tracks(&db.conn, "Aphex").unwrap();
162        assert_eq!(results.len(), 2);
163
164        // Search by title.
165        let results = search_tracks(&db.conn, "Roygbiv").unwrap();
166        assert_eq!(results.len(), 1);
167        assert_eq!(results[0].title, "Roygbiv");
168
169        // Search by album.
170        let results = search_tracks(&db.conn, "Drukqs").unwrap();
171        assert_eq!(results.len(), 1);
172    }
173}