Private
Public Access
1
0

Add 'core/' from commit 'b0dfc4146ca0da535a87f8509aec68817fb2ab14'

git-subtree-dir: core
git-subtree-mainline: a07f3dcd23
git-subtree-split: b0dfc4146c
This commit is contained in:
2025-09-06 19:33:33 -07:00
83 changed files with 12352 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
[package]
name = "kordophone-db"
version = "1.0.0"
edition = "2021"
[dependencies]
anyhow = "1.0.94"
async-trait = "0.1.88"
bincode = "1.3.3"
chrono = "0.4.38"
diesel = { version = "2.2.6", features = ["chrono", "sqlite", "time"] }
diesel_migrations = { version = "2.2.0", features = ["sqlite"] }
kordophone = { path = "../kordophone" }
log = "0.4.27"
serde = { version = "1.0.215", features = ["derive"] }
serde_json = "1.0"
time = "0.3.37"
tokio = "1.44.2"
uuid = { version = "1.11.0", features = ["v4"] }

View File

@@ -0,0 +1,9 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId"]
[migrations_directory]
dir = "migrations"

View File

View File

@@ -0,0 +1,7 @@
-- This file should undo anything in `up.sql`
DROP TABLE IF EXISTS `messages`;
DROP TABLE IF EXISTS `conversation_messages`;
DROP TABLE IF EXISTS `settings`;
DROP TABLE IF EXISTS `conversations`;
DROP TABLE IF EXISTS `participants`;
DROP TABLE IF EXISTS `conversation_participants`;

View File

@@ -0,0 +1,46 @@
-- Your SQL goes here
CREATE TABLE `messages`(
`id` TEXT NOT NULL PRIMARY KEY,
`text` TEXT NOT NULL,
`sender_participant_handle` TEXT,
`date` TIMESTAMP NOT NULL,
`file_transfer_guids` TEXT,
`attachment_metadata` TEXT,
FOREIGN KEY (`sender_participant_handle`) REFERENCES `participants`(`handle`)
);
CREATE TABLE `conversation_messages`(
`conversation_id` TEXT NOT NULL,
`message_id` TEXT NOT NULL,
PRIMARY KEY(`conversation_id`, `message_id`),
FOREIGN KEY (`conversation_id`) REFERENCES `conversations`(`id`),
FOREIGN KEY (`message_id`) REFERENCES `messages`(`id`)
);
CREATE TABLE `settings`(
`key` TEXT NOT NULL PRIMARY KEY,
`value` BINARY NOT NULL
);
CREATE TABLE `conversations`(
`id` TEXT NOT NULL PRIMARY KEY,
`unread_count` BIGINT NOT NULL,
`display_name` TEXT,
`last_message_preview` TEXT,
`date` TIMESTAMP NOT NULL
);
CREATE TABLE `participants`(
`handle` TEXT NOT NULL PRIMARY KEY,
`is_me` BOOL NOT NULL,
`contact_id` TEXT
);
CREATE TABLE `conversation_participants`(
`conversation_id` TEXT NOT NULL,
`participant_handle` TEXT NOT NULL,
PRIMARY KEY(`conversation_id`, `participant_handle`),
FOREIGN KEY (`conversation_id`) REFERENCES `conversations`(`id`),
FOREIGN KEY (`participant_handle`) REFERENCES `participants`(`handle`)
);

View File

@@ -0,0 +1,94 @@
use anyhow::Result;
use async_trait::async_trait;
use diesel::prelude::*;
pub use std::sync::Arc;
pub use tokio::sync::Mutex;
use crate::repository::Repository;
use crate::settings::Settings;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!();
#[async_trait]
pub trait DatabaseAccess {
async fn with_repository<F, R>(&mut self, f: F) -> R
where
F: FnOnce(&mut Repository) -> R + Send,
R: Send;
async fn with_settings<F, R>(&mut self, f: F) -> R
where
F: FnOnce(&mut Settings) -> R + Send,
R: Send;
}
pub struct Database {
pub connection: SqliteConnection,
}
impl Database {
pub fn new(path: &str) -> Result<Self> {
let mut connection = SqliteConnection::establish(path)?;
// Performance optimisations for SQLite. These are safe defaults that speed
// up concurrent writes and cut the fsync cost dramatically while still
// keeping durability guarantees that are good enough for an end-user
// application.
diesel::sql_query("PRAGMA journal_mode = WAL;").execute(&mut connection)?;
diesel::sql_query("PRAGMA synchronous = NORMAL;").execute(&mut connection)?;
connection
.run_pending_migrations(MIGRATIONS)
.map_err(|e| anyhow::anyhow!("Error running migrations: {}", e))?;
Ok(Self { connection })
}
pub fn new_in_memory() -> Result<Self> {
Self::new(":memory:")
}
}
#[async_trait]
impl DatabaseAccess for Database {
async fn with_repository<F, R>(&mut self, f: F) -> R
where
F: FnOnce(&mut Repository) -> R + Send,
R: Send,
{
let mut repository = Repository::new(&mut self.connection);
f(&mut repository)
}
async fn with_settings<F, R>(&mut self, f: F) -> R
where
F: FnOnce(&mut Settings) -> R + Send,
R: Send,
{
let mut settings = Settings::new(&mut self.connection);
f(&mut settings)
}
}
#[async_trait]
impl DatabaseAccess for Arc<Mutex<Database>> {
async fn with_repository<F, R>(&mut self, f: F) -> R
where
F: FnOnce(&mut Repository) -> R + Send,
R: Send,
{
let mut database = self.lock().await;
database.with_repository(f).await
}
async fn with_settings<F, R>(&mut self, f: F) -> R
where
F: FnOnce(&mut Settings) -> R + Send,
R: Send,
{
let mut database = self.lock().await;
database.with_settings(f).await
}
}

