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

@@ -1,4 +1,6 @@
-- This file should undo anything in `up.sql` -- This file should undo anything in `up.sql`
DROP TABLE IF EXISTS `participants`;
DROP TABLE IF EXISTS `conversation_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`; DROP TABLE IF EXISTS `conversations`;

View File

@@ -1,15 +1,29 @@
-- Your SQL goes here -- Your SQL goes here
CREATE TABLE `participants`(
`id` INTEGER NOT NULL PRIMARY KEY,
`display_name` TEXT NOT NULL
);
CREATE TABLE `conversation_participants`( CREATE TABLE `conversation_participants`(
`conversation_id` TEXT NOT NULL, `conversation_id` TEXT NOT NULL,
`participant_id` INTEGER NOT NULL, `participant_id` INTEGER NOT NULL,
PRIMARY KEY(`conversation_id`, `participant_id`) 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`( CREATE TABLE `conversations`(
`id` TEXT NOT NULL PRIMARY KEY, `id` TEXT NOT NULL PRIMARY KEY,
`unread_count` BIGINT NOT NULL, `unread_count` BIGINT NOT NULL,

View File

@@ -3,11 +3,18 @@ use diesel::{prelude::*, sqlite::Sqlite};
use diesel::query_dsl::BelongingToDsl; use diesel::query_dsl::BelongingToDsl;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::models::Participant;
use crate::{ use crate::{
models::{ models::{
Conversation, Conversation,
Message,
db::conversation::Record as ConversationRecord, 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, schema,
}; };
@@ -24,6 +31,14 @@ impl ChatDatabase {
Self::new(":memory:") 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> { pub fn new(db_path: &str) -> Result<Self> {
let mut db = SqliteConnection::establish(db_path)?; let mut db = SqliteConnection::establish(db_path)?;
db.run_pending_migrations(MIGRATIONS) db.run_pending_migrations(MIGRATIONS)
@@ -111,4 +126,88 @@ impl ChatDatabase {
Ok(result) 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 conversation;
pub mod participant; pub mod participant;
pub mod message;

View File

@@ -6,19 +6,28 @@ use crate::schema::conversation_participants;
#[diesel(table_name = crate::schema::participants)] #[diesel(table_name = crate::schema::participants)]
pub struct Record { pub struct Record {
pub id: i32, pub id: i32,
pub display_name: String pub display_name: Option<String>,
pub is_me: bool,
} }
#[derive(Insertable)] #[derive(Insertable)]
#[diesel(table_name = crate::schema::participants)] #[diesel(table_name = crate::schema::participants)]
pub struct InsertableRecord { pub struct InsertableRecord {
pub display_name: String pub display_name: Option<String>,
pub is_me: bool,
} }
impl From<Participant> for InsertableRecord { impl From<Participant> for InsertableRecord {
fn from(participant: Participant) -> Self { fn from(participant: Participant) -> Self {
InsertableRecord { match participant {
display_name: participant.display_name 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 { impl From<Record> for Participant {
fn from(record: Record) -> Self { fn from(record: Record) -> Self {
Participant { if record.is_me {
display_name: record.display_name Participant::Me
} else {
Participant::Remote {
id: Some(record.id),
display_name: record.display_name.unwrap_or_default(),
}
} }
} }
} }
impl From<Participant> for Record { impl From<Participant> for Record {
fn from(participant: Participant) -> Self { fn from(participant: Participant) -> Self {
Record { match participant {
id: 0, // This will be set by the database Participant::Me => Record {
display_name: participant.display_name, 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 conversation;
pub mod participant; pub mod participant;
pub mod message;
pub mod db; pub mod db;
pub use conversation::Conversation; 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)] #[derive(Debug, Clone, PartialEq)]
pub struct Participant { pub enum Participant {
pub display_name: String, Me,
Remote {
id: Option<i32>,
display_name: String,
},
} }
impl From<String> for Participant { impl From<String> for Participant {
fn from(display_name: String) -> Self { fn from(display_name: String) -> Self {
Participant { display_name } Participant::Remote {
id: None,
display_name,
}
} }
} }
impl From<&str> for Participant { impl From<&str> for Participant {
fn from(display_name: &str) -> Self { 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! { diesel::table! {
conversations (id) { conversations (id) {
id -> Text, id -> Text,
@@ -11,7 +14,8 @@ diesel::table! {
diesel::table! { diesel::table! {
participants (id) { participants (id) {
id -> Integer, 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 -> conversations (conversation_id));
diesel::joinable!(conversation_participants -> participants (participant_id)); diesel::joinable!(conversation_participants -> participants (participant_id));
diesel::allow_tables_to_appear_in_same_query!(conversations, participants, conversation_participants); 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, chat_database::ChatDatabase,
models::{ models::{
conversation::{Conversation, ConversationBuilder}, 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] #[test]
fn test_database_init() { fn test_database_init() {
let _ = ChatDatabase::new_in_memory().unwrap(); 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_conversation = db.get_conversation_by_guid(&guid).unwrap().unwrap();
let read_participants = read_conversation.participants; 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 // Try making another conversation with the same participants
let conversation = ConversationBuilder::new() 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_conversation = db.get_conversation_by_guid(&guid).unwrap().unwrap();
let read_participants: Vec<Participant> = read_conversation.participants; let read_participants: Vec<Participant> = read_conversation.participants;
assert_eq!(participants, read_participants); assert!(participants_vec_equal_ignoring_id(&participants, &read_participants));
} }
#[test] #[test]
@@ -112,6 +130,97 @@ fn test_all_conversations_with_participants() {
let conv1 = all_conversations.iter().find(|c| c.guid == guid1).unwrap(); let conv1 = all_conversations.iter().find(|c| c.guid == guid1).unwrap();
let conv2 = all_conversations.iter().find(|c| c.guid == guid2).unwrap(); let conv2 = all_conversations.iter().find(|c| c.guid == guid2).unwrap();
assert_eq!(conv1.participants, participants1); assert!(participants_vec_equal_ignoring_id(&conv1.participants, &participants1));
assert_eq!(conv2.participants, participants2); 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);
}
} }