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 rusqlite::Connection;
11
12use crate::{
13    error::table::TableError,
14    tables::{
15        attachment::ATTACHMENT_COLUMNS,
16        diagnostic::{column_names, table_exists},
17        messages::columns::MESSAGE_COLUMNS,
18        table::{ATTACHMENT, MESSAGE, RECENTLY_DELETED},
19    },
20};
21
22/// Features of a Messages database schema, probed from the live database.
23///
24/// Each flag guards the SQL constructs that reference its column or table:
25/// when a flag is `false`, composed queries substitute a neutral placeholder
26/// so deserialization behaves uniformly across schemas.
27#[derive(Clone, Debug)]
28pub struct Capabilities {
29    /// Recognized `message` columns this schema declares, in canonical
30    /// ([`MESSAGE_COLUMNS`]) order.
31    message_columns: Vec<&'static str>,
32    /// Recognized `attachment` columns this schema declares, in canonical
33    /// ([`ATTACHMENT_COLUMNS`]) order.
34    attachment_columns: Vec<&'static str>,
35    /// Both `filter_action` and `filter_sub_action` exist on `message`.
36    ///
37    /// The pair is one feature: queries project either both real columns or
38    /// two `NULL` placeholders. A schema carrying only one of the pair reads
39    /// `None` for both, keeping [`FilterAction`](crate::tables::messages::models::FilterAction)
40    /// parsing off partial data.
41    pub filter_actions: bool,
42    /// The `chat_recoverable_message_join` table exists, so recently deleted
43    /// messages can be identified and filtered.
44    pub recoverable_messages: bool,
45    /// `thread_originator_guid` exists on `message`, so replies can be counted
46    /// and grouped.
47    pub replies: bool,
48    /// `associated_message_guid` exists on `message`, so tapbacks and poll
49    /// votes can be resolved.
50    pub associated_message_guids: bool,
51}
52
53impl Capabilities {
54    /// Probe the schema of the supplied database.
55    pub fn determine(db: &Connection) -> Result<Self, TableError> {
56        let message_names = column_names(db, MESSAGE)?;
57        let attachment_names = column_names(db, ATTACHMENT)?;
58
59        Ok(Self {
60            message_columns: MESSAGE_COLUMNS
61                .into_iter()
62                .filter(|column| message_names.contains(*column))
63                .collect(),
64            attachment_columns: ATTACHMENT_COLUMNS
65                .into_iter()
66                .filter(|column| attachment_names.contains(*column))
67                .collect(),
68            filter_actions: message_names.contains("filter_action")
69                && message_names.contains("filter_sub_action"),
70            recoverable_messages: table_exists(db, RECENTLY_DELETED)?,
71            replies: message_names.contains("thread_originator_guid"),
72            associated_message_guids: message_names.contains("associated_message_guid"),
73        })
74    }
75
76    /// Return a copy with the row-enrichment features disabled:
77    /// `recoverable_messages`, `replies`, and `filter_actions`.
78    ///
79    /// Narrow scans that only need the base projection (e.g. the tapback
80    /// cache) use this to skip correlated subqueries and joins those
81    /// features would add.
82    #[must_use]
83    pub fn without_derived_features(&self) -> Self {
84        Self {
85            recoverable_messages: false,
86            replies: false,
87            filter_actions: false,
88            ..self.clone()
89        }
90    }
91
92    /// Recognized `message` columns this schema declares, in canonical order.
93    pub fn message_columns(&self) -> &[&'static str] {
94        &self.message_columns
95    }
96
97    /// Recognized `attachment` columns this schema declares, in canonical order.
98    pub fn attachment_columns(&self) -> &[&'static str] {
99        &self.attachment_columns
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::Capabilities;
106    use crate::test_support::schema_db;
107
108    #[test]
109    fn detects_present_features() {
110        let db = schema_db(true, true, true);
111        let capabilities = Capabilities::determine(&db).unwrap();
112
113        assert!(capabilities.filter_actions);
114        assert!(capabilities.recoverable_messages);
115        assert!(capabilities.replies);
116        assert!(capabilities.associated_message_guids);
117
118        // Every recognized column joins the projection.
119        assert_eq!(capabilities.message_columns().len(), 26);
120    }
121
122    #[test]
123    fn detects_absent_features() {
124        let db = schema_db(false, false, false);
125        let capabilities = Capabilities::determine(&db).unwrap();
126
127        assert!(!capabilities.filter_actions);
128        assert!(!capabilities.recoverable_messages);
129        assert!(!capabilities.replies);
130        // The reply columns leave the projection with their capability.
131        assert!(
132            !capabilities
133                .message_columns()
134                .contains(&"thread_originator_guid")
135        );
136    }
137
138    #[test]
139    fn pre_tapback_schema_probes_no_associated_guids() {
140        let db = schema_db(false, false, false);
141        db.execute_batch("ALTER TABLE message DROP COLUMN associated_message_guid")
142            .unwrap();
143
144        assert!(
145            !Capabilities::determine(&db)
146                .unwrap()
147                .associated_message_guids
148        );
149    }
150
151    #[test]
152    fn attachment_columns_track_the_schema() {
153        // The pre-emoji `attachment` layout declares every recognized column
154        // except `emoji_image_short_description`.
155        let db = schema_db(false, false, false);
156        db.execute_batch(
157            "CREATE TABLE attachment (
158                ROWID INTEGER PRIMARY KEY,
159                guid TEXT,
160                filename TEXT,
161                uti TEXT,
162                mime_type TEXT,
163                transfer_name TEXT,
164                total_bytes INTEGER,
165                is_sticker INTEGER,
166                hide_attachment INTEGER
167            );",
168        )
169        .unwrap();
170
171        let capabilities = Capabilities::determine(&db).unwrap();
172
173        assert_eq!(capabilities.attachment_columns().len(), 9);
174        assert!(
175            !capabilities
176                .attachment_columns()
177                .contains(&"emoji_image_short_description")
178        );
179    }
180}