// Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::parser::{EaseKind, Node, PropertyKind, SearchNode, StateKind, TemplateKind}; use crate::{ card::{CardQueue, CardType}, collection::Collection, decks::human_deck_name_to_native, err::Result, notes::field_checksum, notetype::NoteTypeID, storage::ids_to_string, text::{ escape_sql, 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, normalize_note_text: bool, table: RequiredTable, } impl SqlWriter<'_> { pub(crate) fn new(col: &mut Collection) -> SqlWriter<'_> { let normalize_note_text = col.normalize_note_text(); 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)> { 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)> { 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)))?, }, 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(), s => { if is_glob(s) { write!(self.sql, "n.tags regexp ?").unwrap(); let re = &to_custom_re(s, r"\S"); self.args.push(format!("(?i).* {} .*", re)); } else if let Some(tag) = self.col.storage.preferred_tag_case(&to_text(s))? { write!(self.sql, "n.tags like ? escape '\\'").unwrap(); self.args.push(format!("% {} %", escape_sql(&tag))); } else { write!(self.sql, "false").unwrap(); } } } } Ok(()) } fn write_rated(&mut self, op: &str, days: i64, ease: &EaseKind) -> 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), } .unwrap(); match ease { EaseKind::AnswerButton(u) => write!(self.sql, " and ease = {})", u), EaseKind::AnyAnswerButton => write!(self.sql, " and ease > 0)"), EaseKind::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, types::Usn, }; use std::{fs, path::PathBuf}; use tempfile::tempdir; use super::super::parser::parse; // shortcut fn s(req: &mut Collection, search: &str) -> (String, Vec) { 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 i18n = I18n::new(&[""], "", log::terminal()); let mut col = open_collection( &col_path, &PathBuf::new(), &PathBuf::new(), false, i18n, 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![])); // if registered, searches with canonical ctx.transact(None, |col| col.register_tag("One", Usn(-1))) .unwrap(); assert_eq!( s(ctx, r"tag:one"), ( "(n.tags like ? escape '\\')".into(), vec![r"% One %".into()] ) ); // unregistered tags without wildcards won't match assert_eq!(s(ctx, "tag:unknown"), ("(false)".into(), vec![])); // wildcards force a regexp search 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 ); } }