1use std::path::Path;
6
7use rusqlite::types::{ToSqlOutput, ValueRef};
8use rusqlite::{params, Connection, OpenFlags, Row, ToSql};
9
10use crate::dictionary::english::ensure_english_schema;
11use crate::format;
12use crate::user_dictionary::journal::{open_database, BUSY_TIMEOUT_MS};
13
14#[derive(Debug, Clone, PartialEq, Eq, Default)]
15pub struct ReplayResult {
16 pub applied: i32,
17 pub skipped: i32,
18 pub failed: i32,
19 pub error: String,
21}
22
23pub fn replay(user_db: &Path, main_db: &Path, english_db: &Path) -> ReplayResult {
25 replay_with(user_db, Some(main_db), english_db)
26}
27
28pub fn replay_english(user_db: &Path, english_db: &Path) -> ReplayResult {
30 replay_with(user_db, None, english_db)
31}
32
33pub(super) fn open_without_main_dictionary() -> rusqlite::Result<Connection> {
35 let connection = Connection::open_in_memory_with_flags(
36 OpenFlags::SQLITE_OPEN_READ_WRITE
37 | OpenFlags::SQLITE_OPEN_CREATE
38 | OpenFlags::SQLITE_OPEN_FULL_MUTEX,
39 )?;
40 connection.busy_timeout(std::time::Duration::from_millis(BUSY_TIMEOUT_MS))?;
41 Ok(connection)
42}
43
44fn replay_with(user_db: &Path, main_db: Option<&Path>, english_db: &Path) -> ReplayResult {
45 let mut result = ReplayResult::default();
46 if !user_db.exists() {
48 return result;
49 }
50 let fail = |mut result: ReplayResult, error: &str| {
51 result.error = error.to_owned();
52 result
53 };
54 let Ok(journal) = open_database(user_db, OpenFlags::SQLITE_OPEN_READ_ONLY) else {
55 return fail(result, "cannot open user dictionary database");
56 };
57 if ensure_english_schema(english_db).is_err() {
58 return fail(result, "cannot migrate English dictionary database");
59 }
60 let main = match main_db {
61 Some(main_db) => open_database(main_db, OpenFlags::SQLITE_OPEN_READ_WRITE).ok(),
62 None => open_without_main_dictionary().ok(),
63 };
64 let Some(main) = main else {
65 return fail(result, "cannot open target dictionary database");
66 };
67 if attach(&main, english_db, "replay_english").is_err() {
68 return fail(result, "cannot attach English dictionary database");
69 }
70 let wubi_tables = main_db.is_some()
72 && main
73 .query_row(
74 "SELECT EXISTS(SELECT 1 FROM main.sqlite_master WHERE type='table' AND name IN ('wubi86','wubi98'))",
75 [],
76 |row| row.get::<_, bool>(0),
77 )
78 .unwrap_or(true);
79 let Ok(mut rows) = journal.prepare(
80 "SELECT dictionary,key,value,operation,weight,display FROM user_dictionary_operations ORDER BY updated_at,rowid",
81 ) else {
82 return fail(result, "invalid user dictionary database");
83 };
84 if main.execute_batch("BEGIN IMMEDIATE").is_err() {
86 return fail(result, "cannot start replay transaction");
87 }
88 let read_complete = (|| -> rusqlite::Result<()> {
89 let mut cursor = rows.query([])?;
90 while let Some(row) = cursor.next()? {
91 let operation = JournalRow::read(row)?;
92 if main_db.is_none() && operation.kind != b"english" {
93 result.skipped += 1;
94 continue;
95 }
96 if !wubi_tables && (operation.kind == b"wubi" || operation.kind == b"wubi98") {
97 result.skipped += 1;
98 continue;
99 }
100 if operation.kind == b"pinyin"
101 && operation.operation == b"upsert"
102 && operation.weight < 1
103 {
104 result.skipped += 1;
106 continue;
107 }
108 if matches!(operation.apply(&main), Ok(true)) {
110 result.applied += 1;
111 } else {
112 result.failed += 1;
113 }
114 }
115 Ok(())
116 })();
117 if read_complete.is_err() {
118 result.error = if main.execute_batch("ROLLBACK").is_ok() {
119 "cannot read the complete user dictionary journal; changes were rolled back"
120 } else {
121 "cannot read the complete user dictionary journal and rollback also failed"
122 }
123 .to_owned();
124 return result;
125 }
126 if result.failed == 0 {
127 if main.execute_batch("COMMIT").is_err() {
128 result.error = if main.execute_batch("ROLLBACK").is_ok() {
129 "cannot commit replay transaction; changes were rolled back"
130 } else {
131 "cannot commit or roll back replay transaction"
132 }
133 .to_owned();
134 }
135 } else {
136 result.error = if main.execute_batch("ROLLBACK").is_ok() {
137 "one or more operations failed; changes were rolled back"
138 } else {
139 "one or more operations failed and rollback also failed"
140 }
141 .to_owned();
142 }
143 result
144}
145
146struct JournalRow {
148 kind: Vec<u8>,
149 key: Vec<u8>,
150 value: Vec<u8>,
151 operation: Vec<u8>,
152 weight: i64,
153 display: Vec<u8>,
154}
155
156const MAX_REPLAY_KIND_BYTES: usize = 32;
157const MAX_REPLAY_KEY_BYTES: usize = 512;
158const MAX_REPLAY_VALUE_BYTES: usize = 4 * 1024;
159const MAX_REPLAY_OPERATION_BYTES: usize = 16;
160const MAX_REPLAY_DISPLAY_BYTES: usize = 4 * 1024;
161
162impl JournalRow {
163 fn read(row: &Row<'_>) -> rusqlite::Result<Self> {
164 let text = |index: usize, maximum: usize| -> rusqlite::Result<Vec<u8>> {
165 let bytes = row.get_ref(index)?.as_bytes()?;
166 if bytes.len() > maximum {
167 return Err(rusqlite::Error::InvalidQuery);
168 }
169 Ok(bytes.to_vec())
170 };
171 Ok(Self {
172 kind: text(0, MAX_REPLAY_KIND_BYTES)?,
173 key: text(1, MAX_REPLAY_KEY_BYTES)?,
174 value: text(2, MAX_REPLAY_VALUE_BYTES)?,
175 operation: text(3, MAX_REPLAY_OPERATION_BYTES)?,
176 weight: row.get(4)?,
177 display: text(5, MAX_REPLAY_DISPLAY_BYTES)?,
178 })
179 }
180
181 fn apply(&self, main: &Connection) -> rusqlite::Result<bool> {
183 let delete = self.operation == b"delete";
184 match self.kind.as_slice() {
185 b"pinyin" => apply_pinyin(main, &self.key, &self.value, delete, self.weight),
186 b"wubi" => apply_simple(main, "wubi86", &self.key, &self.value, delete, self.weight),
187 b"wubi98" => apply_simple(main, "wubi98", &self.key, &self.value, delete, self.weight),
188 b"quick" => apply_simple(
189 main,
190 "quick_parases",
191 &self.key,
192 &self.value,
193 delete,
194 self.weight,
195 ),
196 b"english" => apply_english(
197 main,
198 &self.key,
199 &self.value,
200 delete,
201 self.weight,
202 &self.display,
203 ),
204 _ => Ok(false),
205 }
206 }
207}
208
209struct Text<'a>(&'a [u8]);
211
212impl ToSql for Text<'_> {
213 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
214 Ok(ToSqlOutput::Borrowed(ValueRef::Text(self.0)))
215 }
216}
217
218pub(super) fn attach(connection: &Connection, path: &Path, schema: &str) -> rusqlite::Result<()> {
222 let path = crate::paths::sqlite_path_no_follow(path)
223 .map_err(|_| rusqlite::Error::InvalidPath(path.to_owned()))?;
224 let name = path
225 .to_str()
226 .ok_or_else(|| rusqlite::Error::InvalidPath(path.clone()))?;
227 connection.execute(&format!("ATTACH DATABASE ?1 AS {schema}"), [name])?;
228 Ok(())
229}
230
231pub(super) fn apply_pinyin(
233 main: &Connection,
234 key: &[u8],
235 value: &[u8],
236 delete: bool,
237 weight: i64,
238) -> rusqlite::Result<bool> {
239 let segments: Vec<&[u8]> = key.split(|&byte| byte == b'\'').collect();
241 if segments.iter().any(|segment| segment.is_empty()) {
242 return Ok(false);
243 }
244 let Some(table) = format::quanpin_table(segments.len(), segments[0][0]) else {
246 return Ok(false);
247 };
248 let (key, value) = (Text(key), Text(value));
249 if delete {
250 main.prepare_cached(&format!(
251 "DELETE FROM \"{table}\" WHERE key=?1 AND value=?2"
252 ))?
253 .execute(params![key, value])?;
254 return Ok(true);
255 }
256 let jianpin: String = segments
258 .iter()
259 .filter_map(|segment| String::from_utf8_lossy(segment).chars().next())
260 .collect();
261 let changed = main
262 .prepare_cached(&format!(
263 "UPDATE \"{table}\" SET jp=?1,weight=?2 WHERE key=?3 AND value=?4"
264 ))?
265 .execute(params![jianpin, weight, key, value])?;
266 if changed > 0 {
267 return Ok(true);
268 }
269 main.prepare_cached(&format!(
270 "INSERT INTO \"{table}\"(key,jp,value,weight) VALUES(?1,?2,?3,?4)"
271 ))?
272 .execute(params![key, jianpin, value, weight])?;
273 Ok(true)
274}
275
276pub(super) fn apply_simple(
278 main: &Connection,
279 table: &str,
280 key: &[u8],
281 value: &[u8],
282 delete: bool,
283 weight: i64,
284) -> rusqlite::Result<bool> {
285 let (key, value) = (Text(key), Text(value));
286 if delete {
287 main.prepare_cached(&format!(
288 "DELETE FROM \"{table}\" WHERE \"key\"=?1 AND \"value\"=?2"
289 ))?
290 .execute(params![key, value])?;
291 return Ok(true);
292 }
293 let changed = main
294 .prepare_cached(&format!(
295 "UPDATE \"{table}\" SET weight=?1 WHERE \"key\"=?2 AND \"value\"=?3"
296 ))?
297 .execute(params![weight, key, value])?;
298 if changed > 0 {
299 return Ok(true);
300 }
301 main.prepare_cached(&format!(
302 "INSERT INTO \"{table}\"(\"key\",\"value\",weight) VALUES(?1,?2,?3)"
303 ))?
304 .execute(params![key, value, weight])?;
305 Ok(true)
306}
307
308pub(super) fn apply_english(
310 main: &Connection,
311 key: &[u8],
312 value: &[u8],
313 delete: bool,
314 weight: i64,
315 display: &[u8],
316) -> rusqlite::Result<bool> {
317 let display = Text(if display.is_empty() { value } else { display });
318 let key = Text(key);
319 if delete {
320 main.prepare_cached(
321 "DELETE FROM replay_english.english_words WHERE word=?1 AND display=?2",
322 )?
323 .execute(params![key, display])?;
324 return Ok(true);
325 }
326 main.prepare_cached(
327 "INSERT INTO replay_english.english_words(word,display,weight) VALUES(?1,?2,?3) ON CONFLICT(word,display) DO UPDATE SET weight=excluded.weight",
328 )?
329 .execute(params![key, display, weight])?;
330 Ok(true)
331}
332
333#[cfg(test)]
334pub(super) mod tests {
335 use super::*;
336 use std::path::PathBuf;
337
338 pub(crate) const OPERATIONS_DDL: &str = "CREATE TABLE IF NOT EXISTS user_dictionary_operations(dictionary TEXT NOT NULL,key TEXT NOT NULL,value TEXT NOT NULL,operation TEXT NOT NULL CHECK(operation IN ('upsert','delete')),weight INTEGER NOT NULL DEFAULT 0,display TEXT NOT NULL DEFAULT '',user_inserted INTEGER NOT NULL DEFAULT 0,updated_at INTEGER NOT NULL DEFAULT(unixepoch()),PRIMARY KEY(dictionary,key,value));";
340
341 pub(crate) fn sql(path: &Path, statements: &str) {
342 Connection::open(path)
343 .unwrap()
344 .execute_batch(statements)
345 .unwrap();
346 }
347
348 pub(crate) fn weight(path: &Path, query: &str) -> Option<i64> {
349 use rusqlite::OptionalExtension;
350 Connection::open(path)
351 .unwrap()
352 .query_row(query, [], |row| row.get(0))
353 .optional()
354 .unwrap()
355 }
356
357 struct Fixture {
358 _root: tempfile::TempDir,
359 journal: PathBuf,
360 main: PathBuf,
361 english: PathBuf,
362 }
363
364 fn fixture() -> Fixture {
365 let root = tempfile::tempdir().unwrap();
366 let journal = root.path().join("msime_user.db");
367 let main = root.path().join("msime-pinyin.db");
368 let english = root.path().join("msime-english.db");
369 sql(
370 &main,
371 "CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
372 INSERT INTO tbl_2_n VALUES('ni''hao','nh','你好',100),('ni''hao','xx','您好',100);
373 CREATE TABLE tbl_7_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
374 CREATE TABLE tbl_others_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
375 CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);
376 CREATE TABLE wubi98(key TEXT,value TEXT,weight INTEGER);
377 CREATE TABLE quick_parases(key TEXT,value TEXT,weight INTEGER);",
378 );
379 sql(&journal, OPERATIONS_DDL);
380 Fixture {
381 _root: root,
382 journal,
383 main,
384 english,
385 }
386 }
387
388 fn journal_row(fixture: &Fixture, row: &str) {
389 sql(
390 &fixture.journal,
391 &format!("INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,display,updated_at) VALUES{row};"),
392 );
393 }
394
395 #[test]
396 fn wubi_and_wubi98_rows_land_in_their_own_tables() {
397 let fixture = fixture();
398 journal_row(&fixture, "('wubi','kl','号','upsert',11,'',1)");
399 journal_row(&fixture, "('wubi98','kg','号','upsert',12,'',2)");
400 replay(&fixture.journal, &fixture.main, &fixture.english);
401 assert_eq!(
402 weight(&fixture.main, "SELECT weight FROM wubi86 WHERE key='kl'"),
403 Some(11)
404 );
405 assert_eq!(
406 weight(&fixture.main, "SELECT weight FROM wubi98 WHERE key='kg'"),
407 Some(12)
408 );
409 assert_eq!(
410 weight(&fixture.main, "SELECT weight FROM wubi86 WHERE key='kg'"),
411 None
412 );
413 }
414
415 #[test]
417 fn wubi_rows_are_skipped_when_the_generation_has_no_wubi_table() {
418 let fixture = fixture();
419 sql(&fixture.main, "DROP TABLE wubi86; DROP TABLE wubi98;");
420 journal_row(&fixture, "('wubi','kl','号','upsert',11,'',1)");
421 journal_row(&fixture, "('wubi98','kg','号','upsert',12,'',2)");
422 journal_row(&fixture, "('pinyin','ni''hao','您好','upsert',500,'',3)");
423 let result = replay(&fixture.journal, &fixture.main, &fixture.english);
424 assert_eq!(
425 (
426 result.applied,
427 result.skipped,
428 result.failed,
429 result.error.as_str()
430 ),
431 (1, 2, 0, "")
432 );
433 assert_eq!(
434 weight(
435 &fixture.main,
436 "SELECT weight FROM tbl_2_n WHERE value='您好'"
437 ),
438 Some(500)
439 );
440
441 let fixture = self::fixture();
442 sql(&fixture.main, "DROP TABLE wubi98;");
443 journal_row(&fixture, "('wubi','kl','号','upsert',11,'',1)");
444 journal_row(&fixture, "('wubi98','kg','号','upsert',12,'',2)");
445 let result = replay(&fixture.journal, &fixture.main, &fixture.english);
446 assert_eq!((result.applied, result.failed), (1, 1));
447 assert_eq!(
448 weight(&fixture.main, "SELECT count(*) FROM wubi86"),
449 Some(0)
450 );
451 }
452
453 #[test]
454 fn a_missing_journal_is_an_empty_success_and_is_not_created() {
455 let fixture = fixture();
456 std::fs::remove_file(&fixture.journal).unwrap();
457 let result = replay(&fixture.journal, &fixture.main, &fixture.english);
458 assert_eq!(result, ReplayResult::default());
459 assert!(!fixture.journal.exists());
460 }
461
462 #[test]
463 fn applies_every_kind_in_journal_order() {
464 let fixture = fixture();
465 journal_row(&fixture, "('pinyin','ni''hao','您好','upsert',500,'',2)");
466 journal_row(&fixture, "('pinyin','ni''hao','拟好','upsert',80,'',3)");
467 journal_row(
468 &fixture,
469 "('pinyin','ni''hao''ni''hao''ni''hao''ni','你好你好你好你','upsert',7,'',1)",
470 );
471 journal_row(
472 &fixture,
473 "('pinyin','ni''ni''ni''ni''ni''ni''ni''ni','你你你你你你你你','upsert',8,'',1)",
474 );
475 journal_row(&fixture, "('wubi','wq','你','upsert',9,'',1)");
476 journal_row(&fixture, "('quick','dh','电话','upsert',10,'',1)");
477 journal_row(
478 &fixture,
479 "('english','hello','Hello','upsert',300,'Hello',1)",
480 );
481 journal_row(&fixture, "('english','world','World','upsert',301,'',1)");
482 journal_row(&fixture, "('pinyin','ni''hao','你好','delete',0,'',4)");
483 journal_row(&fixture, "('pinyin','ni''hao','泥好','upsert',0,'',5)");
484
485 let result = replay(&fixture.journal, &fixture.main, &fixture.english);
486 assert_eq!(
487 result,
488 ReplayResult {
489 applied: 9,
490 skipped: 1,
491 failed: 0,
492 error: String::new()
493 }
494 );
495 assert_eq!(
496 weight(
497 &fixture.main,
498 "SELECT count(*) FROM tbl_2_n WHERE value='你好'"
499 ),
500 Some(0)
501 );
502 assert_eq!(
504 weight(
505 &fixture.main,
506 "SELECT weight FROM tbl_2_n WHERE value='您好' AND jp='nh'"
507 ),
508 Some(500)
509 );
510 assert_eq!(
511 weight(
512 &fixture.main,
513 "SELECT weight FROM tbl_2_n WHERE value='拟好' AND jp='nh'"
514 ),
515 Some(80)
516 );
517 assert_eq!(
518 weight(
519 &fixture.main,
520 "SELECT count(*) FROM tbl_2_n WHERE value='泥好'"
521 ),
522 Some(0)
523 );
524 assert_eq!(
525 weight(
526 &fixture.main,
527 "SELECT weight FROM tbl_7_n WHERE jp='nhnhnhn'"
528 ),
529 Some(7)
530 );
531 assert_eq!(
532 weight(
533 &fixture.main,
534 "SELECT weight FROM tbl_others_n WHERE jp='nnnnnnnn'"
535 ),
536 Some(8)
537 );
538 assert_eq!(
539 weight(&fixture.main, "SELECT weight FROM wubi86 WHERE key='wq'"),
540 Some(9)
541 );
542 assert_eq!(
543 weight(
544 &fixture.main,
545 "SELECT weight FROM quick_parases WHERE key='dh'"
546 ),
547 Some(10)
548 );
549 assert_eq!(
550 weight(
551 &fixture.english,
552 "SELECT weight FROM english_words WHERE word='hello' AND display='Hello'"
553 ),
554 Some(300)
555 );
556 assert_eq!(
557 weight(
558 &fixture.english,
559 "SELECT weight FROM english_words WHERE word='world' AND display='World'"
560 ),
561 Some(301)
562 );
563
564 let again = replay(&fixture.journal, &fixture.main, &fixture.english);
566 assert!(again.error.is_empty() && again.failed == 0);
567 assert_eq!(
568 weight(&fixture.main, "SELECT count(*) FROM tbl_2_n"),
569 Some(2)
570 );
571 }
572
573 #[test]
574 fn a_failed_row_rolls_the_whole_replay_back() {
575 let fixture = fixture();
576 journal_row(&fixture, "('pinyin','ni''hao','您好','upsert',500,'',1)");
577 journal_row(&fixture, "('pinyin','@','无效词','upsert',10,'',2)");
579 journal_row(&fixture, "('pinyin','ni''''hao','无效词','upsert',10,'',3)");
580 journal_row(&fixture, "('japanese','ni','无效词','upsert',10,'',4)");
581 let result = replay(&fixture.journal, &fixture.main, &fixture.english);
582 assert_eq!(result.applied, 1);
583 assert_eq!(result.failed, 3);
584 assert_eq!(
585 result.error,
586 "one or more operations failed; changes were rolled back"
587 );
588 assert_eq!(
589 weight(
590 &fixture.main,
591 "SELECT weight FROM tbl_2_n WHERE value='您好'"
592 ),
593 Some(100)
594 );
595 }
596
597 #[test]
599 fn a_journal_row_that_is_not_utf8_replays_byte_for_byte() {
600 let fixture = fixture();
601 journal_row(&fixture, "('pinyin','ni''hao','您好','upsert',500,'',1)");
602 journal_row(
603 &fixture,
604 "('pinyin','ni''hao',CAST(x'ff' AS TEXT),'upsert',10,'',2)",
605 );
606 journal_row(
607 &fixture,
608 "('quick',CAST(x'6bff' AS TEXT),'phrase','upsert',5,'',3)",
609 );
610 journal_row(
611 &fixture,
612 "('english','hello','hello','upsert',7,CAST(x'68ff' AS TEXT),4)",
613 );
614 let result = replay(&fixture.journal, &fixture.main, &fixture.english);
615 assert_eq!(result.error, "");
616 assert_eq!(result.applied, 4);
617 assert_eq!(
618 weight(
619 &fixture.main,
620 "SELECT weight FROM tbl_2_n WHERE value='您好'"
621 ),
622 Some(500)
623 );
624 assert_eq!(
625 weight(
626 &fixture.main,
627 "SELECT weight FROM tbl_2_n WHERE key='ni''hao' AND hex(value)='FF' AND jp='nh' AND typeof(value)='text'"
628 ),
629 Some(10)
630 );
631 assert_eq!(
632 weight(
633 &fixture.main,
634 "SELECT weight FROM quick_parases WHERE hex(key)='6BFF' AND value='phrase' AND typeof(key)='text'"
635 ),
636 Some(5)
637 );
638 assert_eq!(
639 weight(
640 &fixture.english,
641 "SELECT weight FROM english_words WHERE word='hello' AND hex(display)='68FF'"
642 ),
643 Some(7)
644 );
645 }
646
647 #[test]
648 fn an_oversized_journal_text_rolls_back_before_copying_it() {
649 let fixture = fixture();
650 let huge = "x".repeat(4097);
651 Connection::open(&fixture.journal)
652 .unwrap()
653 .execute(
654 "INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,display,updated_at) VALUES('pinyin','ni''hao',?1,'upsert',10,'',1)",
655 [&huge],
656 )
657 .unwrap();
658
659 let result = replay(&fixture.journal, &fixture.main, &fixture.english);
660 assert_eq!(result.applied, 0);
661 assert!(!result.error.is_empty());
662 assert_eq!(
663 weight(&fixture.main, "SELECT count(*) FROM tbl_2_n"),
664 Some(2)
665 );
666 }
667
668 #[test]
670 fn a_pinyin_key_that_is_not_utf8_and_names_no_table_rolls_back() {
671 let fixture = fixture();
672 journal_row(&fixture, "('pinyin','ni''hao','您好','upsert',500,'',1)");
673 journal_row(
674 &fixture,
675 "('pinyin',CAST(x'ff' AS TEXT),'你','upsert',10,'',2)",
676 );
677 let result = replay(&fixture.journal, &fixture.main, &fixture.english);
678 assert_eq!(result.failed, 1);
679 assert_eq!(
680 result.error,
681 "one or more operations failed; changes were rolled back"
682 );
683 assert_eq!(
684 weight(
685 &fixture.main,
686 "SELECT weight FROM tbl_2_n WHERE value='您好'"
687 ),
688 Some(100)
689 );
690 }
691
692 #[test]
694 fn open_and_cursor_failures_are_reported() {
695 let fixture = fixture();
696 let unreadable = fixture.journal.with_file_name("msime_user.unreadable.db");
697 sql(
698 &unreadable,
699 "CREATE VIEW user_dictionary_operations AS SELECT 'pinyin' AS dictionary,'ni' AS key,'你' AS value,'upsert' AS operation,100 AS weight,'' AS display,1 AS updated_at UNION ALL SELECT 'pinyin','bad',value,'upsert',1,'',2 FROM json_each('not-json')",
700 );
701 assert!(!replay(&unreadable, &fixture.main, &fixture.english)
702 .error
703 .is_empty());
704
705 let not_a_database = fixture.journal.with_file_name("garbage.db");
706 std::fs::write(¬_a_database, b"synthetic invalid database").unwrap();
707 assert_eq!(
708 replay(¬_a_database, &fixture.main, &fixture.english).error,
709 "invalid user dictionary database"
710 );
711
712 let missing_main = fixture.main.with_file_name("missing.db");
713 assert_eq!(
714 replay(&fixture.journal, &missing_main, &fixture.english).error,
715 "cannot open target dictionary database"
716 );
717 assert!(!missing_main.exists());
718 }
719
720 #[test]
721 fn a_locked_target_is_reported_after_the_busy_timeout() {
722 let fixture = fixture();
723 let locked = Connection::open(&fixture.main).unwrap();
724 locked.execute_batch("BEGIN EXCLUSIVE").unwrap();
725 let result = replay(&fixture.journal, &fixture.main, &fixture.english);
726 locked.execute_batch("ROLLBACK").unwrap();
727 assert!(!result.error.is_empty());
729 assert_eq!(result.applied, 0);
730 }
731
732 #[test]
734 fn without_the_main_dictionary_only_english_rows_are_replayed() {
735 let fixture = fixture();
736 std::fs::remove_file(&fixture.main).unwrap();
737 journal_row(&fixture, "('pinyin','ni''hao','您好','upsert',500,'',1)");
738 journal_row(&fixture, "('wubi','wq','你','upsert',9,'',2)");
739 journal_row(&fixture, "('quick','dh','电话','upsert',10,'',3)");
740 journal_row(
741 &fixture,
742 "('english','hello','Hello','upsert',300,'Hello',4)",
743 );
744 journal_row(&fixture, "('english','world','World','upsert',301,'',5)");
745
746 let result = replay_english(&fixture.journal, &fixture.english);
747 assert_eq!(
748 result,
749 ReplayResult {
750 applied: 2,
751 skipped: 3,
752 failed: 0,
753 error: String::new(),
754 }
755 );
756 assert!(!fixture.main.exists());
757 assert_eq!(
758 weight(
759 &fixture.english,
760 "SELECT weight FROM english_words WHERE word='hello' AND display='Hello'"
761 ),
762 Some(300)
763 );
764 assert_eq!(
765 weight(
766 &fixture.english,
767 "SELECT weight FROM english_words WHERE word='world' AND display='World'"
768 ),
769 Some(301)
770 );
771 }
772}