From 146fac2759319c473edaddbd2f39aa5e64ee7075 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 20 Jan 2025 22:05:34 -0800 Subject: [PATCH] kordophone-db: adds support for the Messages table --- .../down.sql | 4 +- .../up.sql | 24 +++- kordophone-db/src/chat_database.rs | 103 ++++++++++++++- kordophone-db/src/models/db/message.rs | 40 ++++++ kordophone-db/src/models/db/mod.rs | 1 + kordophone-db/src/models/db/participant.rs | 40 ++++-- kordophone-db/src/models/message.rs | 84 +++++++++++++ kordophone-db/src/models/mod.rs | 4 +- kordophone-db/src/models/participant.rs | 27 +++- kordophone-db/src/schema.rs | 26 +++- kordophone-db/src/tests/mod.rs | 119 +++++++++++++++++- 11 files changed, 444 insertions(+), 28 deletions(-) rename kordophone-db/migrations/{2024-12-15-030301_create_conversations => 2025-01-21-051154_create_conversations}/down.sql (68%) rename kordophone-db/migrations/{2024-12-15-030301_create_conversations => 2025-01-21-051154_create_conversations}/up.sql (56%) create mode 100644 kordophone-db/src/models/db/message.rs create mode 100644 kordophone-db/src/models/message.rs diff --git a/kordophone-db/migrations/2024-12-15-030301_create_conversations/down.sql b/kordophone-db/migrations/2025-01-21-051154_create_conversations/down.sql similarity index 68% rename from kordophone-db/migrations/2024-12-15-030301_create_conversations/down.sql rename to kordophone-db/migrations/2025-01-21-051154_create_conversations/down.sql index 40e3b94..34cefce 100644 --- a/kordophone-db/migrations/2024-12-15-030301_create_conversations/down.sql +++ b/kordophone-db/migrations/2025-01-21-051154_create_conversations/down.sql @@ -1,4 +1,6 @@ -- This file should undo anything in `up.sql` -DROP TABLE IF EXISTS `participants`; DROP TABLE IF EXISTS `conversation_participants`; +DROP TABLE IF EXISTS `messages`; +DROP TABLE IF EXISTS `conversation_messages`; +DROP TABLE IF EXISTS `participants`; DROP TABLE IF EXISTS `conversations`; diff --git a/kordophone-db/migrations/2024-12-15-030301_create_conversations/up.sql b/kordophone-db/migrations/2025-01-21-051154_create_conversations/up.sql similarity index 56% rename from kordophone-db/migrations/2024-12-15-030301_create_conversations/up.sql rename to kordophone-db/migrations/2025-01-21-051154_create_conversations/up.sql index 9ac80cd..69c3b8b 100644 --- a/kordophone-db/migrations/2024-12-15-030301_create_conversations/up.sql +++ b/kordophone-db/migrations/2025-01-21-051154_create_conversations/up.sql @@ -1,15 +1,29 @@ -- Your SQL goes here -CREATE TABLE `participants`( - `id` INTEGER NOT NULL PRIMARY KEY, - `display_name` TEXT NOT NULL -); - CREATE TABLE `conversation_participants`( `conversation_id` TEXT NOT NULL, `participant_id` INTEGER NOT NULL, PRIMARY KEY(`conversation_id`, `participant_id`) ); +CREATE TABLE `messages`( + `id` TEXT NOT NULL PRIMARY KEY, + `text` TEXT NOT NULL, + `sender_participant_id` INTEGER, + `date` TIMESTAMP NOT NULL +); + +CREATE TABLE `conversation_messages`( + `conversation_id` TEXT NOT NULL, + `message_id` TEXT NOT NULL, + PRIMARY KEY(`conversation_id`, `message_id`) +); + +CREATE TABLE `participants`( + `id` INTEGER NOT NULL PRIMARY KEY, + `display_name` TEXT, + `is_me` BOOL NOT NULL +); + CREATE TABLE `conversations`( `id` TEXT NOT NULL PRIMARY KEY, `unread_count` BIGINT NOT NULL, diff --git a/kordophone-db/src/chat_database.rs b/kordophone-db/src/chat_database.rs index 380659d..1e310fb 100644 --- a/kordophone-db/src/chat_database.rs +++ b/kordophone-db/src/chat_database.rs @@ -3,11 +3,18 @@ use diesel::{prelude::*, sqlite::Sqlite}; use diesel::query_dsl::BelongingToDsl; use std::path::{Path, PathBuf}; +use crate::models::Participant; use crate::{ models::{ Conversation, + Message, db::conversation::Record as ConversationRecord, - db::participant::{Record as ParticipantRecord, ConversationParticipant}, + db::participant::{ + ConversationParticipant, + Record as ParticipantRecord, + InsertableRecord as InsertableParticipantRecord + }, + db::message::Record as MessageRecord, }, schema, }; @@ -24,6 +31,14 @@ impl ChatDatabase { Self::new(":memory:") } + // Helper function to get the last inserted row ID + // This is a workaround since the Sqlite backend doesn't support `RETURNING` + // Huge caveat with this is that it depends on whatever the last insert was, prevents concurrent inserts. + fn last_insert_id(&mut self) -> Result { + Ok(diesel::select(diesel::dsl::sql::("last_insert_rowid()")) + .get_result(&mut self.db)?) + } + pub fn new(db_path: &str) -> Result { let mut db = SqliteConnection::establish(db_path)?; db.run_pending_migrations(MIGRATIONS) @@ -111,4 +126,88 @@ impl ChatDatabase { Ok(result) } -} \ No newline at end of file + + pub fn insert_message(&mut self, conversation_guid: &str, message: Message) -> Result<()> { + use crate::schema::messages::dsl::*; + use crate::schema::conversation_messages::dsl::*; + + // Handle participant if message has a remote sender + let sender = message.sender.clone(); + let mut db_message: MessageRecord = message.into(); + db_message.sender_participant_id = self.get_or_create_participant(&sender); + + diesel::replace_into(messages) + .values(&db_message) + .execute(&mut self.db)?; + + diesel::replace_into(conversation_messages) + .values(( + conversation_id.eq(conversation_guid), + message_id.eq(&db_message.id), + )) + .execute(&mut self.db)?; + + Ok(()) + } + + pub fn get_messages_for_conversation(&mut self, conversation_guid: &str) -> Result> { + use crate::schema::messages::dsl::*; + use crate::schema::conversation_messages::dsl::*; + use crate::schema::participants::dsl::*; + + let message_records = conversation_messages + .filter(conversation_id.eq(conversation_guid)) + .inner_join(messages) + .select(MessageRecord::as_select()) + .order_by(schema::messages::date.asc()) + .load::(&mut self.db)?; + + let mut result = Vec::new(); + for message_record in message_records { + let mut message: Message = message_record.clone().into(); + + // If there's a sender_participant_id, load the participant info + if let Some(pid) = message_record.sender_participant_id { + let participant = participants + .find(pid) + .first::(&mut self.db)?; + message.sender = participant.into(); + } + + result.push(message); + } + + Ok(result) + } + + fn get_or_create_participant(&mut self, participant: &Participant) -> Option { + match participant { + Participant::Me => None, + Participant::Remote { display_name: p_name, .. } => { + use crate::schema::participants::dsl::*; + + let existing_participant = participants + .filter(display_name.eq(p_name)) + .first::(&mut self.db) + .optional() + .unwrap(); + + if let Some(participant) = existing_participant { + return Some(participant.id); + } + + let participant_record = InsertableParticipantRecord { + display_name: Some(participant.display_name()), + is_me: false, + }; + + diesel::insert_into(participants) + .values(&participant_record) + .execute(&mut self.db) + .unwrap(); + + self.last_insert_id().ok() + } + } + } +} diff --git a/kordophone-db/src/models/db/message.rs b/kordophone-db/src/models/db/message.rs new file mode 100644 index 0000000..67c7392 --- /dev/null +++ b/kordophone-db/src/models/db/message.rs @@ -0,0 +1,40 @@ +use diesel::prelude::*; +use chrono::NaiveDateTime; +use crate::models::{Message, Participant}; + +#[derive(Queryable, Selectable, Insertable, AsChangeset, Clone, Identifiable)] +#[diesel(table_name = crate::schema::messages)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct Record { + pub id: String, + pub sender_participant_id: Option, + pub text: String, + pub date: NaiveDateTime, +} + +impl From for Record { + fn from(message: Message) -> Self { + Self { + id: message.id, + sender_participant_id: match message.sender { + Participant::Me => None, + Participant::Remote { id, .. } => id, + }, + text: message.text, + date: message.date, + } + } +} + +impl From for Message { + fn from(record: Record) -> Self { + Self { + id: record.id, + // We'll set the proper sender later when loading participant info + sender: Participant::Me, + text: record.text, + date: record.date, + } + } +} + diff --git a/kordophone-db/src/models/db/mod.rs b/kordophone-db/src/models/db/mod.rs index 6c3c3f6..eeedf6c 100644 --- a/kordophone-db/src/models/db/mod.rs +++ b/kordophone-db/src/models/db/mod.rs @@ -1,2 +1,3 @@ pub mod conversation; pub mod participant; +pub mod message; \ No newline at end of file diff --git a/kordophone-db/src/models/db/participant.rs b/kordophone-db/src/models/db/participant.rs index ba6d50b..e40dae9 100644 --- a/kordophone-db/src/models/db/participant.rs +++ b/kordophone-db/src/models/db/participant.rs @@ -6,19 +6,28 @@ use crate::schema::conversation_participants; #[diesel(table_name = crate::schema::participants)] pub struct Record { pub id: i32, - pub display_name: String + pub display_name: Option, + pub is_me: bool, } #[derive(Insertable)] #[diesel(table_name = crate::schema::participants)] pub struct InsertableRecord { - pub display_name: String + pub display_name: Option, + pub is_me: bool, } impl From for InsertableRecord { fn from(participant: Participant) -> Self { - InsertableRecord { - display_name: participant.display_name + match participant { + Participant::Me => InsertableRecord { + display_name: None, + is_me: true, + }, + Participant::Remote { display_name, .. } => InsertableRecord { + display_name: Some(display_name), + is_me: false, + } } } } @@ -35,17 +44,30 @@ pub struct ConversationParticipant { impl From for Participant { fn from(record: Record) -> Self { - Participant { - display_name: record.display_name + if record.is_me { + Participant::Me + } else { + Participant::Remote { + id: Some(record.id), + display_name: record.display_name.unwrap_or_default(), + } } } } impl From for Record { fn from(participant: Participant) -> Self { - Record { - id: 0, // This will be set by the database - display_name: participant.display_name, + match participant { + Participant::Me => Record { + id: 0, // This will be set by the database + display_name: None, + is_me: true, + }, + Participant::Remote { display_name, .. } => Record { + id: 0, // This will be set by the database + display_name: Some(display_name), + is_me: false, + } } } } \ No newline at end of file diff --git a/kordophone-db/src/models/message.rs b/kordophone-db/src/models/message.rs new file mode 100644 index 0000000..9ef76d9 --- /dev/null +++ b/kordophone-db/src/models/message.rs @@ -0,0 +1,84 @@ +use chrono::{DateTime, NaiveDateTime}; +use uuid::Uuid; +use crate::models::participant::Participant; + +#[derive(Clone, Debug)] +pub struct Message { + pub id: String, + pub sender: Participant, + pub text: String, + pub date: NaiveDateTime, +} + +impl Message { + pub fn builder() -> MessageBuilder { + MessageBuilder::new() + } +} + +impl From for Message { + fn from(value: kordophone::model::Message) -> Self { + Self { + id: value.guid, + sender: match value.sender { + Some(sender) => Participant::Remote { + id: None, + display_name: sender, + }, + None => Participant::Me, + }, + text: value.text, + date: DateTime::from_timestamp( + value.date.unix_timestamp(), + value.date.unix_timestamp_nanos() + .try_into() + .unwrap_or(0), + ) + .unwrap() + .naive_local() + } + } +} + +pub struct MessageBuilder { + id: Option, + sender: Option, + text: Option, + date: Option, +} + +impl MessageBuilder { + pub fn new() -> Self { + Self { + id: None, + sender: None, + text: None, + date: None, + } + } + + pub fn sender(mut self, sender: Participant) -> Self { + self.sender = Some(sender); + self + } + + pub fn text(mut self, text: String) -> Self { + self.text = Some(text); + self + } + + pub fn date(mut self, date: NaiveDateTime) -> Self { + self.date = Some(date); + self + } + + pub fn build(self) -> Message { + Message { + id: self.id.unwrap_or_else(|| Uuid::new_v4().to_string()), + sender: self.sender.unwrap_or(Participant::Me), + text: self.text.unwrap_or_default(), + date: self.date.unwrap_or_else(|| chrono::Utc::now().naive_utc()), + } + } +} + diff --git a/kordophone-db/src/models/mod.rs b/kordophone-db/src/models/mod.rs index f0b8b0c..206eb44 100644 --- a/kordophone-db/src/models/mod.rs +++ b/kordophone-db/src/models/mod.rs @@ -1,6 +1,8 @@ pub mod conversation; pub mod participant; +pub mod message; pub mod db; pub use conversation::Conversation; -pub use participant::Participant; \ No newline at end of file +pub use participant::Participant; +pub use message::Message; \ No newline at end of file diff --git a/kordophone-db/src/models/participant.rs b/kordophone-db/src/models/participant.rs index 9528e2e..f643202 100644 --- a/kordophone-db/src/models/participant.rs +++ b/kordophone-db/src/models/participant.rs @@ -1,16 +1,35 @@ #[derive(Debug, Clone, PartialEq)] -pub struct Participant { - pub display_name: String, +pub enum Participant { + Me, + Remote { + id: Option, + display_name: String, + }, } impl From for Participant { fn from(display_name: String) -> Self { - Participant { display_name } + Participant::Remote { + id: None, + display_name, + } } } impl From<&str> for Participant { fn from(display_name: &str) -> Self { - Participant { display_name: display_name.to_string() } + Participant::Remote { + id: None, + display_name: display_name.to_string(), + } + } +} + +impl Participant { + pub fn display_name(&self) -> String { + match self { + Participant::Me => "(Me)".to_string(), + Participant::Remote { display_name, .. } => display_name.clone(), + } } } diff --git a/kordophone-db/src/schema.rs b/kordophone-db/src/schema.rs index 19556cb..f1708b9 100644 --- a/kordophone-db/src/schema.rs +++ b/kordophone-db/src/schema.rs @@ -1,3 +1,6 @@ +// When this file changes, run the following command to generate a new migration: +// DATABASE_URL=/tmp/db.sql diesel migration generate --diff-schema create_conversations + diesel::table! { conversations (id) { id -> Text, @@ -11,7 +14,8 @@ diesel::table! { diesel::table! { participants (id) { id -> Integer, - display_name -> Text, + display_name -> Nullable, + is_me -> Bool, } } @@ -22,6 +26,26 @@ diesel::table! { } } +diesel::table! { + messages (id) { + id -> Text, // guid + text -> Text, + sender_participant_id -> Nullable, + date -> Timestamp, + } +} + +diesel::table! { + conversation_messages (conversation_id, message_id) { + conversation_id -> Text, // guid + message_id -> Text, // guid + } +} + diesel::joinable!(conversation_participants -> conversations (conversation_id)); diesel::joinable!(conversation_participants -> participants (participant_id)); diesel::allow_tables_to_appear_in_same_query!(conversations, participants, conversation_participants); + +diesel::joinable!(conversation_messages -> conversations (conversation_id)); +diesel::joinable!(conversation_messages -> messages (message_id)); +diesel::allow_tables_to_appear_in_same_query!(conversations, messages, conversation_messages); \ No newline at end of file diff --git a/kordophone-db/src/tests/mod.rs b/kordophone-db/src/tests/mod.rs index d29563d..8434447 100644 --- a/kordophone-db/src/tests/mod.rs +++ b/kordophone-db/src/tests/mod.rs @@ -2,10 +2,28 @@ use crate::{ chat_database::ChatDatabase, models::{ conversation::{Conversation, ConversationBuilder}, - participant::Participant + participant::Participant, + message::Message, } }; +// Helper function to compare participants ignoring database IDs +fn participants_equal_ignoring_id(a: &Participant, b: &Participant) -> bool { + match (a, b) { + (Participant::Me, Participant::Me) => true, + (Participant::Remote { display_name: name_a, .. }, + Participant::Remote { display_name: name_b, .. }) => name_a == name_b, + _ => false + } +} + +fn participants_vec_equal_ignoring_id(a: &[Participant], b: &[Participant]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b.iter()).all(|(a, b)| participants_equal_ignoring_id(a, b)) +} + #[test] fn test_database_init() { let _ = ChatDatabase::new_in_memory().unwrap(); @@ -62,7 +80,7 @@ fn test_conversation_participants() { let read_conversation = db.get_conversation_by_guid(&guid).unwrap().unwrap(); let read_participants = read_conversation.participants; - assert_eq!(participants, read_participants); + assert!(participants_vec_equal_ignoring_id(&participants, &read_participants)); // Try making another conversation with the same participants let conversation = ConversationBuilder::new() @@ -75,7 +93,7 @@ fn test_conversation_participants() { let read_conversation = db.get_conversation_by_guid(&guid).unwrap().unwrap(); let read_participants: Vec = read_conversation.participants; - assert_eq!(participants, read_participants); + assert!(participants_vec_equal_ignoring_id(&participants, &read_participants)); } #[test] @@ -112,6 +130,97 @@ fn test_all_conversations_with_participants() { let conv1 = all_conversations.iter().find(|c| c.guid == guid1).unwrap(); let conv2 = all_conversations.iter().find(|c| c.guid == guid2).unwrap(); - assert_eq!(conv1.participants, participants1); - assert_eq!(conv2.participants, participants2); + assert!(participants_vec_equal_ignoring_id(&conv1.participants, &participants1)); + assert!(participants_vec_equal_ignoring_id(&conv2.participants, &participants2)); +} + +#[test] +fn test_messages() { + let mut db = ChatDatabase::new_in_memory().unwrap(); + + // First create a conversation with participants + let participants = vec!["Alice".into(), "Bob".into()]; + let conversation = ConversationBuilder::new() + .display_name("Test Chat") + .participants(participants) + .build(); + let conversation_id = conversation.guid.clone(); + + db.insert_conversation(conversation).unwrap(); + + // Create and insert a message from Me + let message1 = Message::builder() + .text("Hello everyone!".to_string()) + .build(); + + // Create and insert a message from a remote participant + let message2 = Message::builder() + .text("Hi there!".to_string()) + .sender("Alice".into()) + .build(); + + // Insert both messages + db.insert_message(&conversation_id, message1.clone()).unwrap(); + db.insert_message(&conversation_id, message2.clone()).unwrap(); + + // Retrieve messages + let messages = db.get_messages_for_conversation(&conversation_id).unwrap(); + assert_eq!(messages.len(), 2); + + // Verify first message (from Me) + let retrieved_message1 = messages.iter().find(|m| m.id == message1.id).unwrap(); + assert_eq!(retrieved_message1.text, "Hello everyone!"); + assert!(matches!(retrieved_message1.sender, Participant::Me)); + + // Verify second message (from Alice) + let retrieved_message2 = messages.iter().find(|m| m.id == message2.id).unwrap(); + assert_eq!(retrieved_message2.text, "Hi there!"); + if let Participant::Remote { display_name, .. } = &retrieved_message2.sender { + assert_eq!(display_name, "Alice"); + } else { + panic!("Expected Remote participant. Got: {:?}", retrieved_message2.sender); + } +} + +#[test] +fn test_message_ordering() { + let mut db = ChatDatabase::new_in_memory().unwrap(); + + // Create a conversation + let conversation = ConversationBuilder::new() + .display_name("Test Chat") + .build(); + let conversation_id = conversation.guid.clone(); + db.insert_conversation(conversation).unwrap(); + + // Create messages with specific timestamps + let now = chrono::Utc::now().naive_utc(); + let message1 = Message::builder() + .text("First message".to_string()) + .date(now) + .build(); + + let message2 = Message::builder() + .text("Second message".to_string()) + .date(now + chrono::Duration::minutes(1)) + .build(); + + let message3 = Message::builder() + .text("Third message".to_string()) + .date(now + chrono::Duration::minutes(2)) + .build(); + + // Insert messages + db.insert_message(&conversation_id, message1).unwrap(); + db.insert_message(&conversation_id, message2).unwrap(); + db.insert_message(&conversation_id, message3).unwrap(); + + // Retrieve messages and verify order + let messages = db.get_messages_for_conversation(&conversation_id).unwrap(); + assert_eq!(messages.len(), 3); + + // Messages should be ordered by date + for i in 1..messages.len() { + assert!(messages[i].date > messages[i-1].date); + } } \ No newline at end of file