View File

@@ -0,0 +1,14 @@
pub mod database;
pub mod models;
pub mod repository;
pub mod schema;
pub mod settings;
#[cfg(test)]
mod tests;
pub mod target {
pub static REPOSITORY: &str = "repository";
}
pub use repository::Repository;

View File

@@ -0,0 +1,142 @@
use crate::models::{message::Message, participant::Participant};
use chrono::{DateTime, NaiveDateTime};
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct Conversation {
pub guid: String,
pub unread_count: u16,
pub display_name: Option<String>,
pub last_message_preview: Option<String>,
pub date: NaiveDateTime,
pub participants: Vec<Participant>,
}
impl Conversation {
pub fn builder() -> ConversationBuilder {
ConversationBuilder::new()
}
pub fn into_builder(&self) -> ConversationBuilder {
ConversationBuilder {
guid: Some(self.guid.clone()),
date: self.date,
participants: None,
unread_count: Some(self.unread_count),
last_message_preview: self.last_message_preview.clone(),
display_name: self.display_name.clone(),
}
}
pub fn merge(&self, other: &Conversation, last_message: Option<&Message>) -> Conversation {
let mut new_conversation = self.clone();
new_conversation.unread_count = other.unread_count;
new_conversation.participants = other.participants.clone();
new_conversation.display_name = other.display_name.clone();
if let Some(last_message) = last_message {
if last_message.date > self.date {
new_conversation.date = last_message.date;
}
if !last_message.text.is_empty() && !last_message.text.trim().is_empty() {
new_conversation.last_message_preview = Some(last_message.text.clone());
}
}
new_conversation
}
}
impl PartialEq for Conversation {
fn eq(&self, other: &Self) -> bool {
self.guid == other.guid
&& self.unread_count == other.unread_count
&& self.display_name == other.display_name
&& self.last_message_preview == other.last_message_preview
&& self.date == other.date
&& self.participants == other.participants
}
}
impl From<kordophone::model::Conversation> for Conversation {
fn from(value: kordophone::model::Conversation) -> Self {
Self {
guid: value.guid,
unread_count: u16::try_from(value.unread_count).unwrap(),
display_name: value.display_name,
last_message_preview: value.last_message_preview,
date: DateTime::from_timestamp(
value.date.unix_timestamp(),
value.date.unix_timestamp_nanos().try_into().unwrap_or(0),
)
.unwrap()
.naive_local(),
participants: value
.participant_display_names
.into_iter()
.map(|p| Participant::Remote {
handle: p,
contact_id: None,
}) // todo: this is wrong
.collect(),
}
}
}
#[derive(Default)]
pub struct ConversationBuilder {
guid: Option<String>,
date: NaiveDateTime,
unread_count: Option<u16>,
last_message_preview: Option<String>,
participants: Option<Vec<Participant>>,
display_name: Option<String>,
}
impl ConversationBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn guid(mut self, guid: &str) -> Self {
self.guid = Some(guid.into());
self
}
pub fn date(mut self, date: NaiveDateTime) -> Self {
self.date = date;
self
}
pub fn unread_count(mut self, unread_count: u16) -> Self {
self.unread_count = Some(unread_count);
self
}
pub fn last_message_preview(mut self, last_message_preview: &str) -> Self {
self.last_message_preview = Some(last_message_preview.into());
self
}
pub fn participants(mut self, participants: Vec<Participant>) -> Self {
self.participants = Some(participants);
self
}
pub fn display_name(mut self, display_name: &str) -> Self {
self.display_name = Some(display_name.into());
self
}
pub fn build(&self) -> Conversation {
Conversation {
guid: self.guid.clone().unwrap_or(Uuid::new_v4().to_string()),
unread_count: self.unread_count.unwrap_or(0),
last_message_preview: self.last_message_preview.clone(),
display_name: self.display_name.clone(),
date: self.date,
participants: self.participants.clone().unwrap_or_default(),
}
}
}

View File

@@ -0,0 +1,53 @@
use crate::models::{db::participant::InsertableRecord as InsertableParticipant, Conversation};
use chrono::NaiveDateTime;
use diesel::prelude::*;
#[derive(Queryable, Selectable, Insertable, AsChangeset, Clone, Identifiable)]
#[diesel(table_name = crate::schema::conversations)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct Record {
pub id: String,
pub unread_count: i64,
pub display_name: Option<String>,
pub last_message_preview: Option<String>,
pub date: NaiveDateTime,
}
impl From<Conversation> for Record {
fn from(conversation: Conversation) -> Self {
Self {
id: conversation.guid,
unread_count: conversation.unread_count as i64,
display_name: conversation.display_name,
last_message_preview: conversation.last_message_preview,
date: conversation.date,
}
}
}
// This implementation returns the insertable data types for the conversation and participants
impl From<Conversation> for (Record, Vec<InsertableParticipant>) {
fn from(conversation: Conversation) -> Self {
(
Record::from(conversation.clone()),
conversation
.participants
.into_iter()
.map(InsertableParticipant::from)
.collect(),
)
}
}
impl From<Record> for Conversation {
fn from(record: Record) -> Self {
Self {
guid: record.id,
unread_count: record.unread_count as u16,
display_name: record.display_name,
last_message_preview: record.last_message_preview,
date: record.date,
participants: vec![],
}
}
}

