Create Initial Database Schema for Tasks. (#1)

Create migration scripts for this and initial Task DB Model in rust along with unit tests.

Reviewed-on: #1
Co-authored-by: Drew Galbraith <drew@tiramisu.one>
Co-committed-by: Drew Galbraith <drew@tiramisu.one>
This commit is contained in:
Drew 2025-08-24 17:10:31 +00:00 committed by Drew
parent c2b7c12905
commit 82d524a62f
13 changed files with 1923 additions and 15 deletions

View file

@ -0,0 +1,45 @@
use anyhow::Result;
use sqlx::{SqlitePool, sqlite::SqliteConnectOptions};
use std::str::FromStr;
/// Database configuration
pub struct DatabaseConfig {
pub database_url: String,
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
database_url: "sqlite:local.db".to_string(),
}
}
}
/// Create a SQLx connection pool
pub async fn create_pool(config: &DatabaseConfig) -> Result<SqlitePool> {
let options = SqliteConnectOptions::from_str(&config.database_url)?.create_if_missing(true);
let pool = SqlitePool::connect_with(options).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
/// Initialize database with connection pool
pub async fn initialize_database() -> Result<SqlitePool> {
let config = DatabaseConfig::default();
create_pool(&config).await
}
#[cfg(test)]
pub async fn create_test_pool() -> Result<SqlitePool> {
let options = SqliteConnectOptions::from_str("sqlite::memory:")?.create_if_missing(true);
let pool = SqlitePool::connect_with(options).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}

View file

@ -0,0 +1,3 @@
pub mod connection;
pub use connection::*;

View file

@ -1,5 +1,8 @@
use axum::{Router, routing::get};
mod models;
mod database;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();

View file

@ -0,0 +1,3 @@
mod task;
pub use task::*;

269
backend/src/models/task.rs Normal file
View file

@ -0,0 +1,269 @@
use anyhow::{Result, bail};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, sqlx::Type)]
#[serde(rename_all = "snake_case")]
#[sqlx(rename_all = "lowercase")]
pub enum TaskStatus {
Todo,
Done,
Backlog,
}
#[derive(sqlx::FromRow)]
pub struct TaskModel {
pub id: Uuid,
pub title: String,
pub description: Option<String>,
pub status: TaskStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
impl TaskModel {
pub async fn insert(
pool: &SqlitePool,
title: &str,
description: Option<&str>,
) -> Result<TaskModel> {
if title.len() == 0 {
bail!("Title must not be empty");
}
let id = Uuid::new_v4();
let result = sqlx::query_as(
"INSERT INTO tasks (id, title, description) VALUES ($1, $2, $3) RETURNING *",
)
.bind(id)
.bind(title)
.bind(description)
.fetch_one(pool)
.await?;
Ok(result)
}
pub async fn get_by_id(pool: &SqlitePool, id: Uuid) -> Result<TaskModel> {
let result = sqlx::query_as("SELECT * FROM tasks WHERE id = $1")
.bind(id)
.fetch_one(pool)
.await?;
Ok(result)
}
pub async fn update(self, pool: &SqlitePool) -> Result<TaskModel> {
let now: DateTime<Utc> = Utc::now();
let _ = sqlx::query(
"UPDATE tasks
SET title=$1, description=$2, status=$3, updated_at=$4, completed_at=$5
WHERE id=$6",
)
.bind(&self.title)
.bind(&self.description)
.bind(&self.status)
.bind(now)
.bind(&self.completed_at)
.bind(&self.id)
.execute(pool)
.await?;
TaskModel::get_by_id(pool, self.id).await
}
pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result<()> {
sqlx::query("DELETE FROM tasks WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn list_all(pool: &SqlitePool) -> Result<Vec<TaskModel>> {
let tasks = sqlx::query_as("SELECT * FROM tasks ORDER BY created_at DESC")
.fetch_all(pool)
.await?;
Ok(tasks)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::create_test_pool;
async fn setup_test_db() -> SqlitePool {
create_test_pool()
.await
.expect("Failed to create test pool")
}
#[tokio::test]
async fn test_insert_task() {
let pool = setup_test_db().await;
let title = "Test Task";
let description = "Test Description";
let task = TaskModel::insert(&pool, title, Some(description))
.await
.expect("Failed to insert task");
assert_eq!(task.title, title);
assert_eq!(task.description, Some(description.to_string()));
assert_eq!(task.status, TaskStatus::Todo);
assert!(task.completed_at.is_none());
}
#[tokio::test]
async fn test_insert_task_with_null_description() {
let pool = setup_test_db().await;
let title = "Test Task";
let description = None;
let task = TaskModel::insert(&pool, title, description)
.await
.expect("Failed to insert task");
assert_eq!(task.title, title);
assert_eq!(task.description, None);
assert_eq!(task.status, TaskStatus::Todo);
}
#[tokio::test]
async fn test_get_by_id_found() {
let pool = setup_test_db().await;
let title = "Test Task";
let description = Some("Test Description");
let inserted_task = TaskModel::insert(&pool, title, description)
.await
.expect("Failed to insert task");
let retrieved_task = TaskModel::get_by_id(&pool, inserted_task.id)
.await
.expect("Failed to get task by id");
assert_eq!(retrieved_task.id, inserted_task.id);
assert_eq!(retrieved_task.title, inserted_task.title);
assert_eq!(retrieved_task.description, inserted_task.description);
}
#[tokio::test]
async fn test_get_by_id_not_found() {
let pool = setup_test_db().await;
let non_existent_id = Uuid::new_v4();
let result = TaskModel::get_by_id(&pool, non_existent_id).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_update_task() {
let pool = setup_test_db().await;
let title = "Original Task";
let description = Some("Original Description");
let mut task = TaskModel::insert(&pool, title, description)
.await
.expect("Failed to insert task");
// Update the task
task.title = "Updated Task".to_string();
task.description = Some("Updated Description".to_string());
task.status = TaskStatus::Done;
task.completed_at = Some(Utc::now());
let updated_task = task.update(&pool).await.expect("Failed to update task");
assert_eq!(updated_task.title, "Updated Task");
assert_eq!(
updated_task.description,
Some("Updated Description".to_string())
);
assert_eq!(updated_task.status, TaskStatus::Done);
assert!(updated_task.completed_at.is_some());
assert!(updated_task.updated_at > updated_task.created_at);
}
#[tokio::test]
async fn test_delete_task() {
let pool = setup_test_db().await;
let title = "Task to Delete";
let description = Some("This will be deleted");
let task = TaskModel::insert(&pool, title, description)
.await
.expect("Failed to insert task");
// Delete the task
TaskModel::delete(&pool, task.id)
.await
.expect("Failed to delete task");
// Verify it's gone
let result = TaskModel::get_by_id(&pool, task.id).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_list_all_tasks() {
let pool = setup_test_db().await;
// Insert multiple tasks
let _task1 = TaskModel::insert(&pool, "Task 1", Some("Desc 1"))
.await
.expect("Failed to insert task 1");
let _task2 = TaskModel::insert(&pool, "Task 2", None)
.await
.expect("Failed to insert task 2");
let tasks = TaskModel::list_all(&pool)
.await
.expect("Failed to list tasks");
assert_eq!(tasks.len(), 2);
// Check that both tasks are present (order may vary due to timing)
let titles: Vec<&String> = tasks.iter().map(|t| &t.title).collect();
assert!(titles.contains(&&"Task 1".to_string()));
assert!(titles.contains(&&"Task 2".to_string()));
}
#[tokio::test]
async fn test_task_status_serialization() {
let pool = setup_test_db().await;
// Test each status
let statuses = vec![TaskStatus::Todo, TaskStatus::Done, TaskStatus::Backlog];
for status in statuses {
let mut task = TaskModel::insert(&pool, "Test Task", None)
.await
.expect("Failed to insert task");
task.status = status;
task = task.update(&pool).await.expect("Failed to update task");
assert_eq!(task.status, status);
}
}
#[tokio::test]
async fn test_empty_title_constraint() {
let pool = setup_test_db().await;
let result = TaskModel::insert(&pool, "", None).await;
assert!(result.is_err());
}
}