Skip to main content

imessage_database/tables/
capabilities.rs

1/*!
2 Capability detection for Messages databases.
3
4Schemas vary independently of OS release: even on a single macOS build, two
5machines can carry different `message` columns. [`Capabilities::determine`]
6probes the live schema once; query composition then includes exactly what the
7database supports.
8*/
9
10use std::path::PathBuf;
11
12use rusqlite::Connection;
13
14use crate::{
15    error::table::{TableConnectError, TableError},
16    tables::{
17        attachment::ATTACHMENT_COLUMNS,
18        diagnostic::{column_names, table_exists},
19        messages::columns::MESSAGE_COLUMNS,
20        table::{ATTACHMENT, MESSAGE, RECENTLY_DELETED},
21    },
22};
23
24/// `message` columns that exist only to feed body parsing during row decoding.
25const BODY_INPUT_COLUMNS: [&str; 3] = ["attributedbody", "message_summary_info", "payload_data"];
26
27/// Features of a Messages database schema, probed from the live database.
28///
29/// Each flag guards the SQL constructs that reference its column or table:
30/// when a flag is `false`, composed queries substitute a neutral placeholder
31/// so deserialization behaves uniformly across schemas.
32#[derive(Clone, Debug)]
33pub struct Capabilities {
34    /// Recognized `message` columns this schema declares, in canonical
35    /// ([`MESSAGE_COLUMNS`]) order.
36    message_columns: Vec<&'static str>,
37    /// Recognized `attachment` columns this schema declares, in canonical
38    /// ([`ATTACHMENT_COLUMNS`]) order.
39    attachment_columns: Vec<&'static str>,
40    /// Text values need a blob cast to preserve their UTF-16 storage bytes.
41    pub(crate) utf16_text: bool,
42    /// Both `filter_action` and `filter_sub_action` exist on `message`.
43    ///
44    /// The pair is one feature: queries project either both real columns or
45    /// two `NULL` placeholders. A schema carrying only one of the pair reads
46    /// `None` for both, keeping [`FilterAction`](crate::tables::messages::models::FilterAction)
47    /// parsing off partial data.
48    pub filter_actions: bool,
49    /// The `chat_recoverable_message_join` table exists, so recently deleted
50    /// messages can be identified and filtered.
51    pub recoverable_messages: bool,
52    /// `thread_originator_guid` exists on `message`, so replies can be counted
53    /// and grouped.
54    pub replies: bool,
55    /// `associated_message_guid` exists on `message`, so tapbacks and poll
56    /// votes can be resolved.
57    pub associated_message_guids: bool,
58}
59
60impl Capabilities {
61    /// Probe the schema of the supplied database.
62    pub fn determine(db: &Connection) -> Result<Self, TableError> {
63        if !table_exists(db, MESSAGE)? {
64            return Err(TableError::CannotConnect(
65                TableConnectError::NotMessagesDatabase(PathBuf::from(
66                    db.path().unwrap_or_default(),
67                )),
68            ));
69        }
70        let message_names = column_names(db, MESSAGE)?;
71        let attachment_names = column_names(db, ATTACHMENT)?;
72
73        Ok(Self {
74            message_columns: MESSAGE_COLUMNS
75                .into_iter()
76                .filter(|column| message_names.contains(*column))
77                .collect(),
78            attachment_columns: ATTACHMENT_COLUMNS
79                .into_iter()
80                .filter(|column| attachment_names.contains(*column))
81                .collect(),
82            utf16_text: db
83                .query_row("PRAGMA encoding", [], |row| row.get::<_, String>(0))?
84                .starts_with("UTF-16"),
85            filter_actions: message_names.contains("filter_action")
86                && message_names.contains("filter_sub_action"),
87            recoverable_messages: table_exists(db, RECENTLY_DELETED)?,
88            replies: message_names.contains("thread_originator_guid"),
89            associated_message_guids: message_names.contains("associated_message_guid"),
90        })
91    }
92
93    /// Return a copy with the row-enrichment features disabled:
94    /// `recoverable_messages`, `replies`, and `filter_actions`.
95    ///
96    /// Narrow scans that only need the base projection (e.g. the tapback
97    /// cache) use this to skip correlated subqueries and joins those
98    /// features would add.
99    #[must_use]
100    pub fn without_derived_features(&self) -> Self {
101        Self {
102            recoverable_messages: false,
103            replies: false,
104            filter_actions: false,
105            ..self.clone()
106        }
107    }
108
109    /// Return a copy whose message projection omits the body-parser inputs:
110    /// `attributedBody`, `message_summary_info`, and `payload_data`.
111    ///
112    /// Row decoding parses a body only from projected bytes, so a scan that
113    /// never renders its rows uses this to skip the blob reads and the
114    /// typedstream decode for every row.
115    #[must_use]
116    pub fn without_body_inputs(&self) -> Self {
117        Self {
118            message_columns: self
119                .message_columns
120                .iter()
121                .copied()
122                .filter(|column| !BODY_INPUT_COLUMNS.contains(column))
123                .collect(),
124            ..self.clone()
125        }
126    }
127
128    /// Recognized `message` columns this schema declares, in canonical order.
129    pub fn message_columns(&self) -> &[&'static str] {
130        &self.message_columns
131    }
132
133    /// Recognized `attachment` columns this schema declares, in canonical order.
134    pub fn attachment_columns(&self) -> &[&'static str] {
135        &self.attachment_columns
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use rusqlite::Connection;
142
143    use super::Capabilities;
144    use crate::{
145        error::table::{TableConnectError, TableError},
146        test_support::schema_db,
147    };
148
149    #[test]
150    fn rejects_database_without_message_table() {
151        let db = Connection::open_in_memory().unwrap();
152        db.execute_batch("CREATE TABLE Files (fileID TEXT)")
153            .unwrap();
154
155        assert!(matches!(
156            Capabilities::determine(&db),
157            Err(TableError::CannotConnect(
158                TableConnectError::NotMessagesDatabase(_)
159            ))
160        ));
161    }
162
163    #[test]
164    fn detects_present_features() {
165        let db = schema_db(true, true, true);
166        let capabilities = Capabilities::determine(&db).unwrap();
167
168        assert!(capabilities.filter_actions);
169        assert!(capabilities.recoverable_messages);
170        assert!(capabilities.replies);
171        assert!(capabilities.associated_message_guids);
172
173        // Every recognized column joins the projection.
174        assert_eq!(capabilities.message_columns().len(), 26);
175    }
176
177    #[test]
178    fn detects_absent_features() {
179        let db = schema_db(false, false, false);
180        let capabilities = Capabilities::determine(&db).unwrap();
181
182        assert!(!capabilities.filter_actions);
183        assert!(!capabilities.recoverable_messages);
184        assert!(!capabilities.replies);
185        // The reply columns leave the projection with their capability.
186        assert!(
187            !capabilities
188                .message_columns()
189                .contains(&"thread_originator_guid")
190        );
191    }
192
193    #[test]
194    fn pre_tapback_schema_probes_no_associated_guids() {
195        let db = schema_db(false, false, false);
196        db.execute_batch("ALTER TABLE message DROP COLUMN associated_message_guid")
197            .unwrap();
198
199        assert!(
200            !Capabilities::determine(&db)
201                .unwrap()
202                .associated_message_guids
203        );
204    }
205
206    #[test]
207    fn attachment_columns_track_the_schema() {
208        // The pre-emoji `attachment` layout declares every recognized column
209        // except `emoji_image_short_description`.
210        let db = schema_db(false, false, false);
211        db.execute_batch(
212            "CREATE TABLE attachment (
213                ROWID INTEGER PRIMARY KEY,
214                guid TEXT,
215                filename TEXT,
216                uti TEXT,
217                mime_type TEXT,
218                transfer_name TEXT,
219                total_bytes INTEGER,
220                is_sticker INTEGER,
221                hide_attachment INTEGER
222            );",
223        )
224        .unwrap();
225
226        let capabilities = Capabilities::determine(&db).unwrap();
227
228        assert_eq!(capabilities.attachment_columns().len(), 9);
229        assert!(
230            !capabilities
231                .attachment_columns()
232                .contains(&"emoji_image_short_description")
233        );
234    }
235
236    #[test]
237    fn without_body_inputs_drops_only_the_parser_columns() {
238        let db = schema_db(true, true, true);
239        db.execute_batch(
240            "ALTER TABLE message ADD COLUMN attributedBody BLOB;
241             ALTER TABLE message ADD COLUMN message_summary_info BLOB;
242             ALTER TABLE message ADD COLUMN payload_data BLOB;",
243        )
244        .unwrap();
245        let capabilities = Capabilities::determine(&db).unwrap();
246        assert_eq!(capabilities.message_columns().len(), 29);
247
248        let lean = capabilities.without_body_inputs();
249        let expected: Vec<&str> = capabilities
250            .message_columns()
251            .iter()
252            .copied()
253            .filter(|column| {
254                !matches!(
255                    *column,
256                    "attributedbody" | "message_summary_info" | "payload_data"
257                )
258            })
259            .collect();
260        assert_eq!(lean.message_columns(), expected.as_slice());
261        assert_eq!(lean.message_columns().len(), 26);
262        assert!(lean.filter_actions);
263        assert!(lean.recoverable_messages);
264        assert!(lean.replies);
265    }
266}