View File

@@ -0,0 +1,70 @@
use crate::models::{Message, Participant};
use chrono::NaiveDateTime;
use diesel::prelude::*;
#[derive(Queryable, Selectable, Insertable, AsChangeset, Clone, Identifiable, Debug)]
#[diesel(table_name = crate::schema::messages)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct Record {
pub id: String,
pub sender_participant_handle: Option<String>,
pub text: String,
pub date: NaiveDateTime,
pub file_transfer_guids: Option<String>, // JSON array
pub attachment_metadata: Option<String>, // JSON string
}
impl From<Message> for Record {
fn from(message: Message) -> Self {
let file_transfer_guids = if message.file_transfer_guids.is_empty() {
None
} else {
Some(serde_json::to_string(&message.file_transfer_guids).unwrap_or_default())
};
let attachment_metadata = message
.attachment_metadata
.map(|metadata| serde_json::to_string(&metadata).unwrap_or_default());
Self {
id: message.id,
sender_participant_handle: match message.sender {
Participant::Me => None,
Participant::Remote { handle, .. } => Some(handle),
},
text: message.text,
date: message.date,
file_transfer_guids,
attachment_metadata,
}
}
}
impl From<Record> for Message {
fn from(record: Record) -> Self {
let file_transfer_guids = record
.file_transfer_guids
.and_then(|json| serde_json::from_str(&json).ok())
.unwrap_or_default();
let attachment_metadata = record
.attachment_metadata
.and_then(|json| serde_json::from_str(&json).ok());
let message_sender = match record.sender_participant_handle {
Some(handle) => Participant::Remote {
handle,
contact_id: None,
},
None => Participant::Me,
};
Self {
id: record.id,
sender: message_sender,
text: record.text,
date: record.date,
file_transfer_guids,
attachment_metadata,
}
}
}

View File

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

View File

@@ -0,0 +1,81 @@
use crate::models::Participant;
use crate::schema::conversation_participants;
use diesel::prelude::*;
#[derive(Queryable, Selectable, AsChangeset, Identifiable)]
#[diesel(table_name = crate::schema::participants)]
#[diesel(primary_key(handle))]
pub struct Record {
pub handle: String,
pub is_me: bool,
pub contact_id: Option<String>,
}
#[derive(Insertable)]
#[diesel(table_name = crate::schema::participants)]
pub struct InsertableRecord {
pub handle: String,
pub is_me: bool,
pub contact_id: Option<String>,
}
impl From<Participant> for InsertableRecord {
fn from(participant: Participant) -> Self {
match participant {
Participant::Me => InsertableRecord {
handle: "me".to_string(),
is_me: true,
contact_id: None,
},
Participant::Remote {
handle, contact_id, ..
} => InsertableRecord {
handle,
is_me: false,
contact_id,
},
}
}
}
#[derive(Identifiable, Selectable, Queryable, Associations, Debug)]
#[diesel(belongs_to(super::conversation::Record, foreign_key = conversation_id))]
#[diesel(belongs_to(Record, foreign_key = participant_handle))]
#[diesel(table_name = conversation_participants)]
#[diesel(primary_key(conversation_id, participant_handle))]
pub struct ConversationParticipant {
pub conversation_id: String,
pub participant_handle: String,
}
impl From<Record> for Participant {
fn from(record: Record) -> Self {
if record.is_me {
Participant::Me
} else {
Participant::Remote {
handle: record.handle.clone(),
contact_id: record.contact_id,
}
}
}
}
impl From<Participant> for Record {
fn from(participant: Participant) -> Self {
match participant {
Participant::Me => Record {
handle: "me".to_string(),
is_me: true,
contact_id: None,
},
Participant::Remote {
handle, contact_id, ..
} => Record {
handle,
is_me: false,
contact_id,
},
}
}
}

View File

