1use rusqlite::{Connection, OptionalExtension};
4
5use crate::diagnostics;
6use crate::error::{EngineError, Result};
7
8pub const FORMAT_VERSION: i32 = 1;
10
11pub const LEGACY_FORMAT_VERSION: i32 = 0;
13
14pub const METADATA_SCHEMA: &str =
16 "CREATE TABLE IF NOT EXISTS metadata(name TEXT PRIMARY KEY, value TEXT NOT NULL) WITHOUT ROWID;";
17
18pub const METADATA_FORMAT_VERSION: &str = crate::language_dictionary::METADATA_FORMAT_VERSION;
20
21pub const MAXIMUM_NUMBERED_SYLLABLES: usize = 7;
23pub const SHIPPED_INITIALS: &str = "abcdefghjklmnopqrstwxyz";
25pub const TABLE_PREFIX: &str = "tbl_";
26pub const OVERFLOW_BUCKET: &str = "others";
27
28pub fn quanpin_table(syllables: usize, initial: u8) -> Option<String> {
30 if syllables == 0 || !initial.is_ascii_lowercase() {
31 return None;
32 }
33 let bucket = match syllables {
34 1 => "1",
35 2 => "2",
36 3 => "3",
37 4 => "4",
38 5 => "5",
39 6 => "6",
40 7 => "7",
41 _ => OVERFLOW_BUCKET,
42 };
43 let mut table = String::with_capacity(TABLE_PREFIX.len() + bucket.len() + 2);
44 table.push_str(TABLE_PREFIX);
45 table.push_str(bucket);
46 table.push('_');
47 table.push(initial as char);
48 Some(table)
49}
50
51pub fn build_table_name(segments: &[String]) -> Option<String> {
53 let first = segments.first()?.as_bytes().first()?;
54 quanpin_table(segments.len(), *first)
55}
56
57pub fn check_format_version(connection: &Connection, file: &str) -> Result<i32> {
59 let unsupported = |detail: String| {
60 EngineError::failed(format!(
61 "{}{file} {detail}, this engine reads format_version {FORMAT_VERSION} or older",
62 diagnostics::DICTIONARY_FORMAT_UNSUPPORTED
63 ))
64 };
65 let has_metadata = connection.query_row(
66 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='metadata')",
67 [],
68 |row| row.get::<_, bool>(0),
69 )?;
70 if !has_metadata {
71 return Ok(LEGACY_FORMAT_VERSION);
72 }
73 let value = connection
74 .query_row(
75 "SELECT value FROM metadata WHERE name = ?1",
76 [METADATA_FORMAT_VERSION],
77 |row| row.get::<_, String>(0),
78 )
79 .optional()
80 .map_err(|error| {
81 if is_environmental(&error) {
82 EngineError::Sqlite(error)
83 } else {
84 unsupported(format!("has an unreadable metadata table ({error})"))
85 }
86 })?
87 .ok_or_else(|| unsupported("has a metadata table without format_version".to_owned()))?;
88 match value.parse::<i32>() {
89 Ok(version) if (LEGACY_FORMAT_VERSION..=FORMAT_VERSION).contains(&version) => Ok(version),
90 _ => Err(unsupported(format!("has format_version {value:?}"))),
91 }
92}
93
94fn is_environmental(error: &rusqlite::Error) -> bool {
96 matches!(error, rusqlite::Error::SqliteFailure(failure, _) if failure.code != rusqlite::ErrorCode::Unknown)
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn names_follow_the_contract() {
105 assert_eq!(quanpin_table(2, b'n').as_deref(), Some("tbl_2_n"));
106 assert_eq!(quanpin_table(7, b'a').as_deref(), Some("tbl_7_a"));
107 assert_eq!(quanpin_table(8, b'z').as_deref(), Some("tbl_others_z"));
108 assert_eq!(quanpin_table(2, b'n').unwrap().capacity(), "tbl_2_n".len());
109 assert_eq!(
110 quanpin_table(8, b'z').unwrap().capacity(),
111 "tbl_others_z".len()
112 );
113 assert_eq!(quanpin_table(0, b'a'), None);
114 assert_eq!(quanpin_table(1, b'A'), None);
115 assert_eq!(
116 build_table_name(&["ni".into(), "hao".into()]).as_deref(),
117 Some("tbl_2_n")
118 );
119 assert_eq!(build_table_name(&[]), None);
120 assert_eq!(build_table_name(&[String::new()]), None);
121 }
122
123 fn database(sql: &str) -> Connection {
124 let connection = Connection::open_in_memory().unwrap();
125 connection.execute_batch(sql).unwrap();
126 connection
127 }
128
129 #[test]
130 fn a_file_without_metadata_is_the_legacy_version() {
131 let connection =
132 database("CREATE TABLE tbl_1_a(key TEXT, jp TEXT, value TEXT, weight INTEGER);");
133 assert_eq!(
134 check_format_version(&connection, "msime-pinyin.db").unwrap(),
135 LEGACY_FORMAT_VERSION
136 );
137 }
138
139 #[test]
140 fn a_file_that_is_not_a_database_is_left_to_the_caller() {
141 let directory = tempfile::tempdir().unwrap();
142 let path = directory.path().join("msime-pinyin.db");
143 std::fs::write(&path, b"not a database, not even close to one").unwrap();
144 let connection = Connection::open(&path).unwrap();
145 assert!(matches!(
146 check_format_version(&connection, "msime-pinyin.db"),
147 Err(EngineError::Sqlite(_))
148 ));
149 }
150
151 #[test]
152 fn only_shape_errors_on_the_metadata_table_mean_an_unknown_format() {
153 let failure = |code| {
154 rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(code), Some(String::new()))
155 };
156 for code in [
157 rusqlite::ffi::SQLITE_BUSY,
158 rusqlite::ffi::SQLITE_LOCKED,
159 rusqlite::ffi::SQLITE_IOERR,
160 rusqlite::ffi::SQLITE_CORRUPT,
161 rusqlite::ffi::SQLITE_NOTADB,
162 ] {
163 assert!(is_environmental(&failure(code)), "{code}");
164 }
165 assert!(!is_environmental(&failure(rusqlite::ffi::SQLITE_ERROR)));
166 assert!(!is_environmental(&rusqlite::Error::InvalidColumnType(
167 0,
168 "value".to_owned(),
169 rusqlite::types::Type::Blob
170 )));
171 }
172
173 #[test]
174 fn the_current_and_older_versions_are_read() {
175 for version in [LEGACY_FORMAT_VERSION, FORMAT_VERSION] {
176 let connection = database(&format!(
177 "{METADATA_SCHEMA} INSERT INTO metadata VALUES('format_version', '{version}');"
178 ));
179 assert_eq!(
180 check_format_version(&connection, "msime-wubi.db").unwrap(),
181 version
182 );
183 }
184 }
185
186 #[test]
187 fn a_newer_or_malformed_version_is_refused_with_the_file_name() {
188 let newer = FORMAT_VERSION + 1;
189 for (sql, detail) in [
190 (
191 format!(
192 "{METADATA_SCHEMA} INSERT INTO metadata VALUES('format_version', '{newer}');"
193 ),
194 format!("has format_version \"{newer}\""),
195 ),
196 (
197 format!("{METADATA_SCHEMA} INSERT INTO metadata VALUES('format_version', 'one');"),
198 "has format_version \"one\"".to_owned(),
199 ),
200 (
201 format!("{METADATA_SCHEMA} INSERT INTO metadata VALUES('format_version', '-1');"),
202 "has format_version \"-1\"".to_owned(),
203 ),
204 (
205 format!("{METADATA_SCHEMA} INSERT INTO metadata VALUES('license', 'MIT');"),
206 "has a metadata table without format_version".to_owned(),
207 ),
208 (
209 "CREATE TABLE metadata(key TEXT, data TEXT);".to_owned(),
210 "has an unreadable metadata table".to_owned(),
211 ),
212 ] {
213 let connection = database(&sql);
214 let error = check_format_version(&connection, "msime-others.db")
215 .unwrap_err()
216 .to_string();
217 assert!(
218 error.starts_with(diagnostics::DICTIONARY_FORMAT_UNSUPPORTED),
219 "{error}"
220 );
221 assert!(error.contains("msime-others.db"), "{error}");
222 assert!(error.contains(&detail), "{error}");
223 assert!(
224 error.ends_with(&format!(
225 "this engine reads format_version {FORMAT_VERSION} or older"
226 )),
227 "{error}"
228 );
229 }
230 }
231}