Add 'core/' from commit 'b0dfc4146ca0da535a87f8509aec68817fb2ab14'
git-subtree-dir: core git-subtree-mainline:a07f3dcd23git-subtree-split:b0dfc4146c
This commit is contained in:
19
core/kordophone-db/Cargo.toml
Normal file
19
core/kordophone-db/Cargo.toml
Normal 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"] }
|
||||
9
core/kordophone-db/diesel.toml
Normal file
9
core/kordophone-db/diesel.toml
Normal 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"
|
||||
0
core/kordophone-db/migrations/.keep
Normal file
0
core/kordophone-db/migrations/.keep
Normal 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`;
|
||||
@@ -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`)
|
||||
);
|
||||
|
||||
94
core/kordophone-db/src/database.rs
Normal file
94
core/kordophone-db/src/database.rs
Normal 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
|
||||
}
|
||||
}
|
||||
14
core/kordophone-db/src/lib.rs
Normal file
14
core/kordophone-db/src/lib.rs
Normal 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;
|
||||
142
core/kordophone-db/src/models/conversation.rs
Normal file
142
core/kordophone-db/src/models/conversation.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
53
core/kordophone-db/src/models/db/conversation.rs
Normal file
53
core/kordophone-db/src/models/db/conversation.rs
Normal 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![],
|
||||
}
|
||||
}
|
||||
}
|
||||
70
core/kordophone-db/src/models/db/message.rs
Normal file
70
core/kordophone-db/src/models/db/message.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
3
core/kordophone-db/src/models/db/mod.rs
Normal file
3
core/kordophone-db/src/models/db/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod conversation;
|
||||
pub mod message;
|
||||
pub mod participant;
|
||||
81
core/kordophone-db/src/models/db/participant.rs
Normal file
81
core/kordophone-db/src/models/db/participant.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
148
core/kordophone-db/src/models/message.rs
Normal file
148
core/kordophone-db/src/models/message.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
8
core/kordophone-db/src/models/mod.rs
Normal file
8
core/kordophone-db/src/models/mod.rs
Normal 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;
|
||||
22
core/kordophone-db/src/models/participant.rs
Normal file
22
core/kordophone-db/src/models/participant.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
410
core/kordophone-db/src/repository.rs
Normal file
410
core/kordophone-db/src/repository.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
66
core/kordophone-db/src/schema.rs
Normal file
66
core/kordophone-db/src/schema.rs
Normal 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,
|
||||
);
|
||||
63
core/kordophone-db/src/settings.rs
Normal file
63
core/kordophone-db/src/settings.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
423
core/kordophone-db/src/tests/mod.rs
Normal file
423
core/kordophone-db/src/tests/mod.rs
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user