@@ -0,0 +1,148 @@
use crate::models::participant::Participant;
use chrono::{DateTime, NaiveDateTime};
use kordophone::model::message::AttachmentMetadata;
use kordophone::model::outgoing_message::OutgoingMessage;
use std::collections::HashMap;
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct Message {
pub id: String,
pub sender: Participant,
pub text: String,
pub date: NaiveDateTime,
pub file_transfer_guids: Vec<String>,
pub attachment_metadata: Option<HashMap<String, AttachmentMetadata>>,
}
impl Message {
pub fn builder() -> MessageBuilder {
MessageBuilder::new()
}
}
impl From<kordophone::model::Message> for Message {
fn from(value: kordophone::model::Message) -> Self {
let sender_participant = match value.sender {
Some(sender) => Participant::Remote {
contact_id: None,
// Weird server quirk: some sender handles are encoded with control characters.
handle: sender
.chars()
.filter(|c| {
!c.is_control()
&& !matches!(
c,
'\u{202A}' | // LRE
'\u{202B}' | // RLE
'\u{202C}' | // PDF
'\u{202D}' | // LRO
'\u{202E}' | // RLO
'\u{2066}' | // LRI
'\u{2067}' | // RLI
'\u{2068}' | // FSI
'\u{2069}' // PDI
)
})
.collect::<String>(),
},
None => Participant::Me,
};
Self {
id: value.guid,
sender: sender_participant,
text: value.text,
date: DateTime::from_timestamp(
value.date.unix_timestamp(),
value.date.unix_timestamp_nanos().try_into().unwrap_or(0),
)
.unwrap()
.naive_local(),
file_transfer_guids: value.file_transfer_guids,
attachment_metadata: value.attachment_metadata,
}
}
}
impl From<&OutgoingMessage> for Message {
fn from(value: &OutgoingMessage) -> Self {
Self {
id: value.guid.to_string(),
sender: Participant::Me,
text: value.text.clone(),
date: value.date,
file_transfer_guids: Vec::new(), // Outgoing messages don't have file transfer GUIDs initially
attachment_metadata: None, // Outgoing messages don't have attachment metadata initially
}
}
}
pub struct MessageBuilder {
id: Option<String>,
sender: Option<Participant>,
text: Option<String>,
date: Option<NaiveDateTime>,
file_transfer_guids: Option<Vec<String>>,
attachment_metadata: Option<HashMap<String, AttachmentMetadata>>,
}
impl Default for MessageBuilder {
fn default() -> Self {
Self::new()
}
}
impl MessageBuilder {
pub fn new() -> Self {
Self {
id: None,
sender: None,
text: None,
date: None,
file_transfer_guids: None,
attachment_metadata: 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 file_transfer_guids(mut self, file_transfer_guids: Vec<String>) -> Self {
self.file_transfer_guids = Some(file_transfer_guids);
self
}
pub fn attachment_metadata(
mut self,
attachment_metadata: HashMap<String, AttachmentMetadata>,
) -> Self {
self.attachment_metadata = Some(attachment_metadata);
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()),
file_transfer_guids: self.file_transfer_guids.unwrap_or_default(),
attachment_metadata: self.attachment_metadata,
}
}
}

View File

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

View File

@@ -0,0 +1,22 @@
#[derive(Debug, Clone, PartialEq)]
pub enum Participant {
Me,
Remote {
handle: String,
contact_id: Option<String>,
},
}
impl Participant {
pub fn handle(&self) -> String {
match self {
Participant::Me => "(Me)".to_string(),
Participant::Remote { handle, .. } => handle.clone(),
}
}
// Temporary alias for backward compatibility
pub fn display_name(&self) -> String {
self.handle()
}
}

View File

