Skip to main content

imessage_database/tables/
diagnostic.rs

1/*!
2 Diagnostic result types for Messages database tables.
3*/
4
5use rusqlite::Connection;
6
7use crate::error::table::TableError;
8
9use std::collections::HashSet;
10
11pub(crate) fn count_query(db: &Connection, sql: &str) -> Result<usize, TableError> {
12    let count = db.prepare(sql)?.query_row([], |row| row.get::<_, i64>(0))?;
13
14    usize::try_from(count)
15        .map_err(|_| TableError::QueryError(rusqlite::Error::IntegralValueOutOfRange(0, count)))
16}
17
18pub(crate) fn table_exists(db: &Connection, table_name: &str) -> Result<bool, TableError> {
19    let exists = db.query_row(
20        "
21        SELECT EXISTS(
22            SELECT 1
23            FROM sqlite_master
24            WHERE type = 'table'
25              AND name = ?1
26        )
27        ",
28        [table_name],
29        |row| row.get::<_, i64>(0),
30    )?;
31
32    Ok(exists != 0)
33}
34
35pub(crate) fn column_exists(
36    db: &Connection,
37    table_name: &str,
38    column_name: &str,
39) -> Result<bool, TableError> {
40    let mut statement = db.prepare(&format!(
41        "PRAGMA table_info({})",
42        quote_sqlite_identifier(table_name)
43    ))?;
44    let columns = statement.query_map([], |row| row.get::<_, String>(1))?;
45
46    for column in columns {
47        if column? == column_name {
48            return Ok(true);
49        }
50    }
51
52    Ok(false)
53}
54
55/// Collect the declared column names of `table_name`, lowercased for
56/// case-insensitive membership checks.
57///
58/// A missing table yields an empty set rather than an error, so callers can
59/// treat "table absent" and "no recognized columns" uniformly.
60pub(crate) fn column_names(
61    db: &Connection,
62    table_name: &str,
63) -> Result<HashSet<String>, TableError> {
64    let mut statement = db.prepare(&format!(
65        "PRAGMA table_info({})",
66        quote_sqlite_identifier(table_name)
67    ))?;
68    let columns = statement.query_map([], |row| row.get::<_, String>(1))?;
69    let mut names = HashSet::new();
70    for name in columns {
71        names.insert(name?.to_ascii_lowercase());
72    }
73    Ok(names)
74}
75
76fn quote_sqlite_identifier(identifier: &str) -> String {
77    format!("\"{}\"", identifier.replace('"', "\"\""))
78}
79
80/// Diagnostic data for the `handle` table.
81#[derive(Debug)]
82pub struct HandleDiagnostic {
83    /// Total handles in the table.
84    pub total_handles: usize,
85    /// Distinct `person_centric_id` values, or `None` when the column is unavailable.
86    pub handles_with_multiple_ids: Option<usize>,
87    /// Handles deduplicated into canonical handles.
88    pub total_duplicated: usize,
89}
90
91/// Diagnostic data for the `message` table.
92#[derive(Debug)]
93pub struct MessageDiagnostic {
94    /// Total messages in the table.
95    pub total_messages: usize,
96    /// Messages not associated with any chat.
97    pub messages_without_chat: usize,
98    /// Messages that belong to more than one chat.
99    pub messages_in_multiple_chats: usize,
100    /// Recently deleted messages that are still recoverable.
101    pub recoverable_messages: Option<usize>,
102    /// Raw `date` value of the earliest message.
103    pub first_message_date: Option<i64>,
104    /// Raw `date` value of the most recent message.
105    pub last_message_date: Option<i64>,
106}
107
108/// Diagnostic data for the `attachment` table.
109#[derive(Debug)]
110pub struct AttachmentDiagnostic {
111    /// Total attachments in the table.
112    pub total_attachments: usize,
113    /// Sum of `total_bytes` for all attachment rows.
114    pub total_bytes_referenced: u64,
115    /// Total size of attachment files present on disk.
116    pub total_bytes_on_disk: u64,
117    /// Attachments with no path or no file at the resolved path.
118    pub missing_files: usize,
119    /// Attachments with no path in the table.
120    pub no_path_provided: usize,
121}
122
123impl AttachmentDiagnostic {
124    /// Attachments with a path but no file at that location.
125    #[must_use]
126    pub fn no_file_located(&self) -> usize {
127        self.missing_files.saturating_sub(self.no_path_provided)
128    }
129
130    /// Percentage of attachments that are missing.
131    #[must_use]
132    pub fn missing_percent(&self) -> Option<f64> {
133        if self.total_attachments > 0 {
134            Some(self.missing_files as f64 / self.total_attachments as f64 * 100.0)
135        } else {
136            None
137        }
138    }
139}
140
141/// Diagnostic data for chat-handle relationships.
142#[derive(Debug)]
143pub struct ChatHandleDiagnostic {
144    /// Total chats in the table.
145    pub total_chats: usize,
146    /// Chats deduplicated into canonical chats.
147    pub total_duplicated: usize,
148    /// Chats with messages but no associated handles.
149    pub chats_with_no_handles: usize,
150}
151
152#[cfg(test)]
153mod tests {
154    use rusqlite::Connection;
155
156    use super::{column_exists, table_exists};
157
158    #[test]
159    fn table_exists_detects_existing_and_missing_tables() {
160        let db = Connection::open_in_memory().unwrap();
161        db.execute("CREATE TABLE test_table (id INTEGER)", [])
162            .unwrap();
163
164        assert!(table_exists(&db, "test_table").unwrap());
165        assert!(!table_exists(&db, "missing_table").unwrap());
166    }
167
168    #[test]
169    fn column_exists_detects_existing_and_missing_columns() {
170        let db = Connection::open_in_memory().unwrap();
171        db.execute("CREATE TABLE test_table (id INTEGER, name TEXT)", [])
172            .unwrap();
173
174        assert!(column_exists(&db, "test_table", "name").unwrap());
175        assert!(!column_exists(&db, "test_table", "missing_column").unwrap());
176        assert!(!column_exists(&db, "missing_table", "name").unwrap());
177    }
178
179    #[test]
180    fn column_exists_quotes_table_identifiers() {
181        let db = Connection::open_in_memory().unwrap();
182        db.execute(
183            "CREATE TABLE \"quoted\"\"table\" (\"weird column\" TEXT)",
184            [],
185        )
186        .unwrap();
187
188        assert!(column_exists(&db, "quoted\"table", "weird column").unwrap());
189    }
190}