Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions crates/db-user/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ impl std::ops::Deref for UserDatabase {
}

// Append only. Do not reorder.
const MIGRATIONS: [&str; 27] = [
const MIGRATIONS: [&str; 28] = [
include_str!("./calendars_migration.sql"),
include_str!("./configs_migration.sql"),
include_str!("./events_migration.sql"),
Expand Down Expand Up @@ -170,8 +170,25 @@ const MIGRATIONS: [&str; 27] = [
include_str!("./templates_migration_1.sql"),
include_str!("./chat_conversations_migration.sql"),
include_str!("./chat_messages_v2_migration.sql"),
include_str!("./templates_migration_2.sql"),
];

/// Run the bundled SQL migrations against the given database.
///
/// Applies the module's embedded migrations in order to bring the database schema up to date.
///
/// # Returns
///
/// `Ok(())` on success, `Err(crate::Error)` if obtaining a connection or applying migrations fails.
///
/// # Examples
///
/// ```no_run
/// # async fn run() -> Result<(), crate::Error> {
/// let db = /* obtain a UserDatabase */;
/// migrate(&db).await?;
/// # Ok(()) }
/// ```
pub async fn migrate(db: &UserDatabase) -> Result<(), crate::Error> {
let conn = db.conn()?;
hypr_db_core::migrate(&conn, MIGRATIONS.to_vec()).await?;
Expand All @@ -198,4 +215,4 @@ mod tests {
let user_id = uuid::Uuid::new_v4().to_string();
init::seed(&db, user_id).await.unwrap();
}
}
}
71 changes: 68 additions & 3 deletions crates/db-user/src/templates_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@ impl UserDatabase {
Ok(items)
}

/// Inserts a new template or updates an existing one identified by its `id`, then returns the stored template.
///
/// If a row with the same `id` already exists, this operation updates its `title`, `description`, `sections`, `tags`, and `context_option` and returns the resulting row; otherwise it inserts a new row and returns it. Errors are propagated for database or row-conversion failures.
///
/// # Examples
///
/// ```no_run
/// # use crate::{UserDatabase, Template};
/// # async fn example(db: &UserDatabase, template: Template) -> Result<(), crate::Error> {
/// let stored = db.upsert_template(template).await?;
/// // `stored` now contains the inserted or updated Template as persisted in the database.
/// # Ok(())
/// # }
/// ```
///
/// Returns the inserted or updated `Template`.
pub async fn upsert_template(&self, template: Template) -> Result<Template, crate::Error> {
let conn = self.conn()?;

Expand All @@ -31,15 +47,17 @@ impl UserDatabase {
description,
sections,
tags,
context_option
context_option,
created_at
) VALUES (
:id,
:user_id,
:title,
:description,
:sections,
:tags,
:context_option
:context_option,
:created_at
) ON CONFLICT(id) DO UPDATE SET
title = :title,
description = :description,
Expand All @@ -55,6 +73,7 @@ impl UserDatabase {
":sections": serde_json::to_string(&template.sections).unwrap(),
":tags": serde_json::to_string(&template.tags).unwrap(),
":context_option": template.context_option.as_deref().unwrap_or(""),
":created_at": template.created_at,
},
)
.await?;
Expand All @@ -77,6 +96,50 @@ impl UserDatabase {
mod tests {
use crate::{tests::setup_db, Human, Template};

/// Integration test that verifies listing templates for a user is empty, upserting a template, and then retrieving the inserted template.
///
/// This test:
/// - Creates a test database and a Human (user).
/// - Asserts that listing templates for the user initially returns zero results.
/// - Inserts a Template using `upsert_template`.
/// - Asserts that listing templates for the user returns one result afterwards.
///
/// # Examples
///
/// ```
/// # async fn run_test(db: &crate::UserDatabase) {
/// let human = db
/// .upsert_human(crate::Human {
/// full_name: Some("test".to_string()),
/// ..crate::Human::default()
/// })
/// .await
/// .unwrap();
///
/// let templates = db.list_templates(&human.id).await.unwrap();
/// assert_eq!(templates.len(), 0);
///
/// let _template = db
/// .upsert_template(crate::Template {
/// id: uuid::Uuid::new_v4().to_string(),
/// user_id: human.id.clone(),
/// title: "test".to_string(),
/// description: "test".to_string(),
/// sections: vec![],
/// tags: vec![],
/// context_option: Some(
/// r#"{"type":"tags","selections":["Meeting","Project A"]}"#.to_string(),
/// ),
/// created_at: chrono::Utc::now()
/// .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
/// })
/// .await
/// .unwrap();
///
/// let templates = db.list_templates(&human.id).await.unwrap();
/// assert_eq!(templates.len(), 1);
/// # }
/// ```
#[tokio::test]
async fn test_templates() {
let db = setup_db().await;
Expand All @@ -103,11 +166,13 @@ mod tests {
context_option: Some(
r#"{"type":"tags","selections":["Meeting","Project A"]}"#.to_string(),
),
created_at: chrono::Utc::now()
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
})
.await
.unwrap();

let templates = db.list_templates(&human.id).await.unwrap();
assert_eq!(templates.len(), 1);
}
}
}
27 changes: 26 additions & 1 deletion crates/db-user/src/templates_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ user_common_derives! {
pub sections: Vec<TemplateSection>,
pub tags: Vec<String>,
pub context_option: Option<String>,
pub created_at: String,
}
}

Expand All @@ -20,6 +21,27 @@ user_common_derives! {
}

impl Template {
/// Constructs a `Template` from a database row.
///
/// Maps columns to `Template` fields as follows:
/// - column 0 → `id` (panics if missing),
/// - column 1 → `user_id` (panics if missing),
/// - column 2 → `title` (panics if missing),
/// - column 3 → `description` (panics if missing),
/// - column 4 → `sections` (expects JSON string; defaults to empty `Vec` if absent),
/// - column 5 → `tags` (expects JSON string; defaults to empty `Vec` if absent),
/// - column 6 → `context_option` (set to `None` if missing or unreadable),
/// - column 7 → `created_at` (falls back to current UTC time in RFC3339 seconds precision if unavailable).
///
/// Note: JSON deserialization for `sections` and `tags` is unwrapped and will panic on invalid JSON. Required primitive columns (id, user_id, title, description) use `expect` and will panic if absent.
///
/// # Examples
///
/// ```ignore
/// // `row` must be obtained from libsql query results.
/// let template = Template::from_row(&row).expect("valid row");
/// assert!(!template.id.is_empty());
/// ```
pub fn from_row(row: &libsql::Row) -> Result<Self, serde::de::value::Error> {
Ok(Self {
id: row.get(0).expect("id"),
Expand All @@ -35,6 +57,9 @@ impl Template {
.map(|s| serde_json::from_str(s).unwrap())
.unwrap_or_default(),
context_option: row.get(6).ok(),
created_at: row.get(7).unwrap_or_else(|_| {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}),
})
}
}
}
Loading