@@ -0,0 +1,410 @@
use anyhow::Result;
use diesel::prelude::*;
use diesel::query_dsl::BelongingToDsl;
use std::collections::HashMap;
use crate::{
models::{
db::conversation::Record as ConversationRecord,
db::message::Record as MessageRecord,
db::participant::{
ConversationParticipant, InsertableRecord as InsertableParticipantRecord,
Record as ParticipantRecord,
},
Conversation, Message, Participant,
},
schema, target,
};
pub struct Repository<'a> {
connection: &'a mut SqliteConnection,
}
impl<'a> Repository<'a> {
pub fn new(connection: &'a mut SqliteConnection) -> Self {
Self { connection }
}
pub fn insert_conversation(&mut self, conversation: Conversation) -> Result<()> {
use crate::schema::conversation_participants::dsl::*;
use crate::schema::conversations::dsl::*;
use crate::schema::participants::dsl::*;
let (db_conversation, db_participants) = conversation.into();
diesel::replace_into(conversations)
.values(&db_conversation)
.execute(self.connection)?;
for participant in &db_participants {
diesel::insert_into(participants)
.values(participant)
.on_conflict_do_nothing()
.execute(self.connection)?;
}
// Sqlite backend doesn't support batch insert, so we have to do this manually
for participant in &db_participants {
diesel::replace_into(conversation_participants)
.values((
conversation_id.eq(&db_conversation.id),
participant_handle.eq(&participant.handle),
))
.execute(self.connection)?;
}
Ok(())
}
pub fn get_conversation_by_guid(&mut self, match_guid: &str) -> Result<Option<Conversation>> {
use crate::schema::conversations::dsl::*;
use crate::schema::participants::dsl::*;
let result = conversations
.find(match_guid)
.first::<ConversationRecord>(self.connection)
.optional()?;
if let Some(conversation) = result {
let db_participants = ConversationParticipant::belonging_to(&conversation)
.inner_join(participants)
.select(ParticipantRecord::as_select())
.load::<ParticipantRecord>(self.connection)?;
let mut model_conversation: Conversation = conversation.into();
model_conversation.participants =
db_participants.into_iter().map(|p| p.into()).collect();
return Ok(Some(model_conversation));
}
Ok(None)
}
pub fn all_conversations(&mut self, limit: i32, offset: i32) -> Result<Vec<Conversation>> {
use crate::schema::conversations::dsl::*;
use crate::schema::participants::dsl::*;
let db_conversations = conversations
.order(schema::conversations::date.desc())
.offset(offset as i64)
.limit(limit as i64)
.load::<ConversationRecord>(self.connection)?;
let mut result = Vec::new();
for db_conversation in db_conversations {
let db_participants = ConversationParticipant::belonging_to(&db_conversation)
.inner_join(participants)
.select(ParticipantRecord::as_select())
.load::<ParticipantRecord>(self.connection)?;
let mut model_conversation: Conversation = db_conversation.into();
model_conversation.participants =
db_participants.into_iter().map(|p| p.into()).collect();
result.push(model_conversation);
}
Ok(result)
}
pub fn insert_message(&mut self, conversation_guid: &str, message: Message) -> Result<()> {
use crate::schema::conversation_messages::dsl::*;
use crate::schema::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_handle = self.get_or_create_participant(&sender);
diesel::replace_into(messages)
.values(&db_message)
.execute(self.connection)?;
diesel::replace_into(conversation_messages)
.values((
conversation_id.eq(conversation_guid),
message_id.eq(&db_message.id),
))
.execute(self.connection)?;
// Update conversation date
self.update_conversation_metadata(conversation_guid)?;
Ok(())
}
pub fn insert_messages(
&mut self,
conversation_guid: &str,
in_messages: Vec<Message>,
) -> Result<()> {
use crate::schema::conversation_messages::dsl::*;
use crate::schema::messages::dsl::*;
use crate::schema::participants::dsl as participants_dsl;
#[derive(Insertable)]
#[diesel(table_name = crate::schema::conversation_messages)]
struct InsertableConversationMessage {
pub conversation_id: String,
pub message_id: String,
}
if in_messages.is_empty() {
return Ok(());
}
// Use a single transaction for everything this removes the implicit
// autocommit after every statement which costs a lot when we have many
// individual queries.
self.connection
.transaction::<_, diesel::result::Error, _>(|conn| {
// Cache participant handles we have already looked up / created in a
// typical conversation we only have a handful of participants, but we
// might be processing hundreds of messages. Avoiding an extra SELECT per
// message saves a lot of round-trips to SQLite.
let mut participant_cache: HashMap<String, String> = HashMap::new();
// Prepare collections for the batch inserts.
let mut db_messages: Vec<MessageRecord> = Vec::with_capacity(in_messages.len());
let mut conv_msg_records: Vec<InsertableConversationMessage> =
Vec::with_capacity(in_messages.len());
for message in in_messages {
// Resolve/insert the sender participant only once per display name.
let sender_handle_opt = match &message.sender {
Participant::Me => None,
Participant::Remote { handle, contact_id } => {
if participant_cache.contains_key(handle) {
Some(handle.clone())
} else {
// Ensure participant exists in DB
let exists: Option<String> = participants_dsl::participants
.filter(participants_dsl::handle.eq(handle))
.select(participants_dsl::handle)
.first::<String>(conn)
.optional()?;
if exists.is_none() {
let new_participant = InsertableParticipantRecord {
handle: handle.clone(),
is_me: false,
contact_id: contact_id.clone(),
};
diesel::insert_into(participants_dsl::participants)
.values(&new_participant)
.execute(conn)?;
}
participant_cache.insert(handle.clone(), handle.clone());
Some(handle.clone())
}
}
};
// Convert the message into its DB form.
let mut db_message: MessageRecord = message.into();
db_message.sender_participant_handle = sender_handle_opt.clone();
conv_msg_records.push(InsertableConversationMessage {
conversation_id: conversation_guid.to_string(),
message_id: db_message.id.clone(),
});
db_messages.push(db_message);
}
// Execute the actual batch inserts.
diesel::replace_into(messages)
.values(&db_messages)
.execute(conn)?;
diesel::replace_into(conversation_messages)
.values(&conv_msg_records)
.execute(conn)?;
// Update conversation metadata quickly using the last message we just
// processed instead of re-querying the DB.
if let Some(last_msg) = db_messages.last() {
use crate::schema::conversations::dsl as conv_dsl;
diesel::update(
conv_dsl::conversations.filter(conv_dsl::id.eq(conversation_guid)),
)
.set((
conv_dsl::date.eq(last_msg.date),
conv_dsl::last_message_preview
.eq::<Option<String>>(Some(last_msg.text.clone())),
))
.execute(conn)?;
}
Ok(())
})?;
// TODO: May need to update conversation metadata here, but this has a perf impact.
// Ideally we would consolidate this in the code above, assuming we're only inserting *new* messages, but
// this may not necessarily be the case.
Ok(())
}
pub fn get_messages_for_conversation(
&mut self,
conversation_guid: &str,
) -> Result<Vec<Message>> {
use crate::schema::conversation_messages::dsl::*;
use crate::schema::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>(self.connection)?;
let mut result = Vec::new();
for message_record in message_records {
let mut message: Message = message_record.clone().into();
// If the message references a sender participant, load the participant info
if let Some(sender_handle) = message_record.sender_participant_handle {
let participant = participants
.find(sender_handle)
.first::<ParticipantRecord>(self.connection)?;
message.sender = participant.into();
}
result.push(message);
}
Ok(result)
}
pub fn get_last_message_for_conversation(
&mut self,
conversation_guid: &str,
) -> Result<Option<Message>> {
use crate::schema::conversation_messages::dsl::*;
use crate::schema::messages::dsl::*;
let message_record = conversation_messages
.filter(conversation_id.eq(conversation_guid))
.inner_join(messages)
.select(MessageRecord::as_select())
.order_by(schema::messages::date.desc())
.first::<MessageRecord>(self.connection)
.optional()?;
Ok(message_record.map(|r| r.into()))
}
pub fn delete_all_conversations(&mut self) -> Result<()> {
use crate::schema::conversations::dsl::*;
diesel::delete(conversations).execute(self.connection)?;
Ok(())
}
pub fn delete_all_messages(&mut self) -> Result<()> {
use crate::schema::messages::dsl::*;
diesel::delete(messages).execute(self.connection)?;
Ok(())
}
pub fn merge_conversation_metadata(&mut self, in_conversation: Conversation) -> Result<bool> {
let mut updated = false;
let conversation = self.get_conversation_by_guid(&in_conversation.guid)?;
if let Some(conversation) = conversation {
let merged_conversation = conversation.merge(&in_conversation, None);
if merged_conversation != conversation {
self.insert_conversation(merged_conversation)?;
updated = true;
}
}
log::debug!(target: target::REPOSITORY, "Merged conversation metadata: {} updated: {}", in_conversation.guid, updated);
Ok(updated)
}
fn update_conversation_metadata(&mut self, conversation_guid: &str) -> Result<()> {
let conversation = self.get_conversation_by_guid(conversation_guid)?;
if let Some(conversation) = conversation {
if let Some(last_message) = self.get_last_message_for_conversation(conversation_guid)? {
log::debug!(
target: target::REPOSITORY,
"Updating conversation metadata: {} message: {:?}",
conversation_guid,
last_message
);
let merged_conversation = conversation.merge(&conversation, Some(&last_message));
self.insert_conversation(merged_conversation)?;
}
}
Ok(())
}
// 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(self.connection)?,
)
}
/// Update the contact_id for an existing participant record.
pub fn update_participant_contact(
&mut self,
participant_handle: &str,
new_contact_id: &str,
) -> Result<()> {
use crate::schema::participants::dsl::*;
log::debug!(target: target::REPOSITORY, "Updating participant contact {} => {}", participant_handle, new_contact_id);
diesel::update(participants.filter(handle.eq(participant_handle)))
.set(contact_id.eq(Some(new_contact_id.to_string())))
.execute(self.connection)?;
Ok(())
}
fn get_or_create_participant(&mut self, participant: &Participant) -> Option<String> {
match participant {
Participant::Me => None,
Participant::Remote {
handle: p_handle,
contact_id: c_id,
..
} => {
use crate::schema::participants::dsl::*;
let existing_participant = participants
.filter(handle.eq(p_handle))
.first::<ParticipantRecord>(self.connection)
.optional()
.unwrap();
if existing_participant.is_none() {
let participant_record = InsertableParticipantRecord {
handle: p_handle.clone(),
is_me: false,
contact_id: c_id.clone(),
};
diesel::insert_into(participants)
.values(&participant_record)
.execute(self.connection)
.unwrap();
}
Some(p_handle.clone())
}
}
}
}

