Private
Public Access
1
0

kordophone-db: adds support for the Messages table

This commit is contained in:
2025-01-20 22:05:34 -08:00
parent a8104c379c
commit 146fac2759
11 changed files with 444 additions and 28 deletions

View File

@@ -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<i32> {
Ok(diesel::select(diesel::dsl::sql::<diesel::sql_types::Integer>("last_insert_rowid()"))
.get_result(&mut self.db)?)
}
pub fn new(db_path: &str) -> Result<Self> {
let mut db = SqliteConnection::establish(db_path)?;
db.run_pending_migrations(MIGRATIONS)
@@ -111,4 +126,88 @@ impl ChatDatabase {
Ok(result)
}
}
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<Vec<Message>> {
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::<MessageRecord>(&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::<ParticipantRecord>(&mut self.db)?;
message.sender = participant.into();
}
result.push(message);
}
Ok(result)
}
fn get_or_create_participant(&mut self, participant: &Participant) -> Option<i32> {
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::<ParticipantRecord>(&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()
}
}
}
}

View File

@@ -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<i32>,
pub text: String,
pub date: NaiveDateTime,
}
impl From<Message> 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<Record> 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,
}
}
}

View File

@@ -1,2 +1,3 @@
pub mod conversation;
pub mod participant;
pub mod message;

View File

@@ -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<String>,
pub is_me: bool,
}
#[derive(Insertable)]
#[diesel(table_name = crate::schema::participants)]
pub struct InsertableRecord {
pub display_name: String
pub display_name: Option<String>,
pub is_me: bool,
}
impl From<Participant> 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<Record> 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<Participant> 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,
}
}
}
}

View File

@@ -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<kordophone::model::Message> 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<String>,
sender: Option<Participant>,
text: Option<String>,
date: Option<NaiveDateTime>,
}
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()),
}
}
}

View File

@@ -1,6 +1,8 @@
pub mod conversation;
pub mod participant;
pub mod message;
pub mod db;
pub use conversation::Conversation;
pub use participant::Participant;
pub use participant::Participant;
pub use message::Message;

View File

@@ -1,16 +1,35 @@
#[derive(Debug, Clone, PartialEq)]
pub struct Participant {
pub display_name: String,
pub enum Participant {
Me,
Remote {
id: Option<i32>,
display_name: String,
},
}
impl From<String> 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(),
}
}
}

View File

@@ -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<Text>,
is_me -> Bool,
}
}
@@ -22,6 +26,26 @@ diesel::table! {
}
}
diesel::table! {
messages (id) {
id -> Text, // guid
text -> Text,
sender_participant_id -> Nullable<Integer>,
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);

View File

@@ -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<Participant> = 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);
}
}