Anki/rslib/src/search/sqlwriter.rs
Damien Elmes a90d5aa359 use mixed case for abbreviations in Rust code
So, this is fun. Apparently "DeckId" is considered preferable to the
"DeckID" were were using until now, and the latest clippy will start
warning about it. We could of course disable the warning, but probably
better to bite the bullet and switch to the naming that's generally
considered best.
2021-03-27 19:53:33 +10:00

846 lines
29 KiB
Rust

// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::parser::{Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind};
use crate::{
card::{CardQueue, CardType},
collection::Collection,
decks::human_deck_name_to_native,
err::Result,
notes::field_checksum,
notetype::NoteTypeId,
prelude::*,
storage::ids_to_string,
text::{
is_glob, matches_glob, normalize_to_nfc, strip_html_preserving_media_filenames,
to_custom_re, to_re, to_sql, to_text, without_combining,
},
timestamp::TimestampSecs,
};
use std::{borrow::Cow, fmt::Write};
pub(crate) struct SqlWriter<'a> {
col: &'a mut Collection,
sql: String,
args: Vec<String>,
normalize_note_text: bool,
table: RequiredTable,
}
impl SqlWriter<'_> {
pub(crate) fn new(col: &mut Collection) -> SqlWriter<'_> {
let normalize_note_text = col.get_bool(BoolKey::NormalizeNoteText);
let sql = String::new();
let args = vec![];
SqlWriter {
col,
sql,
args,
normalize_note_text,
table: RequiredTable::CardsOrNotes,
}
}
pub(super) fn build_cards_query(
mut self,
node: &Node,
table: RequiredTable,
) -> Result<(String, Vec<String>)> {
self.table = table.combine(node.required_table());
self.write_cards_table_sql();
self.write_node_to_sql(&node)?;
Ok((self.sql, self.args))
}
pub(super) fn build_notes_query(mut self, node: &Node) -> Result<(String, Vec<String>)> {
self.table = RequiredTable::Notes.combine(node.required_table());
self.write_notes_table_sql();
self.write_node_to_sql(&node)?;
Ok((self.sql, self.args))
}
fn write_cards_table_sql(&mut self) {
let sql = match self.table {
RequiredTable::Cards => "select c.id from cards c where ",
_ => "select c.id from cards c, notes n where c.nid=n.id and ",
};
self.sql.push_str(sql);
}
fn write_notes_table_sql(&mut self) {
let sql = match self.table {
RequiredTable::Notes => "select n.id from notes n where ",
_ => "select distinct n.id from cards c, notes n where c.nid=n.id and ",
};
self.sql.push_str(sql);
}
/// As an optimization we can omit the cards or notes tables from
/// certain queries. For code that specifies a note id, we need to
/// choose the appropriate column name.
fn note_id_column(&self) -> &'static str {
match self.table {
RequiredTable::Notes | RequiredTable::CardsAndNotes => "n.id",
RequiredTable::Cards => "c.nid",
RequiredTable::CardsOrNotes => unreachable!(),
}
}
fn write_node_to_sql(&mut self, node: &Node) -> Result<()> {
match node {
Node::And => write!(self.sql, " and ").unwrap(),
Node::Or => write!(self.sql, " or ").unwrap(),
Node::Not(node) => {
write!(self.sql, "not ").unwrap();
self.write_node_to_sql(node)?;
}
Node::Group(nodes) => {
write!(self.sql, "(").unwrap();
for node in nodes {
self.write_node_to_sql(node)?;
}
write!(self.sql, ")").unwrap();
}
Node::Search(search) => self.write_search_node_to_sql(search)?,
};
Ok(())
}
/// Convert search text to NFC if note normalization is enabled.
fn norm_note<'a>(&self, text: &'a str) -> Cow<'a, str> {
if self.normalize_note_text {
normalize_to_nfc(text)
} else {
text.into()
}
}
fn write_search_node_to_sql(&mut self, node: &SearchNode) -> Result<()> {
use normalize_to_nfc as norm;
match node {
// note fields related
SearchNode::UnqualifiedText(text) => self.write_unqualified(&self.norm_note(text)),
SearchNode::SingleField { field, text, is_re } => {
self.write_single_field(&norm(field), &self.norm_note(text), *is_re)?
}
SearchNode::Duplicates { note_type_id, text } => {
self.write_dupe(*note_type_id, &self.norm_note(text))?
}
SearchNode::Regex(re) => self.write_regex(&self.norm_note(re)),
SearchNode::NoCombining(text) => self.write_no_combining(&self.norm_note(text)),
SearchNode::WordBoundary(text) => self.write_word_boundary(&self.norm_note(text)),
// other
SearchNode::AddedInDays(days) => self.write_added(*days)?,
SearchNode::EditedInDays(days) => self.write_edited(*days)?,
SearchNode::CardTemplate(template) => match template {
TemplateKind::Ordinal(_) => self.write_template(template)?,
TemplateKind::Name(name) => {
self.write_template(&TemplateKind::Name(norm(name).into()))?
}
},
SearchNode::Deck(deck) => self.write_deck(&norm(deck))?,
SearchNode::NoteTypeId(ntid) => {
write!(self.sql, "n.mid = {}", ntid).unwrap();
}
SearchNode::DeckId(did) => {
write!(self.sql, "c.did = {}", did).unwrap();
}
SearchNode::NoteType(notetype) => self.write_note_type(&norm(notetype))?,
SearchNode::Rated { days, ease } => self.write_rated(">", -i64::from(*days), ease)?,
SearchNode::Tag(tag) => self.write_tag(&norm(tag))?,
SearchNode::State(state) => self.write_state(state)?,
SearchNode::Flag(flag) => {
write!(self.sql, "(c.flags & 7) == {}", flag).unwrap();
}
SearchNode::NoteIds(nids) => {
write!(self.sql, "{} in ({})", self.note_id_column(), nids).unwrap();
}
SearchNode::CardIds(cids) => {
write!(self.sql, "c.id in ({})", cids).unwrap();
}
SearchNode::Property { operator, kind } => self.write_prop(operator, kind)?,
SearchNode::WholeCollection => write!(self.sql, "true").unwrap(),
};
Ok(())
}
fn write_unqualified(&mut self, text: &str) {
// implicitly wrap in %
let text = format!("%{}%", &to_sql(text));
self.args.push(text);
write!(
self.sql,
"(n.sfld like ?{n} escape '\\' or n.flds like ?{n} escape '\\')",
n = self.args.len(),
)
.unwrap();
}
fn write_no_combining(&mut self, text: &str) {
let text = format!("%{}%", without_combining(&to_sql(text)));
self.args.push(text);
write!(
self.sql,
concat!(
"(coalesce(without_combining(cast(n.sfld as text)), n.sfld) like ?{n} escape '\\' ",
"or coalesce(without_combining(n.flds), n.flds) like ?{n} escape '\\')"
),
n = self.args.len(),
)
.unwrap();
}
fn write_tag(&mut self, text: &str) -> Result<()> {
if text.contains(' ') {
write!(self.sql, "false").unwrap();
} else {
match text {
"none" => {
write!(self.sql, "n.tags = ''").unwrap();
}
"*" => {
write!(self.sql, "true").unwrap();
}
text => {
write!(self.sql, "n.tags regexp ?").unwrap();
let re = &to_custom_re(text, r"\S");
self.args.push(format!("(?i).* {}(::| ).*", re));
}
}
}
Ok(())
}
fn write_rated(&mut self, op: &str, days: i64, ease: &RatingKind) -> Result<()> {
let today_cutoff = self.col.timing_today()?.next_day_at;
let target_cutoff_ms = (today_cutoff + 86_400 * days) * 1_000;
let day_before_cutoff_ms = (today_cutoff + 86_400 * (days - 1)) * 1_000;
write!(self.sql, "c.id in (select cid from revlog where id").unwrap();
match op {
">" => write!(self.sql, " >= {}", target_cutoff_ms),
">=" => write!(self.sql, " >= {}", day_before_cutoff_ms),
"<" => write!(self.sql, " < {}", day_before_cutoff_ms),
"<=" => write!(self.sql, " < {}", target_cutoff_ms),
"=" => write!(
self.sql,
" between {} and {}",
day_before_cutoff_ms,
target_cutoff_ms - 1
),
"!=" => write!(
self.sql,
" not between {} and {}",
day_before_cutoff_ms,
target_cutoff_ms - 1
),
_ => unreachable!("unexpected op"),
}
.unwrap();
match ease {
RatingKind::AnswerButton(u) => write!(self.sql, " and ease = {})", u),
RatingKind::AnyAnswerButton => write!(self.sql, " and ease > 0)"),
RatingKind::ManualReschedule => write!(self.sql, " and ease = 0)"),
}
.unwrap();
Ok(())
}
fn write_prop(&mut self, op: &str, kind: &PropertyKind) -> Result<()> {
let timing = self.col.timing_today()?;
match kind {
PropertyKind::Due(days) => {
let day = days + (timing.days_elapsed as i32);
write!(
self.sql,
// SQL does integer division if both parameters are integers
"(\
(c.queue in ({rev},{daylrn}) and c.due {op} {day}) or \
(c.queue in ({lrn},{previewrepeat}) and ((c.due - {cutoff}) / 86400) {op} {days})\
)",
rev = CardQueue::Review as u8,
daylrn = CardQueue::DayLearn as u8,
op = op,
day = day,
lrn = CardQueue::Learn as i8,
previewrepeat = CardQueue::PreviewRepeat as i8,
cutoff = timing.next_day_at,
days = days
).unwrap()
}
PropertyKind::Position(pos) => write!(
self.sql,
"(c.type = {t} and due {op} {pos})",
t = CardType::New as u8,
op = op,
pos = pos
)
.unwrap(),
PropertyKind::Interval(ivl) => write!(self.sql, "ivl {} {}", op, ivl).unwrap(),
PropertyKind::Reps(reps) => write!(self.sql, "reps {} {}", op, reps).unwrap(),
PropertyKind::Lapses(days) => write!(self.sql, "lapses {} {}", op, days).unwrap(),
PropertyKind::Ease(ease) => {
write!(self.sql, "factor {} {}", op, (ease * 1000.0) as u32).unwrap()
}
PropertyKind::Rated(days, ease) => self.write_rated(op, i64::from(*days), ease)?,
}
Ok(())
}
fn write_state(&mut self, state: &StateKind) -> Result<()> {
let timing = self.col.timing_today()?;
match state {
StateKind::New => write!(self.sql, "c.type = {}", CardType::New as i8),
StateKind::Review => write!(
self.sql,
"c.type in ({}, {})",
CardType::Review as i8,
CardType::Relearn as i8,
),
StateKind::Learning => write!(
self.sql,
"c.type in ({}, {})",
CardType::Learn as i8,
CardType::Relearn as i8,
),
StateKind::Buried => write!(
self.sql,
"c.queue in ({},{})",
CardQueue::SchedBuried as i8,
CardQueue::UserBuried as i8
),
StateKind::Suspended => write!(self.sql, "c.queue = {}", CardQueue::Suspended as i8),
StateKind::Due => write!(
self.sql,
"(\
(c.queue in ({rev},{daylrn}) and c.due <= {today}) or \
(c.queue in ({lrn},{previewrepeat}) and c.due <= {learncutoff})\
)",
rev = CardQueue::Review as i8,
daylrn = CardQueue::DayLearn as i8,
today = timing.days_elapsed,
lrn = CardQueue::Learn as i8,
previewrepeat = CardQueue::PreviewRepeat as i8,
learncutoff = TimestampSecs::now().0 + (self.col.learn_ahead_secs() as i64),
),
StateKind::UserBuried => write!(self.sql, "c.queue = {}", CardQueue::UserBuried as i8),
StateKind::SchedBuried => {
write!(self.sql, "c.queue = {}", CardQueue::SchedBuried as i8)
}
}
.unwrap();
Ok(())
}
fn write_deck(&mut self, deck: &str) -> Result<()> {
match deck {
"*" => write!(self.sql, "true").unwrap(),
"filtered" => write!(self.sql, "c.odid != 0").unwrap(),
deck => {
// rewrite "current" to the current deck name
let native_deck = if deck == "current" {
let current_did = self.col.get_current_deck_id();
regex::escape(
self.col
.storage
.get_deck(current_did)?
.map(|d| d.name)
.unwrap_or_else(|| "Default".into())
.as_str(),
)
} else {
human_deck_name_to_native(&to_re(deck))
};
// convert to a regex that includes child decks
self.args.push(format!("(?i)^{}($|\x1f)", native_deck));
let arg_idx = self.args.len();
self.sql.push_str(&format!(concat!(
"(c.did in (select id from decks where name regexp ?{n})",
" or (c.odid != 0 and c.odid in (select id from decks where name regexp ?{n})))"),
n=arg_idx
));
}
};
Ok(())
}
fn write_template(&mut self, template: &TemplateKind) -> Result<()> {
match template {
TemplateKind::Ordinal(n) => {
write!(self.sql, "c.ord = {}", n).unwrap();
}
TemplateKind::Name(name) => {
if is_glob(name) {
let re = format!("(?i){}", to_re(name));
self.sql.push_str(
"(n.mid,c.ord) in (select ntid,ord from templates where name regexp ?)",
);
self.args.push(re);
} else {
self.sql.push_str(
"(n.mid,c.ord) in (select ntid,ord from templates where name = ?)",
);
self.args.push(to_text(name).into());
}
}
};
Ok(())
}
fn write_note_type(&mut self, nt_name: &str) -> Result<()> {
if is_glob(nt_name) {
let re = format!("(?i){}", to_re(nt_name));
self.sql
.push_str("n.mid in (select id from notetypes where name regexp ?)");
self.args.push(re);
} else {
self.sql
.push_str("n.mid in (select id from notetypes where name = ?)");
self.args.push(to_text(nt_name).into());
}
Ok(())
}
fn write_single_field(&mut self, field_name: &str, val: &str, is_re: bool) -> Result<()> {
let note_types = self.col.get_all_notetypes()?;
let mut field_map = vec![];
for nt in note_types.values() {
for field in &nt.fields {
if matches_glob(&field.name, field_name) {
field_map.push((nt.id, field.ord));
}
}
}
// for now, sort the map for the benefit of unit tests
field_map.sort();
if field_map.is_empty() {
write!(self.sql, "false").unwrap();
return Ok(());
}
let cmp;
let cmp_trailer;
if is_re {
cmp = "regexp";
cmp_trailer = "";
self.args.push(format!("(?i){}", val));
} else {
cmp = "like";
cmp_trailer = "escape '\\'";
self.args.push(to_sql(val).into())
}
let arg_idx = self.args.len();
let searches: Vec<_> = field_map
.iter()
.map(|(ntid, ord)| {
format!(
"(n.mid = {mid} and field_at_index(n.flds, {ord}) {cmp} ?{n} {cmp_trailer})",
mid = ntid,
ord = ord.unwrap_or_default(),
cmp = cmp,
cmp_trailer = cmp_trailer,
n = arg_idx
)
})
.collect();
write!(self.sql, "({})", searches.join(" or ")).unwrap();
Ok(())
}
fn write_dupe(&mut self, ntid: NoteTypeId, text: &str) -> Result<()> {
let text_nohtml = strip_html_preserving_media_filenames(text);
let csum = field_checksum(text_nohtml.as_ref());
let nids: Vec<_> = self
.col
.storage
.note_fields_by_checksum(ntid, csum)?
.into_iter()
.filter_map(|(nid, field)| {
if strip_html_preserving_media_filenames(&field) == text_nohtml {
Some(nid)
} else {
None
}
})
.collect();
self.sql += "n.id in ";
ids_to_string(&mut self.sql, &nids);
Ok(())
}
fn write_added(&mut self, days: u32) -> Result<()> {
let timing = self.col.timing_today()?;
let cutoff = (timing.next_day_at - (86_400 * (days as i64))) * 1_000;
write!(self.sql, "c.id > {}", cutoff).unwrap();
Ok(())
}
fn write_edited(&mut self, days: u32) -> Result<()> {
let timing = self.col.timing_today()?;
let cutoff = timing.next_day_at - (86_400 * (days as i64));
write!(self.sql, "n.mod > {}", cutoff).unwrap();
Ok(())
}
fn write_regex(&mut self, word: &str) {
self.sql.push_str("n.flds regexp ?");
self.args.push(format!(r"(?i){}", word));
}
fn write_word_boundary(&mut self, word: &str) {
self.write_regex(&format!(r"\b{}\b", to_re(word)));
}
}
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum RequiredTable {
Notes,
Cards,
CardsAndNotes,
CardsOrNotes,
}
impl RequiredTable {
fn combine(self, other: RequiredTable) -> RequiredTable {
match (self, other) {
(RequiredTable::CardsAndNotes, _) => RequiredTable::CardsAndNotes,
(_, RequiredTable::CardsAndNotes) => RequiredTable::CardsAndNotes,
(RequiredTable::CardsOrNotes, b) => b,
(a, RequiredTable::CardsOrNotes) => a,
(a, b) => {
if a == b {
a
} else {
RequiredTable::CardsAndNotes
}
}
}
}
}
impl Node {
fn required_table(&self) -> RequiredTable {
match self {
Node::And => RequiredTable::CardsOrNotes,
Node::Or => RequiredTable::CardsOrNotes,
Node::Not(node) => node.required_table(),
Node::Group(nodes) => nodes.iter().fold(RequiredTable::CardsOrNotes, |cur, node| {
cur.combine(node.required_table())
}),
Node::Search(node) => node.required_table(),
}
}
}
impl SearchNode {
fn required_table(&self) -> RequiredTable {
match self {
SearchNode::AddedInDays(_) => RequiredTable::Cards,
SearchNode::Deck(_) => RequiredTable::Cards,
SearchNode::DeckId(_) => RequiredTable::Cards,
SearchNode::Rated { .. } => RequiredTable::Cards,
SearchNode::State(_) => RequiredTable::Cards,
SearchNode::Flag(_) => RequiredTable::Cards,
SearchNode::CardIds(_) => RequiredTable::Cards,
SearchNode::Property { .. } => RequiredTable::Cards,
SearchNode::UnqualifiedText(_) => RequiredTable::Notes,
SearchNode::SingleField { .. } => RequiredTable::Notes,
SearchNode::Tag(_) => RequiredTable::Notes,
SearchNode::Duplicates { .. } => RequiredTable::Notes,
SearchNode::Regex(_) => RequiredTable::Notes,
SearchNode::NoCombining(_) => RequiredTable::Notes,
SearchNode::WordBoundary(_) => RequiredTable::Notes,
SearchNode::NoteTypeId(_) => RequiredTable::Notes,
SearchNode::NoteType(_) => RequiredTable::Notes,
SearchNode::EditedInDays(_) => RequiredTable::Notes,
SearchNode::NoteIds(_) => RequiredTable::CardsOrNotes,
SearchNode::WholeCollection => RequiredTable::CardsOrNotes,
SearchNode::CardTemplate(_) => RequiredTable::CardsAndNotes,
}
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::{
collection::{open_collection, Collection},
i18n::I18n,
log,
};
use std::{fs, path::PathBuf};
use tempfile::tempdir;
use super::super::parser::parse;
// shortcut
fn s(req: &mut Collection, search: &str) -> (String, Vec<String>) {
let node = Node::Group(parse(search).unwrap());
let mut writer = SqlWriter::new(req);
writer.table = RequiredTable::Notes.combine(node.required_table());
writer.write_node_to_sql(&node).unwrap();
(writer.sql, writer.args)
}
#[test]
fn sql() -> Result<()> {
// re-use the mediacheck .anki2 file for now
use crate::media::check::test::MEDIACHECK_ANKI2;
let dir = tempdir().unwrap();
let col_path = dir.path().join("col.anki2");
fs::write(&col_path, MEDIACHECK_ANKI2).unwrap();
let tr = I18n::template_only();
let mut col = open_collection(
&col_path,
&PathBuf::new(),
&PathBuf::new(),
false,
tr,
log::terminal(),
)
.unwrap();
let ctx = &mut col;
// unqualified search
assert_eq!(
s(ctx, "te*st"),
(
"((n.sfld like ?1 escape '\\' or n.flds like ?1 escape '\\'))".into(),
vec!["%te%st%".into()]
)
);
assert_eq!(s(ctx, "te%st").1, vec![r"%te\%st%".to_string()]);
// user should be able to escape wildcards
assert_eq!(s(ctx, r#"te\*s\_t"#).1, vec!["%te*s\\_t%".to_string()]);
// qualified search
assert_eq!(
s(ctx, "front:te*st"),
(
concat!(
"(((n.mid = 1581236385344 and field_at_index(n.flds, 0) like ?1 escape '\\') or ",
"(n.mid = 1581236385345 and field_at_index(n.flds, 0) like ?1 escape '\\') or ",
"(n.mid = 1581236385346 and field_at_index(n.flds, 0) like ?1 escape '\\') or ",
"(n.mid = 1581236385347 and field_at_index(n.flds, 0) like ?1 escape '\\')))"
)
.into(),
vec!["te%st".into()]
)
);
// added
let timing = ctx.timing_today().unwrap();
assert_eq!(
s(ctx, "added:3").0,
format!("(c.id > {})", (timing.next_day_at - (86_400 * 3)) * 1_000)
);
assert_eq!(s(ctx, "added:0").0, s(ctx, "added:1").0,);
// deck
assert_eq!(
s(ctx, "deck:default"),
(
"((c.did in (select id from decks where name regexp ?1) or (c.odid != 0 and \
c.odid in (select id from decks where name regexp ?1))))"
.into(),
vec!["(?i)^default($|\u{1f})".into()]
)
);
assert_eq!(
s(ctx, "deck:current").1,
vec!["(?i)^Default($|\u{1f})".to_string()]
);
assert_eq!(s(ctx, "deck:d*").1, vec!["(?i)^d.*($|\u{1f})".to_string()]);
assert_eq!(s(ctx, "deck:filtered"), ("(c.odid != 0)".into(), vec![],));
// card
assert_eq!(
s(ctx, r#""card:card 1""#),
(
"((n.mid,c.ord) in (select ntid,ord from templates where name = ?))".into(),
vec!["card 1".into()]
)
);
// IDs
assert_eq!(s(ctx, "mid:3"), ("(n.mid = 3)".into(), vec![]));
assert_eq!(s(ctx, "nid:3"), ("(n.id in (3))".into(), vec![]));
assert_eq!(s(ctx, "nid:3,4"), ("(n.id in (3,4))".into(), vec![]));
assert_eq!(s(ctx, "cid:3,4"), ("(c.id in (3,4))".into(), vec![]));
// flags
assert_eq!(s(ctx, "flag:2"), ("((c.flags & 7) == 2)".into(), vec![]));
assert_eq!(s(ctx, "flag:0"), ("((c.flags & 7) == 0)".into(), vec![]));
// dupes
assert_eq!(s(ctx, "dupe:123,test"), ("(n.id in ())".into(), vec![]));
// tags
assert_eq!(
s(ctx, r"tag:one"),
(
"(n.tags regexp ?)".into(),
vec!["(?i).* one(::| ).*".into()]
)
);
assert_eq!(
s(ctx, r"tag:foo::bar"),
(
"(n.tags regexp ?)".into(),
vec!["(?i).* foo::bar(::| ).*".into()]
)
);
assert_eq!(
s(ctx, r"tag:o*n\*et%w%oth_re\_e"),
(
"(n.tags regexp ?)".into(),
vec![r"(?i).* o\S*n\*et%w%oth\Sre_e(::| ).*".into()]
)
);
assert_eq!(s(ctx, "tag:none"), ("(n.tags = '')".into(), vec![]));
assert_eq!(s(ctx, "tag:*"), ("(true)".into(), vec![]));
// state
assert_eq!(
s(ctx, "is:suspended").0,
format!("(c.queue = {})", CardQueue::Suspended as i8)
);
assert_eq!(
s(ctx, "is:new").0,
format!("(c.type = {})", CardType::New as i8)
);
// rated
assert_eq!(
s(ctx, "rated:2").0,
format!(
"(c.id in (select cid from revlog where id >= {} and ease > 0))",
(timing.next_day_at - (86_400 * 2)) * 1_000
)
);
assert_eq!(
s(ctx, "rated:400:1").0,
format!(
"(c.id in (select cid from revlog where id >= {} and ease = 1))",
(timing.next_day_at - (86_400 * 400)) * 1_000
)
);
assert_eq!(s(ctx, "rated:0").0, s(ctx, "rated:1").0);
// resched
assert_eq!(
s(ctx, "resched:400").0,
format!(
"(c.id in (select cid from revlog where id >= {} and ease = 0))",
(timing.next_day_at - (86_400 * 400)) * 1_000
)
);
// props
assert_eq!(s(ctx, "prop:lapses=3").0, "(lapses = 3)".to_string());
assert_eq!(s(ctx, "prop:ease>=2.5").0, "(factor >= 2500)".to_string());
assert_eq!(
s(ctx, "prop:due!=-1").0,
format!(
"(((c.queue in (2,3) and c.due != {days}) or (c.queue in (1,4) and ((c.due - {cutoff}) / 86400) != -1)))",
days = timing.days_elapsed - 1,
cutoff = timing.next_day_at
)
);
assert_eq!(s(ctx, "prop:rated>-5:3").0, s(ctx, "rated:5:3").0);
// note types by name
assert_eq!(
s(ctx, "note:basic"),
(
"(n.mid in (select id from notetypes where name = ?))".into(),
vec!["basic".into()]
)
);
assert_eq!(
s(ctx, "note:basic*"),
(
"(n.mid in (select id from notetypes where name regexp ?))".into(),
vec!["(?i)basic.*".into()]
)
);
// regex
assert_eq!(
s(ctx, r"re:\bone"),
("(n.flds regexp ?)".into(), vec![r"(?i)\bone".into()])
);
// word boundary
assert_eq!(
s(ctx, r"w:foo"),
("(n.flds regexp ?)".into(), vec![r"(?i)\bfoo\b".into()])
);
assert_eq!(
s(ctx, r"w:*foo"),
("(n.flds regexp ?)".into(), vec![r"(?i)\b.*foo\b".into()])
);
assert_eq!(
s(ctx, r"w:*fo_o*"),
("(n.flds regexp ?)".into(), vec![r"(?i)\b.*fo.o.*\b".into()])
);
Ok(())
}
#[test]
fn required_table() {
assert_eq!(
Node::Group(parse("").unwrap()).required_table(),
RequiredTable::CardsOrNotes
);
assert_eq!(
Node::Group(parse("test").unwrap()).required_table(),
RequiredTable::Notes
);
assert_eq!(
Node::Group(parse("cid:1").unwrap()).required_table(),
RequiredTable::Cards
);
assert_eq!(
Node::Group(parse("cid:1 test").unwrap()).required_table(),
RequiredTable::CardsAndNotes
);
assert_eq!(
Node::Group(parse("nid:1").unwrap()).required_table(),
RequiredTable::CardsOrNotes
);
assert_eq!(
Node::Group(parse("cid:1 nid:1").unwrap()).required_table(),
RequiredTable::Cards
);
assert_eq!(
Node::Group(parse("test nid:1").unwrap()).required_table(),
RequiredTable::Notes
);
}
}