//! Pack repository for database operations on packs //! //! This module provides CRUD operations and queries for Pack entities. use crate::models::{pack::Pack, JsonDict, JsonSchema}; use crate::{Error, Result}; use sqlx::{Executor, Postgres, QueryBuilder}; use super::{Create, Delete, FindById, FindByRef, List, Pagination, Patch, Repository, Update}; /// Repository for Pack operations pub struct PackRepository; impl Repository for PackRepository { type Entity = Pack; fn table_name() -> &'static str { "pack" } } /// Input for creating a new pack #[derive(Debug, Clone)] pub struct CreatePackInput { pub r#ref: String, pub label: String, pub description: Option, pub version: String, pub conf_schema: JsonSchema, pub config: JsonDict, pub meta: JsonDict, pub tags: Vec, pub runtime_deps: Vec, pub dependencies: Vec, pub is_standard: bool, pub installers: JsonDict, } /// Input for updating a pack #[derive(Debug, Clone, Default)] pub struct UpdatePackInput { pub label: Option, pub description: Option>, pub version: Option, pub conf_schema: Option, pub config: Option, pub meta: Option, pub tags: Option>, pub runtime_deps: Option>, pub dependencies: Option>, pub is_standard: Option, pub installers: Option, } const PACK_COLUMNS: &str = "id, ref, label, description, version, conf_schema, config, meta, tags, runtime_deps, dependencies, is_standard, installers, source_type, source_url, source_ref, checksum, checksum_verified, installed_at, installed_by, installation_method, storage_path, created, updated"; #[async_trait::async_trait] impl FindById for PackRepository { async fn find_by_id<'e, E>(executor: E, id: i64) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let query = format!("SELECT {} FROM pack WHERE id = $1", PACK_COLUMNS); let pack = sqlx::query_as::<_, Pack>(&query) .bind(id) .fetch_optional(executor) .await?; Ok(pack) } } #[async_trait::async_trait] impl FindByRef for PackRepository { async fn find_by_ref<'e, E>(executor: E, ref_str: &str) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let query = format!("SELECT {} FROM pack WHERE ref = $1", PACK_COLUMNS); let pack = sqlx::query_as::<_, Pack>(&query) .bind(ref_str) .fetch_optional(executor) .await?; Ok(pack) } } #[async_trait::async_trait] impl List for PackRepository { async fn list<'e, E>(executor: E) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let query = format!("SELECT {} FROM pack ORDER BY ref ASC", PACK_COLUMNS); let packs = sqlx::query_as::<_, Pack>(&query) .fetch_all(executor) .await?; Ok(packs) } } #[async_trait::async_trait] impl Create for PackRepository { type CreateInput = CreatePackInput; async fn create<'e, E>(executor: E, input: Self::CreateInput) -> Result where E: Executor<'e, Database = Postgres> + 'e, { // Validate ref format (alphanumeric, dots, underscores, hyphens) if !input .r#ref .chars() .all(|c| c.is_alphanumeric() || c == '.' || c == '_' || c == '-') { return Err(Error::validation( "Pack ref must contain only alphanumeric characters, dots, underscores, and hyphens", )); } let query = format!( r#" INSERT INTO pack (ref, label, description, version, conf_schema, config, meta, tags, runtime_deps, dependencies, is_standard, installers) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING {} "#, PACK_COLUMNS ); // Try to insert - database will enforce uniqueness constraint let pack = sqlx::query_as::<_, Pack>(&query) .bind(&input.r#ref) .bind(&input.label) .bind(&input.description) .bind(&input.version) .bind(&input.conf_schema) .bind(&input.config) .bind(&input.meta) .bind(&input.tags) .bind(&input.runtime_deps) .bind(&input.dependencies) .bind(input.is_standard) .bind(&input.installers) .fetch_one(executor) .await .map_err(|e| { // Convert unique constraint violation to AlreadyExists error if let sqlx::Error::Database(db_err) = &e { if db_err.is_unique_violation() { return Error::already_exists("Pack", "ref", &input.r#ref); } } e.into() })?; Ok(pack) } } #[async_trait::async_trait] impl Update for PackRepository { type UpdateInput = UpdatePackInput; async fn update<'e, E>(executor: E, id: i64, input: Self::UpdateInput) -> Result where E: Executor<'e, Database = Postgres> + 'e, { // Build dynamic UPDATE query let mut query = QueryBuilder::new("UPDATE pack SET "); let mut has_updates = false; if let Some(label) = &input.label { if has_updates { query.push(", "); } query.push("label = "); query.push_bind(label); has_updates = true; } if let Some(description) = &input.description { if has_updates { query.push(", "); } query.push("description = "); match description { Patch::Set(value) => query.push_bind(value), Patch::Clear => query.push_bind(Option::::None), }; has_updates = true; } if let Some(version) = &input.version { if has_updates { query.push(", "); } query.push("version = "); query.push_bind(version); has_updates = true; } if let Some(conf_schema) = &input.conf_schema { if has_updates { query.push(", "); } query.push("conf_schema = "); query.push_bind(conf_schema); has_updates = true; } if let Some(config) = &input.config { if has_updates { query.push(", "); } query.push("config = "); query.push_bind(config); has_updates = true; } if let Some(meta) = &input.meta { if has_updates { query.push(", "); } query.push("meta = "); query.push_bind(meta); has_updates = true; } if let Some(tags) = &input.tags { if has_updates { query.push(", "); } query.push("tags = "); query.push_bind(tags); has_updates = true; } if let Some(runtime_deps) = &input.runtime_deps { if has_updates { query.push(", "); } query.push("runtime_deps = "); query.push_bind(runtime_deps); has_updates = true; } if let Some(dependencies) = &input.dependencies { if has_updates { query.push(", "); } query.push("dependencies = "); query.push_bind(dependencies); has_updates = true; } if let Some(is_standard) = input.is_standard { if has_updates { query.push(", "); } query.push("is_standard = "); query.push_bind(is_standard); has_updates = true; } if let Some(installers) = &input.installers { if has_updates { query.push(", "); } query.push("installers = "); query.push_bind(installers); has_updates = true; } if !has_updates { // No updates requested, fetch and return existing pack return Self::find_by_id(executor, id) .await? .ok_or_else(|| Error::not_found("pack", "id", id.to_string())); } // Add updated timestamp query.push(", updated = NOW() WHERE id = "); query.push_bind(id); query.push(" RETURNING "); query.push(PACK_COLUMNS); let pack = query .build_query_as::() .fetch_one(executor) .await .map_err(|e| match e { sqlx::Error::RowNotFound => Error::not_found("pack", "id", id.to_string()), _ => e.into(), })?; Ok(pack) } } #[async_trait::async_trait] impl Delete for PackRepository { async fn delete<'e, E>(executor: E, id: i64) -> Result where E: Executor<'e, Database = Postgres> + 'e, { let result = sqlx::query("DELETE FROM pack WHERE id = $1") .bind(id) .execute(executor) .await?; Ok(result.rows_affected() > 0) } } impl PackRepository { /// List packs with pagination pub async fn list_paginated<'e, E>(executor: E, pagination: Pagination) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let query = format!( "SELECT {} FROM pack ORDER BY ref ASC LIMIT $1 OFFSET $2", PACK_COLUMNS ); let packs = sqlx::query_as::<_, Pack>(&query) .bind(pagination.limit()) .bind(pagination.offset()) .fetch_all(executor) .await?; Ok(packs) } /// Count total number of packs pub async fn count<'e, E>(executor: E) -> Result where E: Executor<'e, Database = Postgres> + 'e, { let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM pack") .fetch_one(executor) .await?; Ok(count.0) } /// Find packs by tag pub async fn find_by_tag<'e, E>(executor: E, tag: &str) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let query = format!( "SELECT {} FROM pack WHERE $1 = ANY(tags) ORDER BY ref ASC", PACK_COLUMNS ); let packs = sqlx::query_as::<_, Pack>(&query) .bind(tag) .fetch_all(executor) .await?; Ok(packs) } /// Find standard packs pub async fn find_standard<'e, E>(executor: E) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let query = format!( "SELECT {} FROM pack WHERE is_standard = true ORDER BY ref ASC", PACK_COLUMNS ); let packs = sqlx::query_as::<_, Pack>(&query) .fetch_all(executor) .await?; Ok(packs) } /// Search packs by name/label (case-insensitive) pub async fn search<'e, E>(executor: E, query: &str) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let search_pattern = format!("%{}%", query.to_lowercase()); let sql = format!( "SELECT {} FROM pack WHERE LOWER(ref) LIKE $1 OR LOWER(label) LIKE $1 OR LOWER(description) LIKE $1 ORDER BY ref ASC", PACK_COLUMNS ); let packs = sqlx::query_as::<_, Pack>(&sql) .bind(&search_pattern) .fetch_all(executor) .await?; Ok(packs) } /// Check if a pack with the given ref exists pub async fn exists_by_ref<'e, E>(executor: E, ref_str: &str) -> Result where E: Executor<'e, Database = Postgres> + 'e, { let exists: (bool,) = sqlx::query_as("SELECT EXISTS(SELECT 1 FROM pack WHERE ref = $1)") .bind(ref_str) .fetch_one(executor) .await?; Ok(exists.0) } /// Update installation metadata for a pack #[allow(clippy::too_many_arguments)] pub async fn update_installation_metadata<'e, E>( executor: E, id: i64, source_type: String, source_url: Option, source_ref: Option, checksum: Option, checksum_verified: bool, installed_by: Option, installation_method: String, storage_path: String, ) -> Result where E: Executor<'e, Database = Postgres> + 'e, { let query = format!( r#" UPDATE pack SET source_type = $2, source_url = $3, source_ref = $4, checksum = $5, checksum_verified = $6, installed_at = NOW(), installed_by = $7, installation_method = $8, storage_path = $9, updated = NOW() WHERE id = $1 RETURNING {} "#, PACK_COLUMNS ); let pack = sqlx::query_as::<_, Pack>(&query) .bind(id) .bind(source_type) .bind(source_url) .bind(source_ref) .bind(checksum) .bind(checksum_verified) .bind(installed_by) .bind(installation_method) .bind(storage_path) .fetch_one(executor) .await .map_err(|e| match e { sqlx::Error::RowNotFound => Error::not_found("pack", "id", id.to_string()), _ => e.into(), })?; Ok(pack) } /// Check if a pack has installation metadata pub async fn is_installed<'e, E>(executor: E, pack_id: i64) -> Result where E: Executor<'e, Database = Postgres> + 'e, { let exists: (bool,) = sqlx::query_as( "SELECT EXISTS(SELECT 1 FROM pack WHERE id = $1 AND installed_at IS NOT NULL)", ) .bind(pack_id) .fetch_one(executor) .await?; Ok(exists.0) } /// List all installed packs pub async fn list_installed<'e, E>(executor: E) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let query = format!( "SELECT {} FROM pack WHERE installed_at IS NOT NULL ORDER BY installed_at DESC", PACK_COLUMNS ); let packs = sqlx::query_as::<_, Pack>(&query) .fetch_all(executor) .await?; Ok(packs) } /// List packs by source type pub async fn list_by_source_type<'e, E>(executor: E, source_type: &str) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let query = format!( "SELECT {} FROM pack WHERE source_type = $1 ORDER BY installed_at DESC", PACK_COLUMNS ); let packs = sqlx::query_as::<_, Pack>(&query) .bind(source_type) .fetch_all(executor) .await?; Ok(packs) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_create_pack_input() { let input = CreatePackInput { r#ref: "test.pack".to_string(), label: "Test Pack".to_string(), description: Some("A test pack".to_string()), version: "1.0.0".to_string(), conf_schema: serde_json::json!({}), config: serde_json::json!({}), meta: serde_json::json!({}), tags: vec!["test".to_string()], runtime_deps: vec![], dependencies: vec![], is_standard: false, installers: serde_json::json!({}), }; assert_eq!(input.r#ref, "test.pack"); assert_eq!(input.label, "Test Pack"); } #[test] fn test_update_pack_input_default() { let input = UpdatePackInput::default(); assert!(input.label.is_none()); assert!(input.description.is_none()); assert!(input.version.is_none()); assert!(input.dependencies.is_none()); } }