View File

@@ -0,0 +1,66 @@
// 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,
unread_count -> BigInt,
display_name -> Nullable<Text>,
last_message_preview -> Nullable<Text>,
date -> Timestamp,
}
}
diesel::table! {
participants (handle) {
handle -> Text,
is_me -> Bool,
contact_id -> Nullable<Text>,
}
}
diesel::table! {
conversation_participants (conversation_id, participant_handle) {
conversation_id -> Text,
participant_handle -> Text,
}
}
diesel::table! {
messages (id) {
id -> Text, // guid
text -> Text,
sender_participant_handle -> Nullable<Text>,
date -> Timestamp,
file_transfer_guids -> Nullable<Text>, // JSON array of file transfer GUIDs
attachment_metadata -> Nullable<Text>, // JSON string of attachment metadata
}
}
diesel::table! {
conversation_messages (conversation_id, message_id) {
conversation_id -> Text, // guid
message_id -> Text, // guid
}
}
diesel::table! {
settings (key) {
key -> Text,
value -> Binary,
}
}
diesel::joinable!(conversation_participants -> conversations (conversation_id));
diesel::joinable!(conversation_participants -> participants (participant_handle));
diesel::joinable!(messages -> participants (sender_participant_handle));
diesel::joinable!(conversation_messages -> conversations (conversation_id));
diesel::joinable!(conversation_messages -> messages (message_id));
diesel::allow_tables_to_appear_in_same_query!(
conversations,
participants,
conversation_participants,
messages,
conversation_messages,
settings,
);

View File

