//! Provide search funcitonality for calibre. //! //! Because the calibre database can not be disturbed (it is treated as read-only) //! it attaches a temporary database and inserts the relevant data into a //! virtual table leveraging fts5 (https://www.sqlite.org/fts5.html). Full-text search is run on //! that virtual table. use std::path::Path; use r2d2::{Pool, PooledConnection}; use r2d2_sqlite::SqliteConnectionManager; use rusqlite::named_params; use snafu::{ResultExt, Snafu}; use crate::data::book::Book; /// A lot of joins but only run once at startup. const SEARCH_INIT_QUERY: &str = "INSERT INTO search.fts(book_id, data) SELECT b.id as book_id, COALESCE(b.title, '') || ' ' || COALESCE(a.name, '') || ' ' || COALESCE(c.text, '') || ' ' || COALESCE(GROUP_CONCAT(DISTINCT t.name), '') || ' ' || COALESCE(GROUP_CONCAT(DISTINCT i.val), '') || ' ' || COALESCE(GROUP_CONCAT(DISTINCT s.name), '') as data FROM main.books as b LEFT JOIN main.books_authors_link AS b2a ON b.id = b2a.book LEFT JOIN main.authors AS a ON b2a.author = a.id LEFT JOIN main.comments AS c ON c.book = b.id LEFT JOIN main.books_tags_link AS b2t ON b.id = b2t.book LEFT JOIN main.tags AS t ON b2t.tag = t.id LEFT JOIN main.identifiers AS i ON i.book = b.id LEFT JOIN main.books_series_link AS b2s ON b.id = b2s.book LEFT JOIN main.series AS s ON b2s.series = s.id GROUP BY b.id"; #[derive(Debug, Snafu)] pub enum EnsureSearchDbError { #[snafu(display("Failed to prepare statement."))] PrepareEnsureSearch { source: rusqlite::Error }, #[snafu(display("Failed to execute statement."))] ExecuteEnsureSearch { source: rusqlite::Error }, #[snafu(display("Failed to attach database."))] Attach { source: AttachError }, #[snafu(display("Failed to initialize database."))] Init { source: InitError }, } #[derive(Debug, Snafu)] pub enum AttachError { #[snafu(display("Failed to execute statement."))] ExecuteAttach { source: rusqlite::Error }, } #[derive(Debug, Snafu)] pub enum InitError { #[snafu(display("Failed to prepare statement."))] PrepareInit { source: rusqlite::Error }, #[snafu(display("Failed to execute statement."))] ExecuteInit { source: rusqlite::Error }, } #[derive(Debug, Snafu)] pub enum SearchError { #[snafu(display("Failed ensure the search db is initialized."))] EnsureDb { source: EnsureSearchDbError }, #[snafu(display("Failed to get connection from pool."))] Connection { source: r2d2::Error }, #[snafu(display("Failed to prepare statement."))] PrepareSearch { source: rusqlite::Error }, #[snafu(display("Failed to execute statement."))] ExecuteSearch { source: rusqlite::Error }, } /// Ensure the search database is attached to the connection and /// initializes the data if needed. fn ensure_search_db( conn: &PooledConnection, db_path: &Path, ) -> Result<(), EnsureSearchDbError> { let mut stmt = conn .prepare("SELECT COUNT() FROM pragma_database_list WHERE name = 'search'") .context(PrepareEnsureSearchSnafu)?; let count: u64 = stmt .query_row([], |x| x.get(0)) .context(ExecuteEnsureSearchSnafu)?; let need_attachment = count == 0; if need_attachment { attach(conn, db_path).context(AttachSnafu)?; init(conn).context(InitSnafu)?; } Ok(()) } /// Attach the fts temporary database to the read-only calibre database. fn attach( conn: &PooledConnection, db_path: &Path, ) -> Result<(), AttachError> { conn.execute( &format!("ATTACH DATABASE '{}' AS search", db_path.to_string_lossy()), [], ) .context(ExecuteAttachSnafu)?; Ok(()) } /// Initialise the fts virtual table. fn init(conn: &PooledConnection) -> Result<(), InitError> { let mut stmt = conn .prepare("SELECT COUNT() FROM search.sqlite_master WHERE type='table' AND name = 'fts'") .context(PrepareInitSnafu)?; let count: u64 = stmt.query_row([], |x| x.get(0)).context(ExecuteInitSnafu)?; let need_init = count == 0; if need_init { conn.execute( "CREATE VIRTUAL TABLE search.fts USING fts5(book_id, data)", [], ) .context(ExecuteInitSnafu)?; conn.execute(SEARCH_INIT_QUERY, []) .context(ExecuteInitSnafu)?; } Ok(()) } /// Run a full-text search with the parameter `query`. pub(crate) fn search( query: &str, pool: &Pool, search_db_path: &Path, ) -> Result, SearchError> { let conn = pool.get().context(ConnectionSnafu)?; ensure_search_db(&conn, search_db_path).context(EnsureDbSnafu)?; let mut stmt = conn .prepare("SELECT book_id FROM search.fts WHERE data MATCH (:query) ORDER BY rank") .context(PrepareSearchSnafu)?; let params = named_params! { ":query": query }; let books = stmt .query_map(params, |r| -> Result { r.get(0) }) .context(ExecuteSearchSnafu)? .filter_map(Result::ok) .filter_map(|id| Book::scalar_book(&conn, id).ok()) .collect(); Ok(books) }