Skip to main content

imessage_db/imessage/
repository.rs

1/// MessageRepository: read-only access to the iMessage chat.db database.
2///
3/// It uses rusqlite directly with hand-built SQL, reading from
4/// ~/Library/Messages/chat.db in read-only mode.
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::sync::Arc;
8
9use anyhow::{Context, Result};
10use rusqlite::{Connection, OpenFlags, params};
11use serde_json::Value;
12use tracing::info;
13
14use super::columns::DetectedSchema;
15use super::entities::{Attachment, Chat, Handle, Message};
16use super::row_reader;
17use super::transformers::date_to_db;
18use super::types::*;
19
20/// The iMessage database repository.
21pub struct MessageRepository {
22    conn: Connection,
23    schema: Arc<DetectedSchema>,
24}
25
26/// Convert a serde_json Value to a rusqlite-bindable parameter.
27fn json_to_sql(val: &Value) -> Box<dyn rusqlite::types::ToSql> {
28    match val {
29        Value::String(s) => Box::new(s.clone()),
30        Value::Number(n) => {
31            if let Some(i) = n.as_i64() {
32                Box::new(i)
33            } else if let Some(f) = n.as_f64() {
34                Box::new(f)
35            } else {
36                Box::new(n.to_string())
37            }
38        }
39        Value::Bool(b) => Box::new(if *b { 1i64 } else { 0i64 }),
40        Value::Null => Box::new(Option::<String>::None),
41        _ => Box::new(val.to_string()),
42    }
43}
44
45/// Expand named parameters (`:name`, `:...name`) in a SQL fragment and push bound values.
46fn expand_named_params(
47    statement: &str,
48    args: &HashMap<String, Value>,
49    bind_values: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
50) -> String {
51    let mut result = String::with_capacity(statement.len());
52    let chars: Vec<char> = statement.chars().collect();
53    let len = chars.len();
54    let mut i = 0;
55
56    while i < len {
57        if chars[i] == ':' {
58            let is_spread =
59                i + 3 < len && chars[i + 1] == '.' && chars[i + 2] == '.' && chars[i + 3] == '.';
60            let name_start = if is_spread { i + 4 } else { i + 1 };
61
62            let mut name_end = name_start;
63            while name_end < len && (chars[name_end].is_alphanumeric() || chars[name_end] == '_') {
64                name_end += 1;
65            }
66
67            if name_end > name_start {
68                let name: String = chars[name_start..name_end].iter().collect();
69                if let Some(val) = args.get(&name) {
70                    if is_spread {
71                        if let Some(arr) = val.as_array() {
72                            for (j, item) in arr.iter().enumerate() {
73                                if j > 0 {
74                                    result.push_str(", ");
75                                }
76                                result.push('?');
77                                bind_values.push(json_to_sql(item));
78                            }
79                        }
80                    } else {
81                        result.push('?');
82                        bind_values.push(json_to_sql(val));
83                    }
84                    i = name_end;
85                    continue;
86                }
87            }
88        }
89        result.push(chars[i]);
90        i += 1;
91    }
92
93    result
94}
95
96/// Apply custom WHERE clauses to a SQL query.
97fn apply_where_clauses(
98    sql: &mut String,
99    bind_values: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
100    where_clauses: &[WhereClause],
101) {
102    for clause in where_clauses {
103        let expanded = expand_named_params(&clause.statement, &clause.args, bind_values);
104        sql.push_str(&format!(" AND ({expanded})"));
105    }
106}
107
108impl MessageRepository {
109    /// Open the iMessage database in read-only mode and detect its schema.
110    pub fn open(db_path: PathBuf) -> Result<Self> {
111        let conn = Connection::open_with_flags(
112            &db_path,
113            OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
114        )
115        .with_context(|| format!("Failed to open iMessage DB at {}", db_path.display()))?;
116
117        let schema = Arc::new(DetectedSchema::detect(&conn));
118        info!(
119            "iMessage DB opened: {} message columns detected",
120            schema.message_select_columns().len()
121        );
122
123        Ok(Self { conn, schema })
124    }
125
126    /// Get the detected schema.
127    pub fn schema(&self) -> &DetectedSchema {
128        &self.schema
129    }
130
131    // =========================================================================
132    // Counts
133    // =========================================================================
134
135    /// Count all messages, with optional filters.
136    pub fn get_message_count(&self, params: &MessageCountParams) -> Result<i64> {
137        let mut sql = String::from("SELECT COUNT(*) FROM message");
138        let mut bind_values: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
139
140        // Join to chat if filtering by chat_guid
141        if let Some(ref guid) = params.chat_guid {
142            sql.push_str(
143                " INNER JOIN chat_message_join ON message.ROWID = chat_message_join.message_id \
144                 INNER JOIN chat ON chat.ROWID = chat_message_join.chat_id",
145            );
146            sql.push_str(" WHERE chat.guid = ?");
147            bind_values.push(Box::new(guid.clone()));
148        } else {
149            sql.push_str(" WHERE 1=1");
150        }
151
152        if params.is_from_me {
153            sql.push_str(" AND message.is_from_me = 1");
154        }
155
156        if let Some(min) = params.min_row_id {
157            sql.push_str(" AND message.ROWID >= ?");
158            bind_values.push(Box::new(min));
159        }
160
161        if let Some(max) = params.max_row_id {
162            sql.push_str(" AND message.ROWID <= ?");
163            bind_values.push(Box::new(max));
164        }
165
166        // Date filters
167        if params.after.is_some() || params.before.is_some() {
168            if params.updated {
169                self.append_update_date_sql(
170                    &mut sql,
171                    &mut bind_values,
172                    params.after,
173                    params.before,
174                    false,
175                );
176            } else {
177                self.append_date_sql(&mut sql, &mut bind_values, params.after, params.before);
178            }
179        }
180
181        // Custom WHERE clauses
182        apply_where_clauses(&mut sql, &mut bind_values, &params.where_clauses);
183
184        let bind_refs: Vec<&dyn rusqlite::types::ToSql> =
185            bind_values.iter().map(|b| b.as_ref()).collect();
186        let count: i64 = self
187            .conn
188            .query_row(&sql, bind_refs.as_slice(), |row| row.get(0))?;
189        Ok(count)
190    }
191
192    /// Count all chats.
193    pub fn get_chat_count(&self) -> Result<i64> {
194        let count: i64 = self
195            .conn
196            .query_row("SELECT COUNT(*) FROM chat", [], |row| row.get(0))?;
197        Ok(count)
198    }
199
200    /// Count all handles, optionally filtered by address.
201    pub fn get_handle_count(&self, address: Option<&str>) -> Result<i64> {
202        if let Some(addr) = address {
203            let stripped = addr.replace('+', "");
204            let count: i64 = self.conn.query_row(
205                "SELECT COUNT(*) FROM handle WHERE handle.id LIKE ?",
206                [format!("%{stripped}")],
207                |row| row.get(0),
208            )?;
209            Ok(count)
210        } else {
211            let count: i64 = self
212                .conn
213                .query_row("SELECT COUNT(*) FROM handle", [], |row| row.get(0))?;
214            Ok(count)
215        }
216    }
217
218    /// Count all attachments.
219    pub fn get_attachment_count(&self) -> Result<i64> {
220        let count: i64 = self
221            .conn
222            .query_row("SELECT COUNT(*) FROM attachment", [], |row| row.get(0))?;
223        Ok(count)
224    }
225
226    // =========================================================================
227    // Single-row lookups
228    // =========================================================================
229
230    /// Get a single message by GUID, optionally with chats and attachments.
231    pub fn get_message(
232        &self,
233        guid: &str,
234        with_chats: bool,
235        with_attachments: bool,
236    ) -> Result<Option<Message>> {
237        let msg_cols = self.schema.message_select_columns();
238        let msg_select = msg_cols.join(", ");
239
240        let sql = format!(
241            "SELECT {msg_select}, \
242             handle.ROWID AS h_ROWID, handle.id AS h_id, handle.country AS h_country, \
243             handle.service AS h_service, handle.uncanonicalized_id AS h_uncanonicalized_id \
244             FROM message \
245             LEFT JOIN handle ON message.handle_id = handle.ROWID \
246             WHERE message.guid = ?"
247        );
248
249        let mut stmt = self.conn.prepare(&sql)?;
250        let result = stmt.query_row(params![guid], |row| {
251            let mut msg = row_reader::read_message(row, &self.schema);
252            msg.handle = row_reader::read_handle_from_join(row, "h_");
253            Ok(msg)
254        });
255
256        let mut msg = match result {
257            Ok(msg) => msg,
258            Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
259            Err(e) => return Err(e.into()),
260        };
261
262        // Fetch chats separately to avoid column name ambiguity from JOIN
263        // (both message and chat tables have ROWID, guid, etc.)
264        if with_chats {
265            msg.chats = self.get_chats_for_message(msg.rowid)?;
266        }
267
268        // Fetch attachments separately if requested
269        if with_attachments {
270            msg.attachments = self.get_attachments_for_message(msg.rowid)?;
271        }
272
273        Ok(Some(msg))
274    }
275
276    /// Get a single attachment by GUID (handles prefix stripping).
277    pub fn get_attachment(&self, attachment_guid: &str) -> Result<Option<Attachment>> {
278        let att_cols = self.schema.attachment_select_columns();
279        let att_select = att_cols.join(", ");
280
281        // Build lookup GUIDs: original + last 36 chars (strip prefix like "at_x_" or "p:/")
282        let mut lookup_guids = vec![attachment_guid.to_string()];
283        if attachment_guid.len() > 36 {
284            lookup_guids.push(attachment_guid[attachment_guid.len() - 36..].to_string());
285        }
286
287        for lookup_guid in &lookup_guids {
288            let like_pattern = format!("%{lookup_guid}");
289
290            let sql = format!(
291                "SELECT {att_select} FROM attachment \
292                 WHERE attachment.original_guid LIKE ?1 OR attachment.guid LIKE ?1 \
293                 LIMIT 1"
294            );
295
296            let mut stmt = self.conn.prepare(&sql)?;
297            let result = stmt.query_row(params![like_pattern], |row| {
298                Ok(row_reader::read_attachment(row, &self.schema))
299            });
300
301            match result {
302                Ok(att) => return Ok(Some(att)),
303                Err(rusqlite::Error::QueryReturnedNoRows) => continue,
304                Err(e) => return Err(e.into()),
305            }
306        }
307
308        Ok(None)
309    }
310
311    /// Get the iMessage account login string.
312    pub fn get_imessage_account(&self) -> Result<Option<String>> {
313        let result = self.conn.query_row(
314            "SELECT account_login FROM chat WHERE service_name = 'iMessage' ORDER BY ROWID DESC LIMIT 1",
315            [],
316            |row| row.get::<_, Option<String>>(0),
317        );
318
319        match result {
320            Ok(Some(login)) => Ok(login.split(':').next_back().map(|s| s.to_string())),
321            Ok(None) => Ok(None),
322            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
323            Err(e) => Err(e.into()),
324        }
325    }
326
327    /// Get the last message in a chat by GUID.
328    pub fn get_chat_last_message(&self, chat_guid: &str) -> Result<Option<Message>> {
329        let msg_cols = self.schema.message_select_columns();
330        let msg_select = msg_cols.join(", ");
331
332        let sql = format!(
333            "SELECT {msg_select}, \
334             handle.ROWID AS h_ROWID, handle.id AS h_id, handle.country AS h_country, \
335             handle.service AS h_service, handle.uncanonicalized_id AS h_uncanonicalized_id \
336             FROM message \
337             LEFT JOIN handle ON message.handle_id = handle.ROWID \
338             INNER JOIN chat_message_join ON message.ROWID = chat_message_join.message_id \
339             INNER JOIN chat ON chat.ROWID = chat_message_join.chat_id \
340             WHERE chat.guid = ? \
341             ORDER BY message.date DESC \
342             LIMIT 1"
343        );
344
345        let mut stmt = self.conn.prepare(&sql)?;
346        let result = stmt.query_row(params![chat_guid], |row| {
347            let mut msg = row_reader::read_message(row, &self.schema);
348            msg.handle = row_reader::read_handle_from_join(row, "h_");
349            Ok(msg)
350        });
351
352        match result {
353            Ok(msg) => Ok(Some(msg)),
354            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
355            Err(e) => Err(e.into()),
356        }
357    }
358
359    // =========================================================================
360    // Multi-row queries
361    // =========================================================================
362
363    /// Get messages with pagination and filters. Returns (messages, total_count).
364    pub fn get_messages(&self, params: &MessageQueryParams) -> Result<(Vec<Message>, i64)> {
365        let msg_cols = self.schema.message_select_columns();
366        let msg_select = msg_cols.join(", ");
367
368        let mut sql = format!(
369            "SELECT {msg_select}, \
370             handle.ROWID AS h_ROWID, handle.id AS h_id, handle.country AS h_country, \
371             handle.service AS h_service, handle.uncanonicalized_id AS h_uncanonicalized_id \
372             FROM message \
373             LEFT JOIN handle ON message.handle_id = handle.ROWID"
374        );
375
376        let mut bind_values: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
377
378        // Chat filter
379        if let Some(ref guid) = params.chat_guid {
380            sql.push_str(
381                " INNER JOIN chat_message_join ON message.ROWID = chat_message_join.message_id \
382                 INNER JOIN chat ON chat.ROWID = chat_message_join.chat_id",
383            );
384            sql.push_str(" WHERE chat.guid = ?");
385            bind_values.push(Box::new(guid.clone()));
386        } else if params.with_chats {
387            sql.push_str(
388                " INNER JOIN chat_message_join ON message.ROWID = chat_message_join.message_id \
389                 INNER JOIN chat ON chat.ROWID = chat_message_join.chat_id",
390            );
391            sql.push_str(" WHERE 1=1");
392        } else {
393            sql.push_str(" WHERE 1=1");
394        }
395
396        // Date filters
397        if params.after.is_some() || params.before.is_some() {
398            self.append_date_sql(&mut sql, &mut bind_values, params.after, params.before);
399        }
400
401        // Custom WHERE clauses
402        apply_where_clauses(&mut sql, &mut bind_values, &params.where_clauses);
403
404        // Order, offset, limit
405        let order_col = &params.order_by;
406        let sort = params.sort.as_sql();
407        sql.push_str(&format!(" ORDER BY {order_col} {sort}"));
408        sql.push_str(&format!(" LIMIT {} OFFSET {}", params.limit, params.offset));
409
410        let bind_refs: Vec<&dyn rusqlite::types::ToSql> =
411            bind_values.iter().map(|b| b.as_ref()).collect();
412
413        let mut stmt = self.conn.prepare(&sql)?;
414        let rows = stmt.query_map(bind_refs.as_slice(), |row| {
415            let mut msg = row_reader::read_message(row, &self.schema);
416            msg.handle = row_reader::read_handle_from_join(row, "h_");
417            Ok(msg)
418        })?;
419
420        let mut messages: Vec<Message> = vec![];
421        for row_result in rows {
422            messages.push(row_result?);
423        }
424
425        // Fetch attachments for each message if requested
426        if params.with_attachments {
427            for msg in &mut messages {
428                msg.attachments = self.get_attachments_for_message(msg.rowid)?;
429            }
430        }
431
432        // Fetch chats for each message if requested (and not already joined via chat_guid)
433        if params.with_chats && params.chat_guid.is_none() {
434            for msg in &mut messages {
435                msg.chats = self.get_chats_for_message(msg.rowid)?;
436            }
437        }
438
439        // Get total count (using a separate COUNT query for accuracy with pagination)
440        let count_params = MessageCountParams {
441            chat_guid: params.chat_guid.clone(),
442            after: params.after,
443            before: params.before,
444            where_clauses: params.where_clauses.clone(),
445            ..Default::default()
446        };
447        let total = self.get_message_count(&count_params)?;
448
449        Ok((messages, total))
450    }
451
452    /// Get updated messages (delivered, read, edited, retracted since a date).
453    pub fn get_updated_messages(&self, params: &UpdatedMessageQueryParams) -> Result<Vec<Message>> {
454        let msg_cols = self.schema.message_select_columns();
455        let msg_select = msg_cols.join(", ");
456
457        let mut sql = format!(
458            "SELECT {msg_select}, \
459             handle.ROWID AS h_ROWID, handle.id AS h_id, handle.country AS h_country, \
460             handle.service AS h_service, handle.uncanonicalized_id AS h_uncanonicalized_id \
461             FROM message \
462             LEFT JOIN handle ON message.handle_id = handle.ROWID"
463        );
464
465        let mut bind_values: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
466
467        if let Some(ref guid) = params.chat_guid {
468            sql.push_str(
469                " INNER JOIN chat_message_join ON message.ROWID = chat_message_join.message_id \
470                 INNER JOIN chat ON chat.ROWID = chat_message_join.chat_id",
471            );
472            sql.push_str(" WHERE chat.guid = ?");
473            bind_values.push(Box::new(guid.clone()));
474        } else if params.with_chats {
475            sql.push_str(
476                " INNER JOIN chat_message_join ON message.ROWID = chat_message_join.message_id \
477                 INNER JOIN chat ON chat.ROWID = chat_message_join.chat_id",
478            );
479            sql.push_str(" WHERE 1=1");
480        } else {
481            sql.push_str(" WHERE 1=1");
482        }
483
484        if params.after.is_some() || params.before.is_some() {
485            self.append_update_date_sql(
486                &mut sql,
487                &mut bind_values,
488                params.after,
489                params.before,
490                params.include_created,
491            );
492        }
493
494        let sort = params.sort.as_sql();
495        sql.push_str(&format!(" ORDER BY message.date {sort}"));
496        sql.push_str(&format!(" LIMIT {} OFFSET {}", params.limit, params.offset));
497
498        let bind_refs: Vec<&dyn rusqlite::types::ToSql> =
499            bind_values.iter().map(|b| b.as_ref()).collect();
500        let mut stmt = self.conn.prepare(&sql)?;
501        let rows = stmt.query_map(bind_refs.as_slice(), |row| {
502            let mut msg = row_reader::read_message(row, &self.schema);
503            msg.handle = row_reader::read_handle_from_join(row, "h_");
504            Ok(msg)
505        })?;
506
507        let mut messages: Vec<Message> = vec![];
508        for row_result in rows {
509            messages.push(row_result?);
510        }
511
512        if params.with_attachments {
513            for msg in &mut messages {
514                msg.attachments = self.get_attachments_for_message(msg.rowid)?;
515            }
516        }
517
518        Ok(messages)
519    }
520
521    /// Get chats with pagination and filters. Returns (chats, total_count).
522    pub fn get_chats(&self, params: &ChatQueryParams) -> Result<(Vec<Chat>, i64)> {
523        let chat_cols = self.schema.chat_select_columns();
524        let chat_select = chat_cols.join(", ");
525
526        let mut sql = format!("SELECT {chat_select} FROM chat WHERE 1=1");
527        let mut bind_values: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
528
529        if !params.with_archived {
530            sql.push_str(" AND chat.is_archived = 0");
531        }
532
533        if let Some(ref guid) = params.chat_guid {
534            if params.glob_guid {
535                sql.push_str(" AND chat.guid LIKE ?");
536                bind_values.push(Box::new(format!("%{guid}%")));
537            } else {
538                sql.push_str(" AND chat.guid = ?");
539                bind_values.push(Box::new(guid.clone()));
540            }
541        }
542
543        let order_col = &params.order_by;
544        sql.push_str(&format!(" ORDER BY {order_col} DESC"));
545
546        if let Some(limit) = params.limit {
547            sql.push_str(&format!(" LIMIT {limit} OFFSET {}", params.offset));
548        }
549
550        let bind_refs: Vec<&dyn rusqlite::types::ToSql> =
551            bind_values.iter().map(|b| b.as_ref()).collect();
552        let mut stmt = self.conn.prepare(&sql)?;
553        let rows = stmt.query_map(bind_refs.as_slice(), |row| {
554            Ok(row_reader::read_chat(row, &self.schema))
555        })?;
556
557        let mut chats: Vec<Chat> = vec![];
558        for row_result in rows {
559            chats.push(row_result?);
560        }
561
562        // Fetch participants for each chat
563        if params.with_participants {
564            for chat in &mut chats {
565                chat.participants = self.get_chat_participants(chat.rowid)?;
566            }
567        }
568
569        // Fetch last message for each chat
570        if params.with_last_message {
571            for chat in &mut chats {
572                if let Some(msg) = self.get_chat_last_message(&chat.guid)? {
573                    chat.messages = vec![msg];
574                }
575            }
576        }
577
578        // Total count (with same filters as the main query)
579        let mut count_sql = String::from("SELECT COUNT(*) FROM chat WHERE 1=1");
580        let mut count_binds: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
581
582        if !params.with_archived {
583            count_sql.push_str(" AND chat.is_archived = 0");
584        }
585        if let Some(ref guid) = params.chat_guid {
586            if params.glob_guid {
587                count_sql.push_str(" AND chat.guid LIKE ?");
588                count_binds.push(Box::new(format!("%{guid}%")));
589            } else {
590                count_sql.push_str(" AND chat.guid = ?");
591                count_binds.push(Box::new(guid.clone()));
592            }
593        }
594
595        let count_refs: Vec<&dyn rusqlite::types::ToSql> =
596            count_binds.iter().map(|b| b.as_ref()).collect();
597        let count: i64 = self
598            .conn
599            .query_row(&count_sql, count_refs.as_slice(), |row| row.get(0))?;
600
601        Ok((chats, count))
602    }
603
604    /// Get handles with pagination. Returns (handles, total_count).
605    pub fn get_handles(&self, params: &HandleQueryParams) -> Result<(Vec<Handle>, i64)> {
606        let mut sql = String::from(
607            "SELECT handle.ROWID, handle.id, handle.country, handle.service, \
608             handle.uncanonicalized_id FROM handle",
609        );
610        let mut bind_values: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
611
612        if let Some(ref address) = params.address {
613            // Strip + prefix for matching
614            let stripped = address.replace('+', "");
615            sql.push_str(" WHERE handle.id LIKE ?");
616            bind_values.push(Box::new(format!("%{stripped}")));
617        }
618
619        sql.push_str(&format!(" LIMIT {} OFFSET {}", params.limit, params.offset));
620
621        let bind_refs: Vec<&dyn rusqlite::types::ToSql> =
622            bind_values.iter().map(|b| b.as_ref()).collect();
623        let mut stmt = self.conn.prepare(&sql)?;
624        let rows = stmt.query_map(bind_refs.as_slice(), |row| Ok(row_reader::read_handle(row)))?;
625
626        let mut handles: Vec<Handle> = vec![];
627        for row_result in rows {
628            handles.push(row_result?);
629        }
630
631        let count = self.get_handle_count(params.address.as_deref())?;
632
633        Ok((handles, count))
634    }
635
636    // =========================================================================
637    // Relation helpers
638    // =========================================================================
639
640    /// Get all attachments for a message by message ROWID.
641    pub fn get_attachments_for_message(&self, message_rowid: i64) -> Result<Vec<Attachment>> {
642        let att_cols = self.schema.attachment_select_columns();
643        let att_select = att_cols.join(", ");
644
645        let sql = format!(
646            "SELECT {att_select} FROM attachment \
647             INNER JOIN message_attachment_join ON attachment.ROWID = message_attachment_join.attachment_id \
648             WHERE message_attachment_join.message_id = ?"
649        );
650
651        let mut stmt = self.conn.prepare(&sql)?;
652        let rows = stmt.query_map(params![message_rowid], |row| {
653            Ok(row_reader::read_attachment(row, &self.schema))
654        })?;
655
656        let mut attachments = vec![];
657        for row_result in rows {
658            attachments.push(row_result?);
659        }
660        Ok(attachments)
661    }
662
663    /// Get all chats that a message belongs to (by message ROWID).
664    fn get_chats_for_message(&self, message_rowid: i64) -> Result<Vec<Chat>> {
665        let chat_cols = self.schema.chat_select_columns();
666        let chat_select = chat_cols.join(", ");
667
668        let sql = format!(
669            "SELECT {chat_select} FROM chat \
670             INNER JOIN chat_message_join ON chat.ROWID = chat_message_join.chat_id \
671             WHERE chat_message_join.message_id = ?"
672        );
673
674        let mut stmt = self.conn.prepare(&sql)?;
675        let rows = stmt.query_map(params![message_rowid], |row| {
676            Ok(row_reader::read_chat(row, &self.schema))
677        })?;
678
679        let mut chats = vec![];
680        for row_result in rows {
681            chats.push(row_result?);
682        }
683        Ok(chats)
684    }
685
686    /// Get all participants (handles) for a chat by chat ROWID.
687    /// Preserves insertion order from the join table.
688    fn get_chat_participants(&self, chat_rowid: i64) -> Result<Vec<Handle>> {
689        // Get the ordered handle IDs from the join table
690        let mut join_stmt = self
691            .conn
692            .prepare("SELECT handle_id FROM chat_handle_join WHERE chat_id = ?")?;
693        let handle_ids: Vec<i64> = join_stmt
694            .query_map(params![chat_rowid], |row| row.get::<_, i64>(0))?
695            .filter_map(|r| r.ok())
696            .collect();
697
698        if handle_ids.is_empty() {
699            return Ok(vec![]);
700        }
701
702        // Fetch all handles in bulk
703        let placeholders: Vec<String> = handle_ids.iter().map(|_| "?".to_string()).collect();
704        let sql = format!(
705            "SELECT handle.ROWID, handle.id, handle.country, handle.service, \
706             handle.uncanonicalized_id FROM handle WHERE handle.ROWID IN ({})",
707            placeholders.join(",")
708        );
709
710        let mut stmt = self.conn.prepare(&sql)?;
711        let params_refs: Vec<&dyn rusqlite::types::ToSql> = handle_ids
712            .iter()
713            .map(|id| id as &dyn rusqlite::types::ToSql)
714            .collect();
715
716        let rows = stmt.query_map(params_refs.as_slice(), |row| {
717            Ok(row_reader::read_handle(row))
718        })?;
719
720        let mut handle_map: HashMap<i64, Handle> = HashMap::new();
721        for row_result in rows {
722            let h = row_result?;
723            handle_map.insert(h.rowid, h);
724        }
725
726        // Return handles in join-table order (preserving participant order)
727        let handles: Vec<Handle> = handle_ids
728            .iter()
729            .filter_map(|id| handle_map.remove(id))
730            .collect();
731
732        Ok(handles)
733    }
734
735    // =========================================================================
736    // Statistics queries
737    // =========================================================================
738
739    /// Get chats whose `last_read_message_timestamp` >= the given Apple timestamp.
740    /// Used by the chat update poller to detect read status changes.
741    pub fn get_chats_read_since(&self, after_apple_ts: i64) -> Result<Vec<Chat>> {
742        let chat_cols = self.schema.chat_select_columns();
743        let chat_select = chat_cols.join(", ");
744
745        let sql = format!(
746            "SELECT {chat_select} FROM chat \
747             WHERE chat.last_read_message_timestamp >= ? \
748             ORDER BY chat.last_read_message_timestamp DESC"
749        );
750
751        let mut stmt = self.conn.prepare(&sql)?;
752        let rows = stmt.query_map([after_apple_ts], |row| {
753            Ok(row_reader::read_chat(row, &self.schema))
754        })?;
755
756        let mut chats = Vec::new();
757        for row_result in rows {
758            chats.push(row_result?);
759        }
760        Ok(chats)
761    }
762
763    /// Get media counts (total across all chats).
764    pub fn get_media_counts(&self, media_type: &str, after: Option<i64>) -> Result<i64> {
765        let mime_prefix = if media_type == "location" {
766            "text/x-vlocation".to_string()
767        } else {
768            media_type.to_string()
769        };
770
771        let (sql, bind_values): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
772            if let Some(after_ms) = after {
773                let apple_ts = date_to_db(after_ms);
774                (
775                    "SELECT COUNT(attachment.ROWID) AS media_count \
776                     FROM attachment \
777                     WHERE attachment.created_date >= ? AND attachment.mime_type LIKE ?"
778                        .to_string(),
779                    vec![
780                        Box::new(apple_ts) as Box<dyn rusqlite::types::ToSql>,
781                        Box::new(format!("{mime_prefix}%")),
782                    ],
783                )
784            } else {
785                (
786                    "SELECT COUNT(attachment.ROWID) AS media_count \
787                 FROM attachment \
788                 WHERE attachment.mime_type LIKE ?"
789                        .to_string(),
790                    vec![Box::new(format!("{mime_prefix}%")) as Box<dyn rusqlite::types::ToSql>],
791                )
792            };
793
794        let bind_refs: Vec<&dyn rusqlite::types::ToSql> =
795            bind_values.iter().map(|b| b.as_ref()).collect();
796        let count: i64 = self
797            .conn
798            .query_row(&sql, bind_refs.as_slice(), |row| row.get(0))?;
799        Ok(count)
800    }
801
802    /// Get media counts scoped to a specific chat.
803    pub fn get_media_counts_by_chat(&self, chat_guid: &str, media_type: &str) -> Result<i64> {
804        let mime_prefix = if media_type == "location" {
805            "text/x-vlocation".to_string()
806        } else {
807            media_type.to_string()
808        };
809
810        let sql = "SELECT COUNT(attachment.ROWID) AS media_count \
811                   FROM attachment \
812                   INNER JOIN message_attachment_join ON attachment.ROWID = message_attachment_join.attachment_id \
813                   INNER JOIN chat_message_join ON message_attachment_join.message_id = chat_message_join.message_id \
814                   INNER JOIN chat ON chat.ROWID = chat_message_join.chat_id \
815                   WHERE chat.guid = ? AND attachment.mime_type LIKE ?";
816
817        let count: i64 = self.conn.query_row(
818            sql,
819            rusqlite::params![chat_guid, format!("{mime_prefix}%")],
820            |row| row.get(0),
821        )?;
822        Ok(count)
823    }
824
825    // =========================================================================
826    // Internal SQL helpers
827    // =========================================================================
828
829    /// Append date filter (message.date) to the SQL.
830    fn append_date_sql(
831        &self,
832        sql: &mut String,
833        bind_values: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
834        after: Option<i64>,
835        before: Option<i64>,
836    ) {
837        sql.push_str(" AND (1=1");
838        if let Some(after_ms) = after {
839            sql.push_str(" AND message.date >= ?");
840            bind_values.push(Box::new(date_to_db(after_ms)));
841        }
842        if let Some(before_ms) = before {
843            sql.push_str(" AND message.date <= ?");
844            bind_values.push(Box::new(date_to_db(before_ms)));
845        }
846        sql.push(')');
847    }
848
849    /// Append "updated" date filter: checks date_delivered, date_read, and optionally
850    /// date_edited/date_retracted (Ventura+) and date (if includeCreated).
851    fn append_update_date_sql(
852        &self,
853        sql: &mut String,
854        bind_values: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
855        after: Option<i64>,
856        before: Option<i64>,
857        include_created: bool,
858    ) {
859        sql.push_str(" AND (");
860        let mut first = true;
861
862        // Helper closure to add an OR clause for a date column
863        let add_date_clause = |sql: &mut String,
864                               bind_values: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
865                               col: &str,
866                               first: &mut bool| {
867            if !*first {
868                sql.push_str(" OR ");
869            }
870            *first = false;
871            sql.push_str("(1=1");
872            if let Some(after_ms) = after {
873                sql.push_str(&format!(" AND {col} >= ?"));
874                bind_values.push(Box::new(date_to_db(after_ms)));
875            }
876            if let Some(before_ms) = before {
877                sql.push_str(&format!(" AND {col} <= ?"));
878                bind_values.push(Box::new(date_to_db(before_ms)));
879            }
880            sql.push(')');
881        };
882
883        if include_created {
884            add_date_clause(sql, bind_values, "message.date", &mut first);
885        }
886
887        add_date_clause(sql, bind_values, "message.date_delivered", &mut first);
888        add_date_clause(sql, bind_values, "message.date_read", &mut first);
889        add_date_clause(sql, bind_values, "message.date_edited", &mut first);
890        add_date_clause(sql, bind_values, "message.date_retracted", &mut first);
891
892        sql.push(')');
893    }
894}
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899    use serde_json::json;
900
901    #[test]
902    fn expand_simple_named_param() {
903        let mut binds: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
904        let mut args = HashMap::new();
905        args.insert("term".to_string(), json!("%hello%"));
906        let result =
907            expand_named_params("message.text LIKE :term COLLATE NOCASE", &args, &mut binds);
908        assert_eq!(result, "message.text LIKE ? COLLATE NOCASE");
909        assert_eq!(binds.len(), 1);
910    }
911
912    #[test]
913    fn expand_spread_param() {
914        let mut binds: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
915        let mut args = HashMap::new();
916        args.insert("guids".to_string(), json!(["g1", "g2", "g3"]));
917        let result = expand_named_params("message.guid IN (:...guids)", &args, &mut binds);
918        assert_eq!(result, "message.guid IN (?, ?, ?)");
919        assert_eq!(binds.len(), 3);
920    }
921
922    #[test]
923    fn expand_multiple_params() {
924        let mut binds: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
925        let mut args = HashMap::new();
926        args.insert("after".to_string(), json!(100));
927        args.insert("before".to_string(), json!(200));
928        let result = expand_named_params(
929            "message.date >= :after AND message.date <= :before",
930            &args,
931            &mut binds,
932        );
933        assert_eq!(result, "message.date >= ? AND message.date <= ?");
934        assert_eq!(binds.len(), 2);
935    }
936
937    #[test]
938    fn expand_no_params() {
939        let mut binds: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
940        let args = HashMap::new();
941        let result = expand_named_params("message.is_from_me = 1", &args, &mut binds);
942        assert_eq!(result, "message.is_from_me = 1");
943        assert_eq!(binds.len(), 0);
944    }
945
946    #[test]
947    fn apply_where_clauses_empty() {
948        let mut sql = "SELECT * FROM message WHERE 1=1".to_string();
949        let mut binds: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
950        apply_where_clauses(&mut sql, &mut binds, &[]);
951        assert_eq!(sql, "SELECT * FROM message WHERE 1=1");
952    }
953
954    #[test]
955    fn apply_where_clauses_adds_and() {
956        let mut sql = "SELECT * FROM message WHERE 1=1".to_string();
957        let mut binds: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
958        let mut args = HashMap::new();
959        args.insert("val".to_string(), json!(1));
960        let clauses = vec![WhereClause {
961            statement: "message.is_from_me = :val".to_string(),
962            args,
963        }];
964        apply_where_clauses(&mut sql, &mut binds, &clauses);
965        assert_eq!(
966            sql,
967            "SELECT * FROM message WHERE 1=1 AND (message.is_from_me = ?)"
968        );
969        assert_eq!(binds.len(), 1);
970    }
971}