Skip to main content

imessage_database/tables/
table.rs

1/*!
2 This module defines traits for table representations and stores some shared table constants.
3
4 # Zero-Allocation Streaming API
5
6 This module provides zero-allocation streaming capabilities for all database tables through a callback-based API.
7
8 ```no_run
9 use imessage_database::{
10    error::table::TableError,
11    tables::{
12        table::{get_connection, Table},
13        messages::Message,
14    },
15    util::dirs::default_db_path
16 };
17
18 let db_path = default_db_path();
19 let db = get_connection(&db_path).unwrap();
20
21 Message::stream(&db, |message_result| {
22     match message_result {
23         Ok(message) => println!("Message: {:#?}", message),
24         Err(e) => eprintln!("Error: {:?}", e),
25     }
26    Ok::<(), TableError>(())
27 }).unwrap();
28 ```
29
30 Note: you can substitute `TableError` with your own error type if it implements `From<TableError>`. See the [`Table::stream`] method for more details.
31*/
32
33use std::{collections::HashMap, fs::metadata, path::Path};
34
35use rusqlite::{CachedStatement, Connection, Error, OpenFlags, Result, Row, blob::Blob};
36
37use crate::error::table::{TableConnectError, TableError};
38
39// MARK: Traits
40/// Defines behavior for SQL Table data
41pub trait Table: Sized {
42    /// Deserialize a single row into Self, returning a [`rusqlite::Result`]
43    fn from_row(row: &Row) -> Result<Self>;
44
45    /// Prepare SELECT * statement
46    fn get(db: &'_ Connection) -> Result<CachedStatement<'_>, TableError>;
47
48    /// Map a `rusqlite::Result<Self>` into our `TableError`
49    fn extract(item: Result<Result<Self, Error>, Error>) -> Result<Self, TableError> {
50        match item {
51            Ok(Ok(row)) => Ok(row),
52            Err(why) | Ok(Err(why)) => Err(TableError::QueryError(why)),
53        }
54    }
55
56    /// Process all rows from the table using a callback.
57    /// This is the most memory-efficient approach for large tables.
58    ///
59    /// Uses the default `Table` implementation to prepare the statement and query the rows.
60    ///
61    /// To execute custom queries, see the [`message`](crate::tables::messages::message) module docs for examples.
62    ///
63    /// # Example
64    ///
65    /// ```no_run
66    /// use imessage_database::{
67    ///    error::table::TableError,
68    ///    tables::{
69    ///        table::{get_connection, Table},
70    ///        handle::Handle,
71    ///    },
72    ///    util::dirs::default_db_path
73    /// };
74    ///
75    /// // Get a connection to the database
76    /// let db_path = default_db_path();
77    /// let db = get_connection(&db_path).unwrap();
78    ///
79    /// // Stream the Handle table, processing each row with a callback
80    /// Handle::stream(&db, |handle_result| {
81    ///     match handle_result {
82    ///         Ok(handle) => println!("Handle: {}", handle.id),
83    ///         Err(e) => eprintln!("Error: {:?}", e),
84    ///     }
85    ///     Ok::<(), TableError>(())
86    /// }).unwrap();
87    /// ```
88    fn stream<F, E>(db: &Connection, callback: F) -> Result<(), E>
89    where
90        E: From<TableError>,
91        F: FnMut(Result<Self, TableError>) -> Result<(), E>,
92    {
93        stream_table_callback::<Self, F, E>(db, callback)
94    }
95
96    /// Get a BLOB from the table
97    ///
98    /// # Arguments
99    ///
100    /// * `db` - The database connection
101    /// * `table` - The name of the table
102    /// * `column` - The name of the column containing the BLOB
103    /// * `rowid` - The row ID to retrieve the BLOB from
104    fn get_blob<'a>(
105        &self,
106        db: &'a Connection,
107        table: &str,
108        column: &str,
109        rowid: i64,
110    ) -> Option<Blob<'a>> {
111        db.blob_open(rusqlite::MAIN_DB, table, column, rowid, true)
112            .ok()
113    }
114
115    /// Check if a BLOB exists in the table
116    fn has_blob(&self, db: &Connection, table: &str, column: &str, rowid: i64) -> bool {
117        let sql = std::format!(
118            "SELECT ({column} IS NOT NULL) AS not_null
119         FROM {table}
120         WHERE rowid = ?1",
121        );
122
123        // This returns 1 for true, 0 for false.
124        db.query_row(&sql, [rowid], |row| row.get(0))
125            .ok()
126            .is_some_and(|v: i32| v != 0)
127    }
128}
129
130fn stream_table_callback<T, F, E>(db: &Connection, mut callback: F) -> Result<(), E>
131where
132    T: Table + Sized,
133    E: From<TableError>,
134    F: FnMut(Result<T, TableError>) -> Result<(), E>,
135{
136    let mut stmt = T::get(db).map_err(E::from)?;
137    let rows = stmt
138        .query_map([], |row| Ok(T::from_row(row)))
139        .map_err(TableError::from)
140        .map_err(E::from)?;
141
142    for row_result in rows {
143        let item_result = T::extract(row_result);
144        callback(item_result)?;
145    }
146    Ok(())
147}
148
149/// Defines behavior for table data that can be cached in memory
150pub trait Cacheable {
151    /// The key type for the cache `HashMap`
152    type K;
153    /// The value type for the cache `HashMap`
154    type V;
155    /// Caches the table data in a `HashMap`
156    fn cache(db: &Connection) -> Result<HashMap<Self::K, Self::V>, TableError>;
157}
158
159// MARK: Database
160/// Get a connection to the iMessage `SQLite` database
161/// # Example:
162///
163/// ```
164/// use imessage_database::{
165///     util::dirs::default_db_path,
166///     tables::table::get_connection
167/// };
168///
169/// let db_path = default_db_path();
170/// let connection = get_connection(&db_path);
171/// ```
172pub fn get_connection(path: &Path) -> Result<Connection, TableError> {
173    if path.exists() && path.is_file() {
174        return match Connection::open_with_flags(
175            path,
176            OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
177        ) {
178            Ok(res) => Ok(res),
179            Err(why) => Err(TableError::CannotConnect(TableConnectError::Permissions(
180                why,
181            ))),
182        };
183    }
184
185    // Path does not point to a file
186    if path.exists() && !path.is_file() {
187        return Err(TableError::CannotConnect(TableConnectError::NotAFile(
188            path.to_path_buf(),
189        )));
190    }
191
192    // File is missing
193    Err(TableError::CannotConnect(TableConnectError::DoesNotExist(
194        path.to_path_buf(),
195    )))
196}
197
198/// Get the size of the database on the disk
199/// # Example:
200///
201/// ```
202/// use imessage_database::{
203///     util::dirs::default_db_path,
204///     tables::table::get_db_size
205/// };
206///
207/// let db_path = default_db_path();
208/// let database_size_in_bytes = get_db_size(&db_path);
209/// ```
210pub fn get_db_size(path: &Path) -> Result<u64, TableError> {
211    Ok(metadata(path)?.len())
212}
213
214// MARK: Constants
215// Table Names
216/// Handle table name
217pub const HANDLE: &str = "handle";
218/// Message table name
219pub const MESSAGE: &str = "message";
220/// Chat table name
221pub const CHAT: &str = "chat";
222/// Attachment table name
223pub const ATTACHMENT: &str = "attachment";
224/// Chat to message join table name
225pub const CHAT_MESSAGE_JOIN: &str = "chat_message_join";
226/// Message to attachment join table name
227pub const MESSAGE_ATTACHMENT_JOIN: &str = "message_attachment_join";
228/// Chat to handle join table name
229pub const CHAT_HANDLE_JOIN: &str = "chat_handle_join";
230/// Recently deleted messages table
231pub const RECENTLY_DELETED: &str = "chat_recoverable_message_join";
232
233// Column names
234/// The payload data column contains `plist`-encoded app message data
235pub const MESSAGE_PAYLOAD: &str = "payload_data";
236/// The message summary info column contains `plist`-encoded edited message information
237pub const MESSAGE_SUMMARY_INFO: &str = "message_summary_info";
238/// The `attributedBody` column contains [`typedstream`](crate::util::typedstream)-encoded message body text with many other attributes
239pub const ATTRIBUTED_BODY: &str = "attributedBody";
240/// The sticker user info column contains `plist`-encoded metadata for sticker attachments
241pub const STICKER_USER_INFO: &str = "sticker_user_info";
242/// The attribution info contains `plist`-encoded metadata for sticker attachments
243pub const ATTRIBUTION_INFO: &str = "attribution_info";
244/// The properties column contains `plist`-encoded metadata for a chat
245pub const PROPERTIES: &str = "properties";
246
247// Default information
248/// Name used for messages sent by the database owner in a first-person context
249pub const ME: &str = "Me";
250/// Name used for messages sent by the database owner in a second-person context
251pub const YOU: &str = "You";
252/// Name used for contacts or chats where the name cannot be discovered
253pub const UNKNOWN: &str = "Unknown";
254/// Default location for the Messages database on macOS
255pub const DEFAULT_PATH_MACOS: &str = "Library/Messages/chat.db";
256/// Default location for the Messages database in an iOS backup
257pub const DEFAULT_PATH_IOS: &str = "3d/3d0d7e5fb2ce288813306e4d4636395e047a3d28";
258/// Chat name reserved for messages that do not belong to a chat in the table
259pub const ORPHANED: &str = "orphaned";
260/// Replacement text sent in Fitness.app messages
261pub const FITNESS_RECEIVER: &str = "$(kIMTranscriptPluginBreadcrumbTextReceiverIdentifier)";
262/// Name for attachments directory in exports
263pub const ATTACHMENTS_DIR: &str = "attachments";
264
265#[cfg(test)]
266mod tests {
267    use rusqlite::{CachedStatement, Connection, Result, Row};
268
269    use crate::error::table::TableError;
270
271    use super::Table;
272
273    struct TestRow(i64);
274
275    impl Table for TestRow {
276        fn from_row(row: &Row) -> Result<Self> {
277            Ok(Self(row.get(0)?))
278        }
279
280        fn get(db: &'_ Connection) -> Result<CachedStatement<'_>, TableError> {
281            Ok(db.prepare_cached("SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3")?)
282        }
283    }
284
285    #[derive(Debug)]
286    enum StreamError {
287        Table(TableError),
288        Stop,
289    }
290
291    impl From<TableError> for StreamError {
292        fn from(err: TableError) -> Self {
293            Self::Table(err)
294        }
295    }
296
297    #[test]
298    fn stream_propagates_callback_errors() {
299        let db = Connection::open_in_memory().unwrap();
300        let mut seen = vec![];
301
302        let result = TestRow::stream(&db, |row| {
303            let row = row.map_err(StreamError::from)?;
304            seen.push(row.0);
305            if row.0 == 2 {
306                return Err(StreamError::Stop);
307            }
308            Ok(())
309        });
310
311        assert!(matches!(result, Err(StreamError::Stop)));
312        assert_eq!(seen, vec![1, 2]);
313    }
314
315    #[test]
316    fn stream_converts_setup_errors() {
317        struct BrokenTable;
318
319        impl Table for BrokenTable {
320            fn from_row(_row: &Row) -> Result<Self> {
321                Ok(Self)
322            }
323
324            fn get(_db: &'_ Connection) -> Result<CachedStatement<'_>, TableError> {
325                Err(TableError::CannotRead(std::io::Error::other("boom")))
326            }
327        }
328
329        let db = Connection::open_in_memory().unwrap();
330        let result = BrokenTable::stream(&db, |_| Ok::<(), StreamError>(()));
331
332        assert!(matches!(
333            result,
334            Err(StreamError::Table(TableError::CannotRead(_)))
335        ));
336    }
337}