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 person_centric_ids: Option<usize>,
87    /// Number of groups with more than one handle row.
88    pub multi_handle_groups: usize,
89    /// Number of handle rows beyond the first row in each group.
90    pub merged_handles: usize,
91}
92
93/// Diagnostic data for the `message` table.
94#[derive(Debug)]
95pub struct MessageDiagnostic {
96    /// Total messages in the table.
97    pub total_messages: usize,
98    /// Messages not associated with any chat.
99    pub messages_without_chat: usize,
100    /// Messages that belong to more than one chat.
101    pub messages_in_multiple_chats: usize,
102    /// Recently deleted messages that are still recoverable.
103    pub recoverable_messages: Option<usize>,
104    /// Raw `date` value of the earliest message.
105    pub first_message_date: Option<i64>,
106    /// Raw `date` value of the most recent message.
107    pub last_message_date: Option<i64>,
108}
109
110/// Diagnostic data for the `attachment` table.
111#[derive(Debug)]
112pub struct AttachmentDiagnostic {
113    /// Total attachments in the table.
114    pub total_attachments: usize,
115    /// Sum of `total_bytes` for all attachment rows.
116    pub total_bytes_referenced: u64,
117    /// Total size of attachment files present on disk.
118    pub total_bytes_on_disk: u64,
119    /// Attachments with no path or no file at the resolved path.
120    pub missing_files: usize,
121    /// Attachments with no path in the table.
122    pub no_path_provided: usize,
123}
124
125impl AttachmentDiagnostic {
126    /// Attachments with a path but no file at that location.
127    #[must_use]
128    pub fn no_file_located(&self) -> usize {
129        self.missing_files.saturating_sub(self.no_path_provided)
130    }
131
132    /// Percentage of attachments that are missing.
133    #[must_use]
134    pub fn missing_percent(&self) -> Option<f64> {
135        if self.total_attachments > 0 {
136            Some(self.missing_files as f64 / self.total_attachments as f64 * 100.0)
137        } else {
138            None
139        }
140    }
141}
142
143/// Diagnostic data for chat-handle relationships.
144#[derive(Debug)]
145pub struct ChatHandleDiagnostic {
146    /// Total chats in the table.
147    pub total_chats: usize,
148    /// Chats deduplicated into canonical chats.
149    pub total_duplicated: usize,
150    /// Chats with messages but no associated handles.
151    pub chats_with_no_handles: usize,
152}
153
154#[cfg(test)]
155mod tests {
156    use rusqlite::Connection;
157
158    use super::{column_exists, table_exists};
159
160    #[test]
161    fn table_exists_detects_existing_and_missing_tables() {
162        let db = Connection::open_in_memory().unwrap();
163        db.execute("CREATE TABLE test_table (id INTEGER)", [])
164            .unwrap();
165
166        assert!(table_exists(&db, "test_table").unwrap());
167        assert!(!table_exists(&db, "missing_table").unwrap());
168    }
169
170    #[test]
171    fn column_exists_detects_existing_and_missing_columns() {
172        let db = Connection::open_in_memory().unwrap();
173        db.execute("CREATE TABLE test_table (id INTEGER, name TEXT)", [])
174            .unwrap();
175
176        assert!(column_exists(&db, "test_table", "name").unwrap());
177        assert!(!column_exists(&db, "test_table", "missing_column").unwrap());
178        assert!(!column_exists(&db, "missing_table", "name").unwrap());
179    }
180
181    #[test]
182    fn column_exists_quotes_table_identifiers() {
183        let db = Connection::open_in_memory().unwrap();
184        db.execute(
185            "CREATE TABLE \"quoted\"\"table\" (\"weird column\" TEXT)",
186            [],
187        )
188        .unwrap();
189
190        assert!(column_exists(&db, "quoted\"table", "weird column").unwrap());
191    }
192}