//! Action and Policy repository for database operations //! //! This module provides CRUD operations and queries for Action and Policy entities. use crate::models::{action::*, enums::PolicyMethod, Id, JsonSchema}; use crate::{Error, Result}; use sqlx::{Executor, Postgres, QueryBuilder}; use super::{Create, Delete, FindById, FindByRef, List, Patch, Repository, Update}; /// Columns selected in all Action queries. Must match the `Action` model's `FromRow` fields. pub const ACTION_COLUMNS: &str = "id, ref, pack, pack_ref, label, description, entrypoint, \ runtime, runtime_version_constraint, param_schema, out_schema, workflow_def, is_adhoc, \ parameter_delivery, parameter_format, output_format, created, updated"; /// Filters for [`ActionRepository::list_search`]. /// /// All fields are optional and combinable (AND). Pagination is always applied. #[derive(Debug, Clone, Default)] pub struct ActionSearchFilters { /// Filter by pack ID pub pack: Option, /// Text search across ref, label, description (case-insensitive) pub query: Option, pub limit: u32, pub offset: u32, } /// Result of [`ActionRepository::list_search`]. #[derive(Debug)] pub struct ActionSearchResult { pub rows: Vec, pub total: u64, } /// Repository for Action operations pub struct ActionRepository; impl Repository for ActionRepository { type Entity = Action; fn table_name() -> &'static str { "action" } } /// Input for creating a new action #[derive(Debug, Clone)] pub struct CreateActionInput { pub r#ref: String, pub pack: Id, pub pack_ref: String, pub label: String, pub description: Option, pub entrypoint: String, pub runtime: Option, pub runtime_version_constraint: Option, pub param_schema: Option, pub out_schema: Option, pub is_adhoc: bool, } /// Input for updating an action #[derive(Debug, Clone, Default)] pub struct UpdateActionInput { pub label: Option, pub description: Option>, pub entrypoint: Option, pub runtime: Option, pub runtime_version_constraint: Option>, pub param_schema: Option, pub out_schema: Option, pub parameter_delivery: Option, pub parameter_format: Option, pub output_format: Option, } #[async_trait::async_trait] impl FindById for ActionRepository { async fn find_by_id<'e, E>(executor: E, id: i64) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let action = sqlx::query_as::<_, Action>(&format!( "SELECT {} FROM action WHERE id = $1", ACTION_COLUMNS )) .bind(id) .fetch_optional(executor) .await?; Ok(action) } } #[async_trait::async_trait] impl FindByRef for ActionRepository { async fn find_by_ref<'e, E>(executor: E, ref_str: &str) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let action = sqlx::query_as::<_, Action>(&format!( "SELECT {} FROM action WHERE ref = $1", ACTION_COLUMNS )) .bind(ref_str) .fetch_optional(executor) .await?; Ok(action) } } #[async_trait::async_trait] impl List for ActionRepository { async fn list<'e, E>(executor: E) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let actions = sqlx::query_as::<_, Action>(&format!( "SELECT {} FROM action ORDER BY ref ASC", ACTION_COLUMNS )) .fetch_all(executor) .await?; Ok(actions) } } #[async_trait::async_trait] impl Create for ActionRepository { type CreateInput = CreateActionInput; async fn create<'e, E>(executor: E, input: Self::CreateInput) -> Result where E: Executor<'e, Database = Postgres> + 'e, { // Validate ref format if !input .r#ref .chars() .all(|c| c.is_alphanumeric() || c == '.' || c == '_' || c == '-') { return Err(Error::validation( "Action ref must contain only alphanumeric characters, dots, underscores, and hyphens", )); } // Try to insert - database will enforce uniqueness constraint let action = sqlx::query_as::<_, Action>(&format!( r#" INSERT INTO action (ref, pack, pack_ref, label, description, entrypoint, runtime, runtime_version_constraint, param_schema, out_schema, is_adhoc) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING {} "#, ACTION_COLUMNS )) .bind(&input.r#ref) .bind(input.pack) .bind(&input.pack_ref) .bind(&input.label) .bind(&input.description) .bind(&input.entrypoint) .bind(input.runtime) .bind(&input.runtime_version_constraint) .bind(&input.param_schema) .bind(&input.out_schema) .bind(input.is_adhoc) .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("Action", "ref", &input.r#ref); } } e.into() })?; Ok(action) } } #[async_trait::async_trait] impl Update for ActionRepository { type UpdateInput = UpdateActionInput; 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 action 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(entrypoint) = &input.entrypoint { if has_updates { query.push(", "); } query.push("entrypoint = "); query.push_bind(entrypoint); has_updates = true; } if let Some(runtime) = input.runtime { if has_updates { query.push(", "); } query.push("runtime = "); query.push_bind(runtime); has_updates = true; } if let Some(runtime_version_constraint) = &input.runtime_version_constraint { if has_updates { query.push(", "); } query.push("runtime_version_constraint = "); match runtime_version_constraint { Patch::Set(value) => query.push_bind(value), Patch::Clear => query.push_bind(Option::::None), }; has_updates = true; } if let Some(param_schema) = &input.param_schema { if has_updates { query.push(", "); } query.push("param_schema = "); query.push_bind(param_schema); has_updates = true; } if let Some(out_schema) = &input.out_schema { if has_updates { query.push(", "); } query.push("out_schema = "); query.push_bind(out_schema); has_updates = true; } if let Some(parameter_delivery) = &input.parameter_delivery { if has_updates { query.push(", "); } query.push("parameter_delivery = "); query.push_bind(parameter_delivery); has_updates = true; } if let Some(parameter_format) = &input.parameter_format { if has_updates { query.push(", "); } query.push("parameter_format = "); query.push_bind(parameter_format); has_updates = true; } if let Some(output_format) = &input.output_format { if has_updates { query.push(", "); } query.push("output_format = "); query.push_bind(output_format); has_updates = true; } if !has_updates { // No updates requested, fetch and return existing action return Self::find_by_id(executor, id) .await? .ok_or_else(|| Error::not_found("action", "id", id.to_string())); } query.push(", updated = NOW() WHERE id = "); query.push_bind(id); query.push(format!(" RETURNING {}", ACTION_COLUMNS)); let action = query .build_query_as::() .fetch_one(executor) .await .map_err(|e| match e { sqlx::Error::RowNotFound => Error::not_found("action", "id", id.to_string()), _ => e.into(), })?; Ok(action) } } #[async_trait::async_trait] impl Delete for ActionRepository { async fn delete<'e, E>(executor: E, id: i64) -> Result where E: Executor<'e, Database = Postgres> + 'e, { let result = sqlx::query("DELETE FROM action WHERE id = $1") .bind(id) .execute(executor) .await?; Ok(result.rows_affected() > 0) } } impl ActionRepository { /// Search actions with all filters pushed into SQL. /// /// All filter fields are combinable (AND). Pagination is server-side. pub async fn list_search<'e, E>( db: E, filters: &ActionSearchFilters, ) -> Result where E: Executor<'e, Database = Postgres> + Copy + 'e, { let mut qb: QueryBuilder<'_, Postgres> = QueryBuilder::new(format!("SELECT {} FROM action", ACTION_COLUMNS)); let mut count_qb: QueryBuilder<'_, Postgres> = QueryBuilder::new("SELECT COUNT(*) FROM action"); let mut has_where = false; macro_rules! push_condition { ($cond_prefix:expr, $value:expr) => {{ if !has_where { qb.push(" WHERE "); count_qb.push(" WHERE "); has_where = true; } else { qb.push(" AND "); count_qb.push(" AND "); } qb.push($cond_prefix); qb.push_bind($value.clone()); count_qb.push($cond_prefix); count_qb.push_bind($value); }}; } if let Some(pack_id) = filters.pack { push_condition!("pack = ", pack_id); } if let Some(ref query) = filters.query { let pattern = format!("%{}%", query.to_lowercase()); // Search needs an OR across multiple columns, wrapped in parens if !has_where { qb.push(" WHERE "); count_qb.push(" WHERE "); has_where = true; } else { qb.push(" AND "); count_qb.push(" AND "); } qb.push("(LOWER(ref) LIKE "); qb.push_bind(pattern.clone()); qb.push(" OR LOWER(label) LIKE "); qb.push_bind(pattern.clone()); qb.push(" OR LOWER(description) LIKE "); qb.push_bind(pattern.clone()); qb.push(")"); count_qb.push("(LOWER(ref) LIKE "); count_qb.push_bind(pattern.clone()); count_qb.push(" OR LOWER(label) LIKE "); count_qb.push_bind(pattern.clone()); count_qb.push(" OR LOWER(description) LIKE "); count_qb.push_bind(pattern); count_qb.push(")"); } // Suppress unused-assignment warning from the macro's last expansion. let _ = has_where; // Count let total: i64 = count_qb.build_query_scalar().fetch_one(db).await?; let total = total.max(0) as u64; // Data query qb.push(" ORDER BY ref ASC"); qb.push(" LIMIT "); qb.push_bind(filters.limit as i64); qb.push(" OFFSET "); qb.push_bind(filters.offset as i64); let rows: Vec = qb.build_query_as().fetch_all(db).await?; Ok(ActionSearchResult { rows, total }) } /// Find actions by pack ID pub async fn find_by_pack<'e, E>(executor: E, pack_id: Id) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let actions = sqlx::query_as::<_, Action>(&format!( "SELECT {} FROM action WHERE pack = $1 ORDER BY ref ASC", ACTION_COLUMNS )) .bind(pack_id) .fetch_all(executor) .await?; Ok(actions) } /// Find actions by runtime ID pub async fn find_by_runtime<'e, E>(executor: E, runtime_id: Id) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let actions = sqlx::query_as::<_, Action>(&format!( "SELECT {} FROM action WHERE runtime = $1 ORDER BY ref ASC", ACTION_COLUMNS )) .bind(runtime_id) .fetch_all(executor) .await?; Ok(actions) } /// Search actions by name/label 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 actions = sqlx::query_as::<_, Action>(&format!( "SELECT {} FROM action WHERE LOWER(ref) LIKE $1 OR LOWER(label) LIKE $1 OR LOWER(description) LIKE $1 ORDER BY ref ASC", ACTION_COLUMNS )) .bind(&search_pattern) .fetch_all(executor) .await?; Ok(actions) } /// Find all workflow actions (actions linked to a workflow definition) pub async fn find_workflows<'e, E>(executor: E) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let actions = sqlx::query_as::<_, Action>(&format!( "SELECT {} FROM action WHERE workflow_def IS NOT NULL ORDER BY ref ASC", ACTION_COLUMNS )) .fetch_all(executor) .await?; Ok(actions) } /// Find action by workflow definition ID pub async fn find_by_workflow_def<'e, E>( executor: E, workflow_def_id: Id, ) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let action = sqlx::query_as::<_, Action>(&format!( "SELECT {} FROM action WHERE workflow_def = $1", ACTION_COLUMNS )) .bind(workflow_def_id) .fetch_optional(executor) .await?; Ok(action) } /// Delete non-adhoc actions belonging to a pack whose refs are NOT in the given set. /// /// Used during pack reinstallation to clean up actions that were removed /// from the pack's YAML files. Ad-hoc (user-created) actions are preserved. pub async fn delete_non_adhoc_by_pack_excluding<'e, E>( executor: E, pack_id: Id, keep_refs: &[String], ) -> Result where E: Executor<'e, Database = Postgres> + 'e, { let result = if keep_refs.is_empty() { sqlx::query("DELETE FROM action WHERE pack = $1 AND is_adhoc = false") .bind(pack_id) .execute(executor) .await? } else { sqlx::query( "DELETE FROM action WHERE pack = $1 AND is_adhoc = false AND ref != ALL($2)", ) .bind(pack_id) .bind(keep_refs) .execute(executor) .await? }; Ok(result.rows_affected()) } /// Link an action to a workflow definition pub async fn link_workflow_def<'e, E>( executor: E, action_id: Id, workflow_def_id: Id, ) -> Result where E: Executor<'e, Database = Postgres> + 'e, { let action = sqlx::query_as::<_, Action>(&format!( r#" UPDATE action SET workflow_def = $2, updated = NOW() WHERE id = $1 RETURNING {} "#, ACTION_COLUMNS )) .bind(action_id) .bind(workflow_def_id) .fetch_one(executor) .await .map_err(|e| match e { sqlx::Error::RowNotFound => Error::not_found("action", "id", action_id.to_string()), _ => e.into(), })?; Ok(action) } } // ============================================================================ // Policy Repository // ============================================================================ /// Repository for Policy operations pub struct PolicyRepository; impl Repository for PolicyRepository { type Entity = Policy; fn table_name() -> &'static str { "policies" } } /// Input for creating a new policy #[derive(Debug, Clone)] pub struct CreatePolicyInput { pub r#ref: String, pub pack: Option, pub pack_ref: Option, pub action: Option, pub action_ref: Option, pub parameters: Vec, pub method: PolicyMethod, pub threshold: i32, pub name: String, pub description: Option, pub tags: Vec, } /// Input for updating a policy #[derive(Debug, Clone, Default)] pub struct UpdatePolicyInput { pub parameters: Option>, pub method: Option, pub threshold: Option, pub name: Option, pub description: Option, pub tags: Option>, } #[async_trait::async_trait] impl FindById for PolicyRepository { async fn find_by_id<'e, E>(executor: E, id: i64) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let policy = sqlx::query_as::<_, Policy>( r#" SELECT id, ref, pack, pack_ref, action, action_ref, parameters, method, threshold, name, description, tags, created, updated FROM policies WHERE id = $1 "#, ) .bind(id) .fetch_optional(executor) .await?; Ok(policy) } } #[async_trait::async_trait] impl FindByRef for PolicyRepository { async fn find_by_ref<'e, E>(executor: E, ref_str: &str) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let policy = sqlx::query_as::<_, Policy>( r#" SELECT id, ref, pack, pack_ref, action, action_ref, parameters, method, threshold, name, description, tags, created, updated FROM policies WHERE ref = $1 "#, ) .bind(ref_str) .fetch_optional(executor) .await?; Ok(policy) } } #[async_trait::async_trait] impl List for PolicyRepository { async fn list<'e, E>(executor: E) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let policies = sqlx::query_as::<_, Policy>( r#" SELECT id, ref, pack, pack_ref, action, action_ref, parameters, method, threshold, name, description, tags, created, updated FROM policies ORDER BY ref ASC "#, ) .fetch_all(executor) .await?; Ok(policies) } } #[async_trait::async_trait] impl Create for PolicyRepository { type CreateInput = CreatePolicyInput; async fn create<'e, E>(executor: E, input: Self::CreateInput) -> Result where E: Executor<'e, Database = Postgres> + 'e, { // Try to insert - database will enforce uniqueness constraint let policy = sqlx::query_as::<_, Policy>( r#" INSERT INTO policies (ref, pack, pack_ref, action, action_ref, parameters, method, threshold, name, description, tags) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, ref, pack, pack_ref, action, action_ref, parameters, method, threshold, name, description, tags, created, updated "#, ) .bind(&input.r#ref) .bind(input.pack) .bind(&input.pack_ref) .bind(input.action) .bind(&input.action_ref) .bind(&input.parameters) .bind(input.method) .bind(input.threshold) .bind(&input.name) .bind(&input.description) .bind(&input.tags) .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("Policy", "ref", &input.r#ref); } } e.into() })?; Ok(policy) } } #[async_trait::async_trait] impl Update for PolicyRepository { type UpdateInput = UpdatePolicyInput; async fn update<'e, E>(executor: E, id: i64, input: Self::UpdateInput) -> Result where E: Executor<'e, Database = Postgres> + 'e, { let mut query = QueryBuilder::new("UPDATE policies SET "); let mut has_updates = false; if let Some(parameters) = &input.parameters { if has_updates { query.push(", "); } query.push("parameters = "); query.push_bind(parameters); has_updates = true; } if let Some(method) = input.method { if has_updates { query.push(", "); } query.push("method = "); query.push_bind(method); has_updates = true; } if let Some(threshold) = input.threshold { if has_updates { query.push(", "); } query.push("threshold = "); query.push_bind(threshold); has_updates = true; } if let Some(name) = &input.name { if has_updates { query.push(", "); } query.push("name = "); query.push_bind(name); has_updates = true; } if let Some(description) = &input.description { if has_updates { query.push(", "); } query.push("description = "); query.push_bind(description); 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 !has_updates { // No updates requested, fetch and return existing policy return Self::get_by_id(executor, id).await; } query.push(", updated = NOW() WHERE id = "); query.push_bind(id); query.push(" RETURNING id, ref, pack, pack_ref, action, action_ref, parameters, method, threshold, name, description, tags, created, updated"); let policy = query.build_query_as::().fetch_one(executor).await?; Ok(policy) } } #[async_trait::async_trait] impl Delete for PolicyRepository { async fn delete<'e, E>(executor: E, id: i64) -> Result where E: Executor<'e, Database = Postgres> + 'e, { let result = sqlx::query("DELETE FROM policies WHERE id = $1") .bind(id) .execute(executor) .await?; Ok(result.rows_affected() > 0) } } impl PolicyRepository { /// Find policies by action ID pub async fn find_by_action<'e, E>(executor: E, action_id: Id) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let policies = sqlx::query_as::<_, Policy>( r#" SELECT id, ref, pack, pack_ref, action, action_ref, parameters, method, threshold, name, description, tags, created, updated FROM policies WHERE action = $1 ORDER BY ref ASC "#, ) .bind(action_id) .fetch_all(executor) .await?; Ok(policies) } /// Find policies by tag pub async fn find_by_tag<'e, E>(executor: E, tag: &str) -> Result> where E: Executor<'e, Database = Postgres> + 'e, { let policies = sqlx::query_as::<_, Policy>( r#" SELECT id, ref, pack, pack_ref, action, action_ref, parameters, method, threshold, name, description, tags, created, updated FROM policies WHERE $1 = ANY(tags) ORDER BY ref ASC "#, ) .bind(tag) .fetch_all(executor) .await?; Ok(policies) } }