@@ -0,0 +1,63 @@
use anyhow::Result;
use diesel::*;
use serde::{de::DeserializeOwned, Serialize};
#[derive(Insertable, Queryable, AsChangeset)]
#[diesel(table_name = crate::schema::settings)]
struct SettingsRow<'a> {
key: &'a str,
value: &'a [u8],
}
pub struct Settings<'a> {
connection: &'a mut SqliteConnection,
}
impl<'a> Settings<'a> {
pub fn new(connection: &'a mut SqliteConnection) -> Self {
Self { connection }
}
pub fn put<T: Serialize>(&mut self, k: &str, v: &T) -> Result<()> {
use crate::schema::settings::dsl::*;
let bytes = bincode::serialize(v)?;
diesel::insert_into(settings)
.values(SettingsRow {
key: k,
value: &bytes,
})
.on_conflict(key)
.do_update()
.set(value.eq(&bytes))
.execute(self.connection)?;
Ok(())
}
pub fn get<T: DeserializeOwned>(&mut self, k: &str) -> Result<Option<T>> {
use crate::schema::settings::dsl::*;
let blob: Option<Vec<u8>> = settings
.select(value)
.filter(key.eq(k))
.first(self.connection)
.optional()?;
Ok(match blob {
Some(b) => Some(bincode::deserialize(&b)?),
None => None,
})
}
pub fn del(&mut self, k: &str) -> Result<usize> {
use crate::schema::settings::dsl::*;
Ok(diesel::delete(settings.filter(key.eq(k))).execute(self.connection)?)
}
pub fn list_keys(&mut self) -> Result<Vec<String>> {
use crate::schema::settings::dsl::*;
let keys: Vec<String> = settings.select(key).load(self.connection)?;
Ok(keys)
}
}

View File

