anki/rslib/src/search/mod.rs
Damien Elmes 553303fc12
Refactor service generation (#2552)
* Automatically elide empty inputs and outputs to backend methods

* Refactor service generation

Despite the fact that the majority of our Protobuf service methods require
an open collection, they were not accessible with just a Collection
object. To access the methods (e.g. because we haven't gotten around to
exposing the correct API in Collection yet), you had to wrap the collection
in a Backend object, and pay a mutex-acquisition cost for each call, even
if you have exclusive access to the object.

This commit migrates the majority of service methods to the Collection, so
they can now be used directly, and improves the ergonomics a bit at the
same time.

The approach taken:

- The service generation now happens in rslib instead of anki_proto, which
avoids the need for trait constraints and associated types.
- Service methods are assumed to be collection-based by default. Instead of
implementing the service on Backend, we now implement it on Collection, which
means our methods no longer need to use self.with_col(...).
- We automatically generate methods in Backend which use self.with_col() to
delegate to the Collection method.
- For methods that are only appropriate for the backend, we add a flag in
the .proto file. The codegen uses this flag to write the method into a
BackendFooService instead of FooService, which the backend implements.
- The flag can also allows us to define separate implementations for collection
and backend, so we can e.g. skip the collection mutex in the i18n service
while also providing the service on a collection.
2023-06-19 15:33:40 +10:00

423 lines
13 KiB
Rust

// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
mod builder;
mod parser;
mod service;
mod sqlwriter;
pub(crate) mod writer;
use std::borrow::Cow;
pub use builder::JoinSearches;
pub use builder::Negated;
pub use builder::SearchBuilder;
pub use parser::parse as parse_search;
pub use parser::Node;
pub use parser::PropertyKind;
pub use parser::RatingKind;
pub use parser::SearchNode;
pub use parser::StateKind;
pub use parser::TemplateKind;
use rusqlite::params_from_iter;
use rusqlite::types::FromSql;
use sqlwriter::RequiredTable;
use sqlwriter::SqlWriter;
pub use writer::replace_search_node;
use crate::browser_table::Column;
use crate::card::CardType;
use crate::prelude::*;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum ReturnItemType {
Cards,
Notes,
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum SortMode {
NoOrder,
Builtin { column: Column, reverse: bool },
Custom(String),
}
pub trait AsReturnItemType {
fn as_return_item_type() -> ReturnItemType;
}
impl AsReturnItemType for CardId {
fn as_return_item_type() -> ReturnItemType {
ReturnItemType::Cards
}
}
impl AsReturnItemType for NoteId {
fn as_return_item_type() -> ReturnItemType {
ReturnItemType::Notes
}
}
impl ReturnItemType {
fn required_table(&self) -> RequiredTable {
match self {
ReturnItemType::Cards => RequiredTable::Cards,
ReturnItemType::Notes => RequiredTable::Notes,
}
}
}
impl SortMode {
fn required_table(&self) -> RequiredTable {
match self {
SortMode::NoOrder => RequiredTable::CardsOrNotes,
SortMode::Builtin { column, .. } => column.required_table(),
SortMode::Custom(ref text) => {
if text.contains("n.") {
if text.contains("c.") {
RequiredTable::CardsAndNotes
} else {
RequiredTable::Notes
}
} else {
RequiredTable::Cards
}
}
}
}
}
impl Column {
fn required_table(self) -> RequiredTable {
match self {
Column::Cards
| Column::NoteCreation
| Column::NoteMod
| Column::Notetype
| Column::SortField
| Column::Tags => RequiredTable::Notes,
_ => RequiredTable::CardsOrNotes,
}
}
}
pub trait TryIntoSearch {
fn try_into_search(self) -> Result<Node, AnkiError>;
}
impl TryIntoSearch for &str {
fn try_into_search(self) -> Result<Node, AnkiError> {
parser::parse(self).map(Node::Group)
}
}
impl TryIntoSearch for &String {
fn try_into_search(self) -> Result<Node, AnkiError> {
parser::parse(self).map(Node::Group)
}
}
impl<T> TryIntoSearch for T
where
T: Into<Node>,
{
fn try_into_search(self) -> Result<Node, AnkiError> {
Ok(self.into())
}
}
pub struct CardTableGuard<'a> {
pub col: &'a mut Collection,
pub cards: usize,
}
impl Drop for CardTableGuard<'_> {
fn drop(&mut self) {
if let Err(err) = self.col.storage.clear_searched_cards_table() {
println!("{err:?}");
}
}
}
pub struct NoteTableGuard<'a> {
pub col: &'a mut Collection,
pub notes: usize,
}
impl Drop for NoteTableGuard<'_> {
fn drop(&mut self) {
if let Err(err) = self.col.storage.clear_searched_notes_table() {
println!("{err:?}");
}
}
}
impl Collection {
pub fn search_cards<N>(&mut self, search: N, mode: SortMode) -> Result<Vec<CardId>>
where
N: TryIntoSearch,
{
self.search(search, mode)
}
pub fn search_notes<N>(&mut self, search: N, mode: SortMode) -> Result<Vec<NoteId>>
where
N: TryIntoSearch,
{
self.search(search, mode)
}
pub fn search_notes_unordered<N>(&mut self, search: N) -> Result<Vec<NoteId>>
where
N: TryIntoSearch,
{
self.search(search, SortMode::NoOrder)
}
}
impl Collection {
fn search<T, N>(&mut self, search: N, mode: SortMode) -> Result<Vec<T>>
where
N: TryIntoSearch,
T: FromSql + AsReturnItemType,
{
let item_type = T::as_return_item_type();
let top_node = search.try_into_search()?;
let writer = SqlWriter::new(self, item_type);
let (mut sql, args) = writer.build_query(&top_node, mode.required_table())?;
self.add_order(&mut sql, item_type, mode)?;
let mut stmt = self.storage.db.prepare(&sql)?;
let ids: Vec<_> = stmt
.query_map(params_from_iter(args.iter()), |row| row.get(0))?
.collect::<std::result::Result<_, _>>()?;
Ok(ids)
}
fn add_order(
&mut self,
sql: &mut String,
item_type: ReturnItemType,
mode: SortMode,
) -> Result<()> {
match mode {
SortMode::NoOrder => (),
SortMode::Builtin { column, reverse } => {
prepare_sort(self, column, item_type)?;
sql.push_str(" order by ");
write_order(sql, item_type, column, reverse)?;
}
SortMode::Custom(order_clause) => {
sql.push_str(" order by ");
sql.push_str(&order_clause);
}
}
Ok(())
}
/// Place the matched card ids into a temporary 'search_cids' table
/// instead of returning them. Returns a guard with a collection reference
/// and the number of added cards. When the guard is dropped, the temporary
/// table is cleaned up.
pub(crate) fn search_cards_into_table(
&mut self,
search: impl TryIntoSearch,
mode: SortMode,
) -> Result<CardTableGuard> {
let top_node = search.try_into_search()?;
let writer = SqlWriter::new(self, ReturnItemType::Cards);
let want_order = mode != SortMode::NoOrder;
let (mut sql, args) = writer.build_query(&top_node, mode.required_table())?;
self.add_order(&mut sql, ReturnItemType::Cards, mode)?;
if want_order {
self.storage
.setup_searched_cards_table_to_preserve_order()?;
} else {
self.storage.setup_searched_cards_table()?;
}
let sql = format!("insert into search_cids {}", sql);
let cards = self
.storage
.db
.prepare(&sql)?
.execute(params_from_iter(args))?;
Ok(CardTableGuard { cards, col: self })
}
pub(crate) fn all_cards_for_search(&mut self, search: impl TryIntoSearch) -> Result<Vec<Card>> {
let guard = self.search_cards_into_table(search, SortMode::NoOrder)?;
guard.col.storage.all_searched_cards()
}
pub(crate) fn all_cards_for_search_in_order(
&mut self,
search: impl TryIntoSearch,
mode: SortMode,
) -> Result<Vec<Card>> {
let guard = self.search_cards_into_table(search, mode)?;
guard.col.storage.all_searched_cards_in_search_order()
}
pub(crate) fn all_cards_for_ids(
&self,
cards: &[CardId],
preserve_order: bool,
) -> Result<Vec<Card>> {
self.storage.with_searched_cards_table(preserve_order, || {
self.storage.set_search_table_to_card_ids(cards)?;
if preserve_order {
self.storage.all_searched_cards_in_search_order()
} else {
self.storage.all_searched_cards()
}
})
}
pub(crate) fn for_each_card_in_search(
&mut self,
search: impl TryIntoSearch,
mut func: impl FnMut(&Collection, Card) -> Result<()>,
) -> Result<()> {
let guard = self.search_cards_into_table(search, SortMode::NoOrder)?;
guard
.col
.storage
.for_each_card_in_search(|card| func(guard.col, card))
}
/// Place the matched card ids into a temporary 'search_nids' table
/// instead of returning them. Returns a guard with a collection reference
/// and the number of added notes. When the guard is dropped, the temporary
/// table is cleaned up.
pub(crate) fn search_notes_into_table(
&mut self,
search: impl TryIntoSearch,
) -> Result<NoteTableGuard> {
let top_node = search.try_into_search()?;
let writer = SqlWriter::new(self, ReturnItemType::Notes);
let mode = SortMode::NoOrder;
let (sql, args) = writer.build_query(&top_node, mode.required_table())?;
self.storage.setup_searched_notes_table()?;
let sql = format!("insert into search_nids {}", sql);
let notes = self
.storage
.db
.prepare(&sql)?
.execute(params_from_iter(args))?;
Ok(NoteTableGuard { notes, col: self })
}
/// Place the ids of cards with notes in 'search_nids' into 'search_cids'.
/// Returns number of added cards.
pub(crate) fn search_cards_of_notes_into_table(&mut self) -> Result<CardTableGuard> {
self.storage.setup_searched_cards_table()?;
let cards = self.storage.search_cards_of_notes_into_table()?;
Ok(CardTableGuard { cards, col: self })
}
}
/// Add the order clause to the sql.
fn write_order(
sql: &mut String,
item_type: ReturnItemType,
column: Column,
reverse: bool,
) -> Result<()> {
let order = match item_type {
ReturnItemType::Cards => card_order_from_sort_column(column),
ReturnItemType::Notes => note_order_from_sort_column(column),
};
require!(!order.is_empty(), "Can't sort {item_type:?} by {column:?}.");
if reverse {
sql.push_str(
&order
.to_ascii_lowercase()
.replace(" desc", "")
.replace(" asc", " desc"),
)
} else {
sql.push_str(&order);
}
Ok(())
}
fn card_order_from_sort_column(column: Column) -> Cow<'static, str> {
match column {
Column::CardMod => "c.mod asc".into(),
Column::Cards => concat!(
"coalesce((select pos from sort_order where ntid = n.mid and ord = c.ord),",
// need to fall back on ord 0 for cloze cards
"(select pos from sort_order where ntid = n.mid and ord = 0)) asc"
)
.into(),
Column::Deck => "(select pos from sort_order where did = c.did) asc".into(),
Column::Due => "c.type asc, c.due asc".into(),
Column::Ease => format!("c.type = {} asc, c.factor asc", CardType::New as i8).into(),
Column::Interval => "c.ivl asc".into(),
Column::Lapses => "c.lapses asc".into(),
Column::NoteCreation => "n.id asc, c.ord asc".into(),
Column::NoteMod => "n.mod asc, c.ord asc".into(),
Column::Notetype => "(select pos from sort_order where ntid = n.mid) asc".into(),
Column::Reps => "c.reps asc".into(),
Column::SortField => "n.sfld collate nocase asc, c.ord asc".into(),
Column::Tags => "n.tags asc".into(),
Column::Answer | Column::Custom | Column::Question => "".into(),
}
}
fn note_order_from_sort_column(column: Column) -> Cow<'static, str> {
match column {
Column::CardMod
| Column::Cards
| Column::Deck
| Column::Due
| Column::Ease
| Column::Interval
| Column::Lapses
| Column::Reps => "(select pos from sort_order where nid = n.id) asc".into(),
Column::NoteCreation => "n.id asc".into(),
Column::NoteMod => "n.mod asc".into(),
Column::Notetype => "(select pos from sort_order where ntid = n.mid) asc".into(),
Column::SortField => "n.sfld collate nocase asc".into(),
Column::Tags => "n.tags asc".into(),
Column::Answer | Column::Custom | Column::Question => "".into(),
}
}
fn prepare_sort(col: &mut Collection, column: Column, item_type: ReturnItemType) -> Result<()> {
let sql = match item_type {
ReturnItemType::Cards => match column {
Column::Cards => include_str!("template_order.sql"),
Column::Deck => include_str!("deck_order.sql"),
Column::Notetype => include_str!("notetype_order.sql"),
_ => return Ok(()),
},
ReturnItemType::Notes => match column {
Column::Cards => include_str!("note_cards_order.sql"),
Column::CardMod => include_str!("card_mod_order.sql"),
Column::Deck => include_str!("note_decks_order.sql"),
Column::Due => include_str!("note_due_order.sql"),
Column::Ease => include_str!("note_ease_order.sql"),
Column::Interval => include_str!("note_interval_order.sql"),
Column::Lapses => include_str!("note_lapses_order.sql"),
Column::Reps => include_str!("note_reps_order.sql"),
Column::Notetype => include_str!("notetype_order.sql"),
_ => return Ok(()),
},
};
col.storage.db.execute_batch(sql)?;
Ok(())
}