use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use lru::LruCache;
use rusqlite::types::ValueRef;
use rusqlite::{Connection, OpenFlags, OptionalExtension};
use super::frequency::WubiFrequency;
use crate::dictionary::pinyin::BUSY_TIMEOUT;
use crate::types::{CandidateSource, QueryRequest, SchemeType, WordItem, WubiProfileKind};
const QUERY_LIMIT: i64 = 50;
const REVERSE_CACHE_CAPACITY: usize = 1024;
const REVERSE_QUERY_SQL_86: &str = "SELECT \"key\" FROM wubi86 WHERE \"value\" = ?1 ORDER BY length(\"key\") DESC, \"weight\" DESC, \"key\" ASC, rowid ASC LIMIT 1";
const REVERSE_QUERY_SQL_98: &str = "SELECT \"key\" FROM wubi98 WHERE \"value\" = ?1 ORDER BY length(\"key\") DESC, \"weight\" DESC, \"key\" ASC, rowid ASC LIMIT 1";
const QUERY_SQL_86: &str = "SELECT \"key\", \"value\", \"weight\" FROM wubi86 WHERE \"key\" >= ?1 AND \"key\" < ?2 ORDER BY (\"key\" = ?1) DESC, \"weight\" DESC, \"key\" ASC, rowid ASC LIMIT ?3";
const QUERY_SQL_98: &str = "SELECT \"key\", \"value\", \"weight\" FROM wubi98 WHERE \"key\" >= ?1 AND \"key\" < ?2 ORDER BY (\"key\" = ?1) DESC, \"weight\" DESC, \"key\" ASC, rowid ASC LIMIT ?3";
const WILDCARD_SQL_86: &str = "SELECT \"key\", \"value\", \"weight\" FROM wubi86 WHERE \"key\" GLOB ?1 ORDER BY \"weight\" DESC, \"key\" ASC, rowid ASC LIMIT ?2";
const WILDCARD_SQL_98: &str = "SELECT \"key\", \"value\", \"weight\" FROM wubi98 WHERE \"key\" GLOB ?1 ORDER BY \"weight\" DESC, \"key\" ASC, rowid ASC LIMIT ?2";
const GRID_EXACT_SQL_86: &str = "SELECT \"key\", \"value\", \"weight\" FROM wubi86 WHERE \"key\" = ?1 ORDER BY \"weight\" DESC, rowid ASC LIMIT ?2";
const GRID_EXACT_SQL_98: &str = "SELECT \"key\", \"value\", \"weight\" FROM wubi98 WHERE \"key\" = ?1 ORDER BY \"weight\" DESC, rowid ASC LIMIT ?2";
const GRID_LONGER_SQL_86: &str = "SELECT \"key\", \"value\", \"weight\" FROM wubi86 WHERE \"key\" > ?1 AND \"key\" < ?2 ORDER BY \"weight\" DESC, \"key\" ASC, rowid ASC LIMIT ?3";
const GRID_LONGER_SQL_98: &str = "SELECT \"key\", \"value\", \"weight\" FROM wubi98 WHERE \"key\" > ?1 AND \"key\" < ?2 ORDER BY \"weight\" DESC, \"key\" ASC, rowid ASC LIMIT ?3";
const REVERSE_INDEXES: [(&str, &str, &str); 2] = [
(
"wubi86",
"idx_wubi86_value",
"CREATE INDEX IF NOT EXISTS idx_wubi86_value ON wubi86(\"value\")",
),
(
"wubi98",
"idx_wubi98_value",
"CREATE INDEX IF NOT EXISTS idx_wubi98_value ON wubi98(\"value\")",
),
];
pub(crate) fn ensure_reverse_indexes(connection: &Connection) -> rusqlite::Result<()> {
for (table, index, create) in REVERSE_INDEXES {
let (has_table, has_index): (bool, bool) = connection.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1), EXISTS(SELECT 1 FROM sqlite_master WHERE type='index' AND name=?2)",
(table, index),
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
if has_table && !has_index {
connection.execute_batch(create)?;
}
}
Ok(())
}
fn query_sql(profile: WubiProfileKind) -> &'static str {
match profile {
WubiProfileKind::Wubi86 => QUERY_SQL_86,
WubiProfileKind::Wubi98 => QUERY_SQL_98,
}
}
fn grid_sql(profile: WubiProfileKind) -> (&'static str, &'static str) {
match profile {
WubiProfileKind::Wubi86 => (GRID_EXACT_SQL_86, GRID_LONGER_SQL_86),
WubiProfileKind::Wubi98 => (GRID_EXACT_SQL_98, GRID_LONGER_SQL_98),
}
}
fn wildcard_sql(profile: WubiProfileKind) -> &'static str {
match profile {
WubiProfileKind::Wubi86 => WILDCARD_SQL_86,
WubiProfileKind::Wubi98 => WILDCARD_SQL_98,
}
}
fn wildcard_pattern_into(code: &str, pattern: &mut String) {
pattern.clear();
pattern.reserve(code.len() + 1);
for character in code.chars() {
match character {
'z' => pattern.push('?'),
'*' | '?' | '[' | ']' => {
pattern.push('[');
pattern.push(character);
pattern.push(']');
}
other => pattern.push(other),
}
}
pattern.push('*');
}
pub struct WubiProvider {
main_db: PathBuf,
profile: WubiProfileKind,
connection: Option<Connection>,
query_upper_bound: String,
wildcard_pattern: String,
grid_expansions: Vec<String>,
frequency: WubiFrequency,
reverse_cache: LruCache<String, Option<String>>,
}
impl WubiProvider {
#[cfg(test)]
pub fn new(main_db: &Path) -> Self {
Self::with_pinyin_dictionary(main_db, main_db)
}
pub fn with_pinyin_dictionary(main_db: &Path, pinyin_db: &Path) -> Self {
Self {
main_db: main_db.to_path_buf(),
profile: WubiProfileKind::Wubi86,
connection: None,
query_upper_bound: String::new(),
wildcard_pattern: String::new(),
grid_expansions: Vec::new(),
frequency: WubiFrequency::new(pinyin_db),
reverse_cache: LruCache::new(NonZeroUsize::new(REVERSE_CACHE_CAPACITY).unwrap()),
}
}
pub fn set_profile(&mut self, profile: WubiProfileKind) {
self.profile = profile;
self.reverse_cache.clear();
}
pub fn query(&mut self, request: &QueryRequest) -> Vec<WordItem> {
if !request.valid
|| request.scheme != SchemeType::Wubi
|| request.normalized_input.is_empty()
{
return Vec::new();
}
let profile = self.profile;
if self.connection.is_none() {
self.connection = open_read_only(&self.main_db);
}
let Some(connection) = self.connection.as_ref() else {
return Vec::new();
};
let rows = if request.wubi_grid {
super::grid::expand_into(&request.normalized_input, &mut self.grid_expansions);
query_grid_rows(
connection,
profile,
&self.grid_expansions,
&mut self.query_upper_bound,
&mut self.frequency,
)
} else if request.wubi_z_wildcard {
wildcard_pattern_into(&request.normalized_input, &mut self.wildcard_pattern);
query_wildcard_rows(connection, profile, &self.wildcard_pattern)
} else {
query_rows(
connection,
profile,
&request.normalized_input,
&mut self.query_upper_bound,
)
};
match rows {
Ok(rows) => rows,
Err(_) => {
self.connection = None;
Vec::new()
}
}
}
pub fn reset_cache(&mut self) {
self.connection = None;
self.reverse_cache.clear();
self.frequency.reset_cache();
}
#[cfg(test)]
pub fn reverse_code(&mut self, word: &str) -> Option<String> {
self.reverse_code_ref(word).map(str::to_owned)
}
pub fn reverse_code_into(&mut self, word: &str, destination: &mut String) -> bool {
let Some(code) = self.reverse_code_ref(word) else {
destination.clear();
return false;
};
destination.clear();
destination.push_str(code);
true
}
fn reverse_code_ref(&mut self, word: &str) -> Option<&str> {
if word.is_empty() {
return None;
}
if self.reverse_cache.peek(word).is_none() {
let profile = self.profile;
let code = self
.connection()
.and_then(|connection| reverse_code(connection, profile, word).ok().flatten());
self.reverse_cache.put(word.to_owned(), code);
}
self.reverse_cache
.get(word)
.and_then(|code| code.as_deref())
}
fn connection(&mut self) -> Option<&Connection> {
if self.connection.is_none() {
self.connection = open_read_only(&self.main_db);
}
self.connection.as_ref()
}
}
pub(crate) fn open_read_only(path: &Path) -> Option<Connection> {
if path.as_os_str().is_empty() {
return None;
}
let path = crate::paths::sqlite_read_only_path_no_follow(path).ok()?;
let connection = Connection::open_with_flags(
&path,
OpenFlags::SQLITE_OPEN_READ_ONLY
| OpenFlags::SQLITE_OPEN_NOFOLLOW
| OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.ok()?;
connection.busy_timeout(BUSY_TIMEOUT).ok()?;
Some(connection)
}
fn prefix_upper_bound_into(code: &str, upper: &mut String) {
upper.clear();
upper.push_str(code);
if let Some(last) = upper.pop() {
upper.push(char::from_u32(u32::from(last) + 1).unwrap_or(char::MAX));
}
}
fn query_rows(
connection: &Connection,
profile: WubiProfileKind,
code: &str,
upper: &mut String,
) -> rusqlite::Result<Vec<WordItem>> {
let mut statement = connection.prepare_cached(query_sql(profile))?;
prefix_upper_bound_into(code, upper);
let mut rows = statement.query((code, upper.as_str(), QUERY_LIMIT))?;
collect_rows(&mut rows)
}
fn collect_rows(rows: &mut rusqlite::Rows<'_>) -> rusqlite::Result<Vec<WordItem>> {
let mut candidates = Vec::new();
while let Some(row) = rows.next()? {
let (ValueRef::Text(key), ValueRef::Text(value)) = (row.get_ref(0)?, row.get_ref(1)?)
else {
continue;
};
let key = String::from_utf8_lossy(key).into_owned();
let value = String::from_utf8_lossy(value).into_owned();
let weight = match row.get_ref(2)? {
ValueRef::Integer(weight) => weight,
ValueRef::Real(weight) => weight as i64,
ValueRef::Null | ValueRef::Text(_) | ValueRef::Blob(_) => 0,
};
if candidates.is_empty() {
candidates.reserve_exact(QUERY_LIMIT as usize);
}
let mut item = WordItem::new(key, value, weight, CandidateSource::Database, "");
item.scheme = SchemeType::Wubi;
candidates.push(item);
}
Ok(candidates)
}
fn query_grid_rows(
connection: &Connection,
profile: WubiProfileKind,
expansions: &[String],
upper: &mut String,
frequency: &mut WubiFrequency,
) -> rusqlite::Result<Vec<WordItem>> {
let (exact_sql, longer_sql) = grid_sql(profile);
let mut scored: Vec<(i64, WordItem)> = Vec::new();
{
let mut statement = connection.prepare_cached(exact_sql)?;
for code in expansions {
let mut rows = statement.query((code.as_str(), QUERY_LIMIT))?;
let rows = collect_rows(&mut rows)?;
let start = scored.len();
scored.extend(rows.into_iter().map(|item| (0, item)));
let mut best = 0;
for (score, item) in scored[start..].iter_mut().rev() {
best = best.max(frequency.weight(&item.word));
*score = best;
}
}
}
scored.sort_by(|(left_score, left), (right_score, right)| {
right_score
.cmp(left_score)
.then(right.weight.cmp(&left.weight))
.then(left.pinyin.cmp(&right.pinyin))
});
let mut candidates: Vec<WordItem> = scored.into_iter().map(|(_, item)| item).collect();
let limit = QUERY_LIMIT as usize;
if candidates.len() < limit {
let mut longer = Vec::new();
let mut statement = connection.prepare_cached(longer_sql)?;
for code in expansions {
prefix_upper_bound_into(code, upper);
let mut rows = statement.query((code.as_str(), upper.as_str(), QUERY_LIMIT))?;
longer.extend(collect_rows(&mut rows)?);
}
longer.sort_by(|left, right| {
right
.weight
.cmp(&left.weight)
.then(left.pinyin.cmp(&right.pinyin))
});
longer.truncate(limit - candidates.len());
candidates.extend(longer);
}
candidates.truncate(limit);
Ok(candidates)
}
fn query_wildcard_rows(
connection: &Connection,
profile: WubiProfileKind,
pattern: &str,
) -> rusqlite::Result<Vec<WordItem>> {
let mut statement = connection.prepare_cached(wildcard_sql(profile))?;
let mut rows = statement.query((pattern, QUERY_LIMIT))?;
collect_rows(&mut rows)
}
fn reverse_code(
connection: &Connection,
profile: WubiProfileKind,
word: &str,
) -> rusqlite::Result<Option<String>> {
connection
.prepare_cached(match profile {
WubiProfileKind::Wubi86 => REVERSE_QUERY_SQL_86,
WubiProfileKind::Wubi98 => REVERSE_QUERY_SQL_98,
})?
.query_row([word], |row| row.get(0))
.optional()
}
#[cfg(test)]
mod tests {
use super::*;
struct Fixture {
_root: tempfile::TempDir,
provider: WubiProvider,
}
fn fixture(sql: &str) -> Fixture {
let root = tempfile::tempdir().expect("temporary directory");
let main_db = root.path().join("msime-pinyin.db");
Connection::open(&main_db)
.expect("fixture database")
.execute_batch(sql)
.expect("fixture SQL");
let provider = WubiProvider::new(&main_db);
Fixture {
_root: root,
provider,
}
}
fn request(code: &str) -> QueryRequest {
QueryRequest {
scheme: SchemeType::Wubi,
raw_input: code.to_owned(),
raw_input_with_cases: code.to_owned(),
normalized_input: code.to_owned(),
valid: !code.is_empty(),
..QueryRequest::default()
}
}
fn rows(provider: &mut WubiProvider, code: &str) -> Vec<(String, String, i64)> {
provider
.query(&request(code))
.into_iter()
.map(|item| {
(
item.pinyin.into_string(),
item.word.into_string(),
item.weight,
)
})
.collect()
}
fn wildcard_request(code: &str) -> QueryRequest {
QueryRequest {
wubi_z_wildcard: true,
..request(code)
}
}
fn wildcard_rows(provider: &mut WubiProvider, code: &str) -> Vec<(String, String, i64)> {
provider
.query(&wildcard_request(code))
.into_iter()
.map(|item| {
(
item.pinyin.into_string(),
item.word.into_string(),
item.weight,
)
})
.collect()
}
fn row(code: &str, word: &str, weight: i64) -> (String, String, i64) {
(code.to_owned(), word.to_owned(), weight)
}
const GRID_WUBI_FIXTURE: &str = "CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);\
INSERT INTO wubi86 VALUES('q','我',20),('q','金',10),('w','人',20),('w','八',10),\
('qq','多',20),('qw','欠',10),('wq','你',10),('ww','从',10),\
('wqiy','你',20),('qqiy','铄',20),('wwiy','人流',30),\
('x','经',20),('xx','弱',20),('vb','好',10);";
fn grid_words(provider: &mut WubiProvider, code: &str) -> Vec<(String, String)> {
provider
.query(&QueryRequest {
wubi_grid: true,
..request(code)
})
.into_iter()
.map(|item| (item.pinyin.into_string(), item.word.into_string()))
.collect()
}
fn pairs(expected: &[(&str, &str)]) -> Vec<(String, String)> {
expected
.iter()
.map(|(code, word)| ((*code).to_owned(), (*word).to_owned()))
.collect()
}
fn grid_fixture() -> Fixture {
fixture(&format!(
"{}{GRID_WUBI_FIXTURE}",
crate::wubi::frequency::tests::PINYIN_FIXTURE
))
}
#[test]
fn a_grid_code_ranks_the_codes_of_its_keys_by_word_frequency() {
let mut fixture = grid_fixture();
assert_eq!(
grid_words(&mut fixture.provider, "qq"),
pairs(&[
("wq", "你"),
("qq", "多"),
("ww", "从"),
("qw", "欠"),
("wwiy", "人流"),
("qqiy", "铄"),
("wqiy", "你"),
])
);
assert_eq!(
grid_words(&mut fixture.provider, "x"),
pairs(&[("x", "经"), ("xx", "弱")])
);
assert!(grid_words(&mut fixture.provider, "cb")
.iter()
.any(|(code, _)| code == "vb"));
}
#[test]
fn a_grid_code_keeps_the_order_inside_each_code() {
let mut fixture = grid_fixture();
assert_eq!(
grid_words(&mut fixture.provider, "q")[..4],
pairs(&[("q", "我"), ("w", "人"), ("q", "金"), ("w", "八")])
);
Connection::open(&fixture.provider.main_db)
.unwrap()
.execute_batch("UPDATE wubi86 SET weight=30 WHERE key='q' AND value='金';")
.unwrap();
fixture.provider.reset_cache();
assert_eq!(
grid_words(&mut fixture.provider, "q")[..4],
pairs(&[("q", "金"), ("q", "我"), ("w", "人"), ("w", "八")])
);
}
#[test]
fn a_grid_code_falls_back_to_table_weights_without_the_pinyin_dictionary() {
let mut fixture = fixture(GRID_WUBI_FIXTURE);
assert_eq!(
grid_words(&mut fixture.provider, "qq")[..4],
pairs(&[("qq", "多"), ("qw", "欠"), ("wq", "你"), ("ww", "从")])
);
}
#[test]
#[ignore = "需要真实词库,见文档注释"]
fn wubi_grid_real_table_latency_and_rank() {
use std::time::{Duration, Instant};
let path = std::path::PathBuf::from(
std::env::var_os("MSIME_WUBI_GRID_DB").expect("MSIME_WUBI_GRID_DB"),
);
let connection = Connection::open(&path).unwrap();
let rows: Vec<(String, String)> = connection
.prepare("SELECT key, value FROM wubi86 ORDER BY rowid")
.unwrap()
.query_map((), |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap()
.filter_map(std::result::Result::ok)
.filter(|(code, _)| {
(1..=4).contains(&code.len())
&& code.bytes().all(|letter| (b'a'..=b'y').contains(&letter))
})
.collect();
let mut provider = WubiProvider::new(&path);
let cold = Instant::now();
provider.query(&QueryRequest {
wubi_grid: true,
..request("q")
});
let cold = cold.elapsed();
let mut frequency = WubiFrequency::new(&path);
let mut latencies: [Vec<Duration>; 4] = Default::default();
let mut baseline: [Vec<Duration>; 4] = Default::default();
let mut ranks = [[0f64; 4]; 2];
let mut unranked = 0usize;
for (index, (code, word)) in rows.iter().enumerate() {
let groups: String = code
.bytes()
.map(|letter| char::from(super::super::grid::representative_of(letter).unwrap()))
.collect();
if index % 16 == 0 {
for length in 1..=groups.len() {
let started = Instant::now();
provider.query(&QueryRequest {
wubi_grid: true,
..request(&groups[..length])
});
latencies[length - 1].push(started.elapsed());
let started = Instant::now();
provider.query(&request(&code[..length]));
baseline[length - 1].push(started.elapsed());
}
}
let weight = frequency.weight(word) as f64;
if weight <= 0.0 {
unranked += 1;
continue;
}
let answer = provider.query(&QueryRequest {
wubi_grid: true,
..request(&groups)
});
let position = answer
.iter()
.position(|item| item.pinyin.as_str() == code && item.word.as_str() == word)
.unwrap_or(usize::MAX);
let kind = usize::from(word.chars().count() > 1);
ranks[kind][0] += weight;
ranks[kind][1] += weight * f64::from(u8::from(position < 1));
ranks[kind][2] += weight * f64::from(u8::from(position < 3));
ranks[kind][3] += weight * f64::from(u8::from(position < 5));
}
eprintln!(
"rows {} (no frequency {unranked}), cold first query {cold:?}",
rows.len()
);
for (length, samples) in latencies.iter_mut().chain(baseline.iter_mut()).enumerate() {
samples.sort_unstable();
let at = |quantile: f64| samples[((samples.len() - 1) as f64 * quantile) as usize];
eprintln!(
"{} {} key(s): n={} p50={:?} p95={:?} p99={:?} max={:?}",
if length < 4 { "grid" } else { "26-key" },
length % 4 + 1,
samples.len(),
at(0.5),
at(0.95),
at(0.99),
samples.last().unwrap()
);
}
for (kind, [total, top1, top3, top5]) in ["characters", "words"].iter().zip(ranks) {
eprintln!(
"{kind}: weighted top-1 {:.3} top-3 {:.3} top-5 {:.3}",
top1 / total,
top3 / total,
top5 / total
);
}
let p95 = |samples: &[Duration]| samples[(samples.len() - 1) * 95 / 100];
assert!(latencies
.iter()
.all(|samples| p95(samples) < Duration::from_millis(20)));
}
#[test]
fn a_grid_code_answers_at_most_the_query_limit() {
let mut sql = String::from("CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);");
for index in 0..40 {
sql.push_str(&format!(
"INSERT INTO wubi86 VALUES('q','甲{index}',{index}),('wqiy','乙{index}',{index});"
));
}
let mut fixture = fixture(&sql);
let words = grid_words(&mut fixture.provider, "q");
assert_eq!(words.len(), QUERY_LIMIT as usize);
assert!(words[..40].iter().all(|(code, _)| code == "q"));
assert!(words[40..].iter().all(|(code, _)| code == "wqiy"));
}
const SESSION_FIXTURE: &str = "CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);\
INSERT INTO wubi86 VALUES('w','人',20);\
INSERT INTO wubi86 VALUES('wq','你',10);\
INSERT INTO wubi86 VALUES('wqb','爷',20);\
INSERT INTO wubi86 VALUES('wqi','你',10);\
INSERT INTO wubi86 VALUES('wqbb','父子',30);";
#[test]
fn the_profile_picks_the_table() {
let mut fixture = fixture(
"CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);\
INSERT INTO wubi86 VALUES('kl','号',10);\
CREATE TABLE wubi98(key TEXT,value TEXT,weight INTEGER);\
INSERT INTO wubi98 VALUES('kg','号',10);",
);
let provider = &mut fixture.provider;
assert_eq!(rows(provider, "kl"), vec![row("kl", "号", 10)]);
assert!(rows(provider, "kg").is_empty());
provider.set_profile(WubiProfileKind::Wubi98);
assert_eq!(rows(provider, "kg"), vec![row("kg", "号", 10)]);
assert!(rows(provider, "kl").is_empty());
}
#[test]
fn exact_code_leads_then_weight_without_word_dedup() {
let mut fixture = fixture(SESSION_FIXTURE);
let provider = &mut fixture.provider;
assert_eq!(
rows(provider, "wq"),
vec![
row("wq", "你", 10),
row("wqbb", "父子", 30),
row("wqb", "爷", 20),
row("wqi", "你", 10),
]
);
assert_eq!(
rows(provider, "w"),
vec![
row("w", "人", 20),
row("wqbb", "父子", 30),
row("wqb", "爷", 20),
row("wq", "你", 10),
row("wqi", "你", 10),
]
);
assert_eq!(rows(provider, "wqbb"), vec![row("wqbb", "父子", 30)]);
assert!(rows(provider, "wx").is_empty());
}
#[test]
fn rows_are_tagged_wubi_with_an_empty_canonical_key() {
let mut fixture = fixture(SESSION_FIXTURE);
let items = fixture.provider.query(&request("w"));
assert!(items.iter().all(|item| item.scheme == SchemeType::Wubi
&& item.source == CandidateSource::Database
&& item.canonical_pinyin.is_empty()));
}
#[test]
fn prefix_query_is_bounded() {
let mut sql = String::from("CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);");
for index in 0..55 {
sql.push_str(&format!(
"INSERT INTO wubi86 VALUES('b{}','合成{index:02}',{});",
char::from(b'a' + (index % 25) as u8),
1_000 - index
));
}
let mut fixture = fixture(&sql);
let words: Vec<String> = fixture
.provider
.query(&request("b"))
.into_iter()
.map(|item| item.word.into_string())
.collect();
let expected: Vec<String> = (0..QUERY_LIMIT)
.map(|index| format!("合成{index:02}"))
.collect();
assert_eq!(words, expected);
}
#[test]
fn last_letter_bound_covers_z_codes() {
let mut fixture = fixture(
"CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);\
INSERT INTO wubi86 VALUES('yz','甲',1);\
INSERT INTO wubi86 VALUES('yzz','乙',2);\
INSERT INTO wubi86 VALUES('z','丙',3);",
);
let mut upper = String::new();
prefix_upper_bound_into("az", &mut upper);
assert_eq!(upper, "a{");
assert_eq!(
rows(&mut fixture.provider, "yz"),
vec![row("yz", "甲", 1), row("yzz", "乙", 2)]
);
}
#[test]
fn a_z_in_the_code_matches_any_one_letter_when_the_wildcard_is_on() {
let mut fixture = fixture(
"CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);\
INSERT INTO wubi86 VALUES('wqiy','你',100);\
INSERT INTO wubi86 VALUES('wqiy','伱',10);\
INSERT INTO wubi86 VALUES('wqvb','你好',300);\
INSERT INTO wubi86 VALUES('abcd','合成',200);",
);
let provider = &mut fixture.provider;
assert_eq!(
wildcard_rows(provider, "wqzy"),
vec![row("wqiy", "你", 100), row("wqiy", "伱", 10)]
);
assert_eq!(
wildcard_rows(provider, "zqiy"),
vec![row("wqiy", "你", 100), row("wqiy", "伱", 10)]
);
assert!(wildcard_rows(provider, "zvbb").is_empty());
}
#[test]
fn a_wildcard_query_does_not_leak_glob_metacharacters() {
let mut fixture = fixture(
"CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);\
INSERT INTO wubi86 VALUES('a[b','甲',100);\
INSERT INTO wubi86 VALUES('a*b','乙',90);\
INSERT INTO wubi86 VALUES('axb','丙',80);",
);
let provider = &mut fixture.provider;
assert_eq!(wildcard_rows(provider, "a[b"), vec![row("a[b", "甲", 100)]);
assert_eq!(wildcard_rows(provider, "a*b"), vec![row("a*b", "乙", 90)]);
assert_eq!(
wildcard_rows(provider, "azb"),
vec![
row("a[b", "甲", 100),
row("a*b", "乙", 90),
row("axb", "丙", 80),
]
);
}
#[test]
fn the_wildcard_only_takes_the_pattern_path_when_the_code_holds_z() {
let mut fixture = fixture(SESSION_FIXTURE);
let provider = &mut fixture.provider;
let plain = request("wq");
assert!(!plain.wubi_z_wildcard);
assert_eq!(
rows(provider, "wq"),
vec![
row("wq", "你", 10),
row("wqbb", "父子", 30),
row("wqb", "爷", 20),
row("wqi", "你", 10),
]
);
}
#[test]
fn prefix_upper_bound_reuses_destination_capacity() {
let mut upper = String::with_capacity(16);
prefix_upper_bound_into("az", &mut upper);
assert_eq!(upper, "a{");
assert!(upper.capacity() >= 16);
prefix_upper_bound_into("a", &mut upper);
assert_eq!(upper, "b");
}
#[test]
fn null_rows_are_skipped_and_null_weight_reads_zero() {
let mut fixture = fixture(
"CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);\
INSERT INTO wubi86 VALUES('a',NULL,5);\
INSERT INTO wubi86 VALUES('a','工',NULL);",
);
assert_eq!(rows(&mut fixture.provider, "a"), vec![row("a", "工", 0)]);
}
#[test]
fn empty_prefix_query_does_not_reserve_candidate_page() {
let mut fixture = fixture(
"CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);\
INSERT INTO wubi86 VALUES('a',NULL,5);",
);
let candidates = fixture.provider.query(&request("a"));
assert!(candidates.is_empty());
assert_eq!(candidates.capacity(), 0);
}
#[test]
fn missing_table_or_invalid_request_is_empty() {
let mut other = fixture("CREATE TABLE other(x);");
assert!(rows(&mut other.provider, "a").is_empty());
let mut wubi = fixture(SESSION_FIXTURE);
assert!(rows(&mut wubi.provider, "").is_empty());
let mut quanpin = request("w");
quanpin.scheme = SchemeType::Quanpin;
assert!(wubi.provider.query(&quanpin).is_empty());
}
#[test]
fn missing_database_is_empty() {
let root = tempfile::tempdir().expect("temporary directory");
let mut provider = WubiProvider::new(&root.path().join("absent.db"));
assert!(provider.query(&request("a")).is_empty());
assert!(!root.path().join("absent.db").exists());
}
#[test]
fn reset_cache_sees_rows_written_since() {
let mut fixture = fixture(SESSION_FIXTURE);
assert_eq!(rows(&mut fixture.provider, "wqbb").len(), 1);
Connection::open(&fixture.provider.main_db)
.expect("writer")
.execute("UPDATE wubi86 SET weight=99 WHERE key='wqbb'", ())
.expect("update");
fixture.provider.reset_cache();
assert_eq!(
rows(&mut fixture.provider, "wqbb"),
vec![row("wqbb", "父子", 99)]
);
}
fn reverse_plan(connection: &Connection, profile: WubiProfileKind) -> String {
let sql = match profile {
WubiProfileKind::Wubi86 => REVERSE_QUERY_SQL_86,
WubiProfileKind::Wubi98 => REVERSE_QUERY_SQL_98,
};
let mut statement = connection
.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))
.unwrap();
let details: Vec<String> = statement
.query_map(["你好"], |row| row.get(3))
.unwrap()
.map(Result::unwrap)
.collect();
details.join("; ")
}
#[test]
fn reverse_indexes_are_created_once_for_the_tables_present() {
let both = fixture(
"CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER,UNIQUE(key,value));\
CREATE TABLE wubi98(key TEXT,value TEXT,weight INTEGER,UNIQUE(key,value));",
);
let connection = Connection::open(&both.provider.main_db).unwrap();
assert!(reverse_plan(&connection, WubiProfileKind::Wubi86).contains("SCAN wubi86"));
ensure_reverse_indexes(&connection).unwrap();
ensure_reverse_indexes(&connection).unwrap();
let indexes: i64 = connection
.query_row(
"SELECT count(*) FROM sqlite_master WHERE type='index' AND name IN ('idx_wubi86_value','idx_wubi98_value')",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(indexes, 2);
assert!(reverse_plan(&connection, WubiProfileKind::Wubi86)
.contains("USING INDEX idx_wubi86_value"));
assert!(reverse_plan(&connection, WubiProfileKind::Wubi98)
.contains("USING INDEX idx_wubi98_value"));
let other = fixture("CREATE TABLE english_words(word TEXT);");
let connection = Connection::open(&other.provider.main_db).unwrap();
ensure_reverse_indexes(&connection).unwrap();
let tables: i64 = connection
.query_row("SELECT count(*) FROM sqlite_master", [], |row| row.get(0))
.unwrap();
assert_eq!(tables, 1);
}
#[test]
fn the_reverse_index_does_not_change_which_code_is_shown() {
let sql = "CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER,UNIQUE(key,value));\
INSERT INTO wubi86 VALUES('a','工',900);\
INSERT INTO wubi86 VALUES('aaaa','工',10);\
INSERT INTO wubi86 VALUES('aaab','工',20);\
INSERT INTO wubi86 VALUES('wqvb','你好',300);\
INSERT INTO wubi86 VALUES('wqvc','你好',300);\
INSERT INTO wubi86 VALUES('wq','你',10);";
let words = ["工", "你好", "你", "缺"];
let mut plain = fixture(sql);
let before: Vec<Option<String>> = words
.iter()
.map(|word| plain.provider.reverse_code(word))
.collect();
assert_eq!(
before,
[
Some("aaab".to_owned()),
Some("wqvb".to_owned()),
Some("wq".to_owned()),
None
]
);
let mut indexed = fixture(sql);
ensure_reverse_indexes(&Connection::open(&indexed.provider.main_db).unwrap()).unwrap();
let after: Vec<Option<String>> = words
.iter()
.map(|word| indexed.provider.reverse_code(word))
.collect();
assert_eq!(after, before);
}
#[test]
fn reverse_cache_does_not_grow_without_bound() {
let mut fixture = fixture("CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);");
for index in 0..=1024 {
let word = format!("合成{index:04}");
assert_eq!(fixture.provider.reverse_code(&word), None);
}
assert!(fixture.provider.reverse_cache.len() <= 1024);
}
#[test]
fn cached_reverse_code_fills_existing_storage() {
let mut fixture = fixture(
"CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);\
INSERT INTO wubi86 VALUES('aaab','你',10);",
);
assert_eq!(fixture.provider.reverse_code("你").as_deref(), Some("aaab"));
let mut destination = String::with_capacity(8);
destination.push_str("old");
let pointer = destination.as_ptr();
let ((), allocations) = crate::ime::personal_rerank::allocations::count(|| {
assert!(fixture.provider.reverse_code_into("你", &mut destination));
});
assert_eq!(allocations, 0);
assert_eq!(destination, "aaab");
assert_eq!(destination.as_ptr(), pointer);
}
}