@@ -0,0 +1,423 @@
use crate::{
database::{Database, DatabaseAccess},
models::{
conversation::{Conversation, ConversationBuilder},
message::Message,
participant::Participant,
},
};
// 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 { handle: name_a, .. },
Participant::Remote { handle: 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;
}
// For each participant in a, check if there is a matching participant in b
a.iter().all(|a_participant| {
b.iter().any(|b_participant| participants_equal_ignoring_id(a_participant, b_participant))
}) &&
// Also check the reverse to ensure no duplicates
b.iter().all(|b_participant| {
a.iter().any(|a_participant| participants_equal_ignoring_id(b_participant, a_participant))
})
}
#[tokio::test]
async fn test_database_init() {
let _ = Database::new_in_memory().unwrap();
}
#[tokio::test]
async fn test_add_conversation() {
let mut db = Database::new_in_memory().unwrap();
db.with_repository(|repository| {
let guid = "test";
let test_conversation = Conversation::builder()
.guid(guid)
.unread_count(2)
.display_name("Test Conversation")
.build();
repository
.insert_conversation(test_conversation.clone())
.unwrap();
// Try to fetch with id now
let conversation = repository.get_conversation_by_guid(guid).unwrap().unwrap();
assert_eq!(conversation.guid, "test");
// Modify the conversation and update it
let modified_conversation = test_conversation
.into_builder()
.display_name("Modified Conversation")
.build();
repository
.insert_conversation(modified_conversation.clone())
.unwrap();
// Make sure we still only have one conversation.
let all_conversations = repository.all_conversations(i32::MAX, 0).unwrap();
assert_eq!(all_conversations.len(), 1);
// And make sure the display name was updated
let conversation = repository.get_conversation_by_guid(guid).unwrap().unwrap();
assert_eq!(conversation.display_name.unwrap(), "Modified Conversation");
})
.await;
}
#[tokio::test]
async fn test_conversation_participants() {
let mut db = Database::new_in_memory().unwrap();
db.with_repository(|repository| {
let participants: Vec<Participant> = vec!["one".into(), "two".into()];
let guid = uuid::Uuid::new_v4().to_string();
let conversation = ConversationBuilder::new()
.guid(&guid)
.display_name("Test")
.participants(participants.clone())
.build();
repository.insert_conversation(conversation).unwrap();
let read_conversation = repository.get_conversation_by_guid(&guid).unwrap().unwrap();
let read_participants = read_conversation.participants;
assert!(participants_vec_equal_ignoring_id(
&participants,
&read_participants
));
// Try making another conversation with the same participants
let conversation = ConversationBuilder::new()
.display_name("A Different Test")
.participants(participants.clone())
.build();
repository.insert_conversation(conversation).unwrap();
let read_conversation = repository.get_conversation_by_guid(&guid).unwrap().unwrap();
let read_participants: Vec<Participant> = read_conversation.participants;
assert!(participants_vec_equal_ignoring_id(
&participants,
&read_participants
));
})
.await;
}
#[tokio::test]
async fn test_all_conversations_with_participants() {
let mut db = Database::new_in_memory().unwrap();
db.with_repository(|repository| {
// Create two conversations with different participants
let participants1: Vec<Participant> = vec!["one".into(), "two".into()];
let participants2: Vec<Participant> = vec!["three".into(), "four".into()];
let guid1 = uuid::Uuid::new_v4().to_string();
let conversation1 = ConversationBuilder::new()
.guid(&guid1)
.display_name("Test 1")
.participants(participants1.clone())
.build();
let guid2 = uuid::Uuid::new_v4().to_string();
let conversation2 = ConversationBuilder::new()
.guid(&guid2)
.display_name("Test 2")
.participants(participants2.clone())
.build();
// Insert both conversations
repository.insert_conversation(conversation1).unwrap();
repository.insert_conversation(conversation2).unwrap();
// Get all conversations and verify the results
let all_conversations = repository.all_conversations(i32::MAX, 0).unwrap();
assert_eq!(all_conversations.len(), 2);
// Find and verify each conversation's participants
let conv1 = all_conversations.iter().find(|c| c.guid == guid1).unwrap();
let conv2 = all_conversations.iter().find(|c| c.guid == guid2).unwrap();
assert!(participants_vec_equal_ignoring_id(
&conv1.participants,
&participants1
));
assert!(participants_vec_equal_ignoring_id(
&conv2.participants,
&participants2
));
})
.await;
}
#[tokio::test]
async fn test_messages() {
let mut db = Database::new_in_memory().unwrap();
db.with_repository(|repository| {
// 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();
repository.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
repository
.insert_message(&conversation_id, message1.clone())
.unwrap();
repository
.insert_message(&conversation_id, message2.clone())
.unwrap();
// Retrieve messages
let messages = repository
.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 { handle, .. } = &retrieved_message2.sender {
assert_eq!(handle, "Alice");
} else {
panic!(
"Expected Remote participant. Got: {:?}",
retrieved_message2.sender
);
}
})
.await;
}
#[tokio::test]
async fn test_message_ordering() {
let mut db = Database::new_in_memory().unwrap();
db.with_repository(|repository| {
// Create a conversation
let conversation = ConversationBuilder::new().display_name("Test Chat").build();
let conversation_id = conversation.guid.clone();
repository.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
repository
.insert_message(&conversation_id, message1)
.unwrap();
repository
.insert_message(&conversation_id, message2)
.unwrap();
repository
.insert_message(&conversation_id, message3)
.unwrap();
// Retrieve messages and verify order
let messages = repository
.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);
}
})
.await;
}
#[tokio::test]
async fn test_insert_messages_batch() {
let mut db = Database::new_in_memory().unwrap();
db.with_repository(|repository| {
// Create a conversation with two remote participants
let participants: Vec<Participant> = vec!["Alice".into(), "Bob".into()];
let conversation = ConversationBuilder::new()
.display_name("Batch Chat")
.participants(participants.clone())
.build();
let conversation_id = conversation.guid.clone();
repository.insert_conversation(conversation).unwrap();
// Prepare a batch of messages with increasing timestamps
let now = chrono::Utc::now().naive_utc();
let message1 = Message::builder().text("Hi".to_string()).date(now).build();
let message2 = Message::builder()
.text("Hello".to_string())
.sender("Alice".into())
.date(now + chrono::Duration::seconds(1))
.build();
let message3 = Message::builder()
.text("How are you?".to_string())
.sender("Bob".into())
.date(now + chrono::Duration::seconds(2))
.build();
let message4 = Message::builder()
.text("Great!".to_string())
.date(now + chrono::Duration::seconds(3))
.build();
let original_messages = vec![
message1.clone(),
message2.clone(),
message3.clone(),
message4.clone(),
];
// Batch insert the messages
repository
.insert_messages(&conversation_id, original_messages.clone())
.unwrap();
// Retrieve messages and verify
let retrieved_messages = repository
.get_messages_for_conversation(&conversation_id)
.unwrap();
assert_eq!(retrieved_messages.len(), original_messages.len());
// Ensure ordering by date
for i in 1..retrieved_messages.len() {
assert!(retrieved_messages[i].date > retrieved_messages[i - 1].date);
}
// Verify that all messages are present with correct content and sender
for original in &original_messages {
let retrieved = retrieved_messages
.iter()
.find(|m| m.id == original.id)
.expect("Message not found");
assert_eq!(retrieved.text, original.text);
match (&original.sender, &retrieved.sender) {
(Participant::Me, Participant::Me) => {}
(
Participant::Remote { handle: o_name, .. },
Participant::Remote { handle: r_name, .. },
) => assert_eq!(o_name, r_name),
_ => panic!(
"Sender mismatch: original {:?}, retrieved {:?}",
original.sender, retrieved.sender
),
}
}
// Make sure the last message is the last one we inserted
let last_message = repository
.get_last_message_for_conversation(&conversation_id)
.unwrap()
.unwrap();
assert_eq!(last_message.id, message4.id);
})
.await;
}
#[tokio::test]
async fn test_settings() {
let mut db = Database::new_in_memory().unwrap();
db.with_settings(|settings| {
settings.put("test", &"test".to_string()).unwrap();
assert_eq!(settings.get::<String>("test").unwrap().unwrap(), "test");
settings.del("test").unwrap();
assert!(settings.get::<String>("test").unwrap().is_none());
let keys = settings.list_keys().unwrap();
assert_eq!(keys.len(), 0);
// Try encoding a struct
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq)]
struct TestStruct {
name: String,
age: u32,
}
let test_struct = TestStruct {
name: "James".to_string(),
age: 35,
};
settings.put("test_struct", &test_struct).unwrap();
assert_eq!(
settings.get::<TestStruct>("test_struct").unwrap().unwrap(),
test_struct
);
// Test with an option<string>
settings
.put("test_struct_option", &Option::<String>::None)
.unwrap();
assert!(settings
.get::<Option<String>>("test_struct_option")
.unwrap()
.unwrap()
.is_none());
settings
.put(
"test_struct_option",
&Option::<String>::Some("test".to_string()),
)
.unwrap();
assert_eq!(
settings
.get::<Option<String>>("test_struct_option")
.unwrap()
.unwrap(),
Some("test".to_string())
);
})
.await;
}