454 lines
16 KiB
Rust
454 lines
16 KiB
Rust
//! Execution repository for database operations
|
|
|
|
use chrono::{DateTime, Utc};
|
|
|
|
use crate::models::{enums::ExecutionStatus, execution::*, Id, JsonDict};
|
|
use crate::Result;
|
|
use sqlx::{Executor, Postgres, QueryBuilder};
|
|
|
|
use super::{Create, Delete, FindById, List, Repository, Update};
|
|
|
|
/// Filters for the [`ExecutionRepository::search`] query-builder method.
|
|
///
|
|
/// Every field is optional. When set, the corresponding `WHERE` clause is
|
|
/// appended to the query. Pagination (`limit`/`offset`) is always applied.
|
|
///
|
|
/// Filters that involve the `enforcement` table (`rule_ref`, `trigger_ref`)
|
|
/// cause a `LEFT JOIN enforcement` to be added automatically.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct ExecutionSearchFilters {
|
|
pub status: Option<ExecutionStatus>,
|
|
pub action_ref: Option<String>,
|
|
pub pack_name: Option<String>,
|
|
pub rule_ref: Option<String>,
|
|
pub trigger_ref: Option<String>,
|
|
pub executor: Option<Id>,
|
|
pub result_contains: Option<String>,
|
|
pub enforcement: Option<Id>,
|
|
pub parent: Option<Id>,
|
|
pub top_level_only: bool,
|
|
pub limit: u32,
|
|
pub offset: u32,
|
|
}
|
|
|
|
/// Result of [`ExecutionRepository::search`].
|
|
///
|
|
/// Includes the matching rows *and* the total count (before LIMIT/OFFSET)
|
|
/// so the caller can build pagination metadata without a second round-trip.
|
|
#[derive(Debug)]
|
|
pub struct ExecutionSearchResult {
|
|
pub rows: Vec<ExecutionWithRefs>,
|
|
pub total: u64,
|
|
}
|
|
|
|
/// An execution row with optional `rule_ref` / `trigger_ref` populated from
|
|
/// the joined `enforcement` table. This avoids a separate in-memory lookup.
|
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
|
pub struct ExecutionWithRefs {
|
|
// — execution columns (same order as SELECT_COLUMNS) —
|
|
pub id: Id,
|
|
pub action: Option<Id>,
|
|
pub action_ref: String,
|
|
pub config: Option<JsonDict>,
|
|
pub env_vars: Option<JsonDict>,
|
|
pub parent: Option<Id>,
|
|
pub enforcement: Option<Id>,
|
|
pub executor: Option<Id>,
|
|
pub worker: Option<Id>,
|
|
pub status: ExecutionStatus,
|
|
pub result: Option<JsonDict>,
|
|
pub started_at: Option<DateTime<Utc>>,
|
|
#[sqlx(json, default)]
|
|
pub workflow_task: Option<WorkflowTaskMetadata>,
|
|
pub created: DateTime<Utc>,
|
|
pub updated: DateTime<Utc>,
|
|
// — joined from enforcement —
|
|
pub rule_ref: Option<String>,
|
|
pub trigger_ref: Option<String>,
|
|
}
|
|
|
|
/// Column list for SELECT queries on the execution table.
|
|
///
|
|
/// Defined once to avoid drift between queries and the `Execution` model.
|
|
/// The execution table has DB-only columns (`is_workflow`, `workflow_def`) that
|
|
/// are NOT in the Rust struct, so `SELECT *` must never be used.
|
|
pub const SELECT_COLUMNS: &str = "\
|
|
id, action, action_ref, config, env_vars, parent, enforcement, \
|
|
executor, worker, status, result, started_at, workflow_task, created, updated";
|
|
|
|
pub struct ExecutionRepository;
|
|
|
|
impl Repository for ExecutionRepository {
|
|
type Entity = Execution;
|
|
fn table_name() -> &'static str {
|
|
"executions"
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct CreateExecutionInput {
|
|
pub action: Option<Id>,
|
|
pub action_ref: String,
|
|
pub config: Option<JsonDict>,
|
|
pub env_vars: Option<JsonDict>,
|
|
pub parent: Option<Id>,
|
|
pub enforcement: Option<Id>,
|
|
pub executor: Option<Id>,
|
|
pub worker: Option<Id>,
|
|
pub status: ExecutionStatus,
|
|
pub result: Option<JsonDict>,
|
|
pub workflow_task: Option<WorkflowTaskMetadata>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct UpdateExecutionInput {
|
|
pub status: Option<ExecutionStatus>,
|
|
pub result: Option<JsonDict>,
|
|
pub executor: Option<Id>,
|
|
pub worker: Option<Id>,
|
|
pub started_at: Option<DateTime<Utc>>,
|
|
pub workflow_task: Option<WorkflowTaskMetadata>,
|
|
}
|
|
|
|
impl From<Execution> for UpdateExecutionInput {
|
|
fn from(execution: Execution) -> Self {
|
|
Self {
|
|
status: Some(execution.status),
|
|
result: execution.result,
|
|
executor: execution.executor,
|
|
worker: execution.worker,
|
|
started_at: execution.started_at,
|
|
workflow_task: execution.workflow_task,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl FindById for ExecutionRepository {
|
|
async fn find_by_id<'e, E>(executor: E, id: i64) -> Result<Option<Self::Entity>>
|
|
where
|
|
E: Executor<'e, Database = Postgres> + 'e,
|
|
{
|
|
let sql = format!("SELECT {SELECT_COLUMNS} FROM execution WHERE id = $1");
|
|
sqlx::query_as::<_, Execution>(&sql)
|
|
.bind(id)
|
|
.fetch_optional(executor)
|
|
.await
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl List for ExecutionRepository {
|
|
async fn list<'e, E>(executor: E) -> Result<Vec<Self::Entity>>
|
|
where
|
|
E: Executor<'e, Database = Postgres> + 'e,
|
|
{
|
|
let sql =
|
|
format!("SELECT {SELECT_COLUMNS} FROM execution ORDER BY created DESC LIMIT 1000");
|
|
sqlx::query_as::<_, Execution>(&sql)
|
|
.fetch_all(executor)
|
|
.await
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl Create for ExecutionRepository {
|
|
type CreateInput = CreateExecutionInput;
|
|
async fn create<'e, E>(executor: E, input: Self::CreateInput) -> Result<Self::Entity>
|
|
where
|
|
E: Executor<'e, Database = Postgres> + 'e,
|
|
{
|
|
let sql = format!(
|
|
"INSERT INTO execution \
|
|
(action, action_ref, config, env_vars, parent, enforcement, executor, worker, status, result, workflow_task) \
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
|
|
RETURNING {SELECT_COLUMNS}"
|
|
);
|
|
sqlx::query_as::<_, Execution>(&sql)
|
|
.bind(input.action)
|
|
.bind(&input.action_ref)
|
|
.bind(&input.config)
|
|
.bind(&input.env_vars)
|
|
.bind(input.parent)
|
|
.bind(input.enforcement)
|
|
.bind(input.executor)
|
|
.bind(input.worker)
|
|
.bind(input.status)
|
|
.bind(&input.result)
|
|
.bind(sqlx::types::Json(&input.workflow_task))
|
|
.fetch_one(executor)
|
|
.await
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl Update for ExecutionRepository {
|
|
type UpdateInput = UpdateExecutionInput;
|
|
async fn update<'e, E>(executor: E, id: i64, input: Self::UpdateInput) -> Result<Self::Entity>
|
|
where
|
|
E: Executor<'e, Database = Postgres> + 'e,
|
|
{
|
|
// Build update query
|
|
let mut query = QueryBuilder::new("UPDATE execution SET ");
|
|
let mut has_updates = false;
|
|
|
|
if let Some(status) = input.status {
|
|
query.push("status = ").push_bind(status);
|
|
has_updates = true;
|
|
}
|
|
if let Some(result) = &input.result {
|
|
if has_updates {
|
|
query.push(", ");
|
|
}
|
|
query.push("result = ").push_bind(result);
|
|
has_updates = true;
|
|
}
|
|
if let Some(executor_id) = input.executor {
|
|
if has_updates {
|
|
query.push(", ");
|
|
}
|
|
query.push("executor = ").push_bind(executor_id);
|
|
has_updates = true;
|
|
}
|
|
if let Some(worker_id) = input.worker {
|
|
if has_updates {
|
|
query.push(", ");
|
|
}
|
|
query.push("worker = ").push_bind(worker_id);
|
|
has_updates = true;
|
|
}
|
|
if let Some(started_at) = input.started_at {
|
|
if has_updates {
|
|
query.push(", ");
|
|
}
|
|
query.push("started_at = ").push_bind(started_at);
|
|
has_updates = true;
|
|
}
|
|
if let Some(workflow_task) = &input.workflow_task {
|
|
if has_updates {
|
|
query.push(", ");
|
|
}
|
|
query
|
|
.push("workflow_task = ")
|
|
.push_bind(sqlx::types::Json(workflow_task));
|
|
has_updates = true;
|
|
}
|
|
|
|
if !has_updates {
|
|
// No updates requested, fetch and return existing entity
|
|
return Self::get_by_id(executor, id).await;
|
|
}
|
|
|
|
query.push(", updated = NOW() WHERE id = ").push_bind(id);
|
|
query.push(" RETURNING ");
|
|
query.push(SELECT_COLUMNS);
|
|
|
|
query
|
|
.build_query_as::<Execution>()
|
|
.fetch_one(executor)
|
|
.await
|
|
.map_err(Into::into)
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl Delete for ExecutionRepository {
|
|
async fn delete<'e, E>(executor: E, id: i64) -> Result<bool>
|
|
where
|
|
E: Executor<'e, Database = Postgres> + 'e,
|
|
{
|
|
let result = sqlx::query("DELETE FROM execution WHERE id = $1")
|
|
.bind(id)
|
|
.execute(executor)
|
|
.await?;
|
|
Ok(result.rows_affected() > 0)
|
|
}
|
|
}
|
|
|
|
impl ExecutionRepository {
|
|
pub async fn find_by_status<'e, E>(
|
|
executor: E,
|
|
status: ExecutionStatus,
|
|
) -> Result<Vec<Execution>>
|
|
where
|
|
E: Executor<'e, Database = Postgres> + 'e,
|
|
{
|
|
let sql = format!(
|
|
"SELECT {SELECT_COLUMNS} FROM execution WHERE status = $1 ORDER BY created DESC"
|
|
);
|
|
sqlx::query_as::<_, Execution>(&sql)
|
|
.bind(status)
|
|
.fetch_all(executor)
|
|
.await
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
pub async fn find_by_enforcement<'e, E>(
|
|
executor: E,
|
|
enforcement_id: Id,
|
|
) -> Result<Vec<Execution>>
|
|
where
|
|
E: Executor<'e, Database = Postgres> + 'e,
|
|
{
|
|
let sql = format!(
|
|
"SELECT {SELECT_COLUMNS} FROM execution WHERE enforcement = $1 ORDER BY created DESC"
|
|
);
|
|
sqlx::query_as::<_, Execution>(&sql)
|
|
.bind(enforcement_id)
|
|
.fetch_all(executor)
|
|
.await
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
/// Find all child executions for a given parent execution ID.
|
|
///
|
|
/// Returns child executions ordered by creation time (ascending),
|
|
/// which is the natural task execution order for workflows.
|
|
pub async fn find_by_parent<'e, E>(executor: E, parent_id: Id) -> Result<Vec<Execution>>
|
|
where
|
|
E: Executor<'e, Database = Postgres> + 'e,
|
|
{
|
|
let sql = format!(
|
|
"SELECT {SELECT_COLUMNS} FROM execution WHERE parent = $1 ORDER BY created ASC"
|
|
);
|
|
sqlx::query_as::<_, Execution>(&sql)
|
|
.bind(parent_id)
|
|
.fetch_all(executor)
|
|
.await
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
/// Search executions with all filters pushed into SQL.
|
|
///
|
|
/// Builds a dynamic query with only the WHERE clauses that apply,
|
|
/// a LEFT JOIN on `enforcement` when `rule_ref` or `trigger_ref` filters
|
|
/// are present (or always, to populate those columns on the result),
|
|
/// and proper LIMIT/OFFSET so pagination is server-side.
|
|
///
|
|
/// Returns both the matching page of rows and the total count.
|
|
pub async fn search<'e, E>(
|
|
db: E,
|
|
filters: &ExecutionSearchFilters,
|
|
) -> Result<ExecutionSearchResult>
|
|
where
|
|
E: Executor<'e, Database = Postgres> + Copy + 'e,
|
|
{
|
|
// We always LEFT JOIN enforcement so we can return rule_ref/trigger_ref
|
|
// on every row without a second round-trip.
|
|
let prefixed_select = SELECT_COLUMNS
|
|
.split(", ")
|
|
.map(|col| format!("e.{col}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
|
|
let select_clause =
|
|
format!("{prefixed_select}, enf.rule_ref AS rule_ref, enf.trigger_ref AS trigger_ref");
|
|
|
|
let from_clause = "FROM execution e LEFT JOIN enforcement enf ON e.enforcement = enf.id";
|
|
|
|
// ── Build WHERE clauses ──────────────────────────────────────────
|
|
let mut conditions: Vec<String> = Vec::new();
|
|
|
|
// We'll collect bind values to push into the QueryBuilder afterwards.
|
|
// Because QueryBuilder doesn't let us interleave raw SQL and binds in
|
|
// arbitrary order easily, we build the SQL string with numbered $N
|
|
// placeholders and then bind in order.
|
|
|
|
// Track the next placeholder index ($1, $2, …).
|
|
// We can't use QueryBuilder's push_bind because we need the COUNT(*)
|
|
// query to share the same WHERE clause text. Instead we build the
|
|
// clause once and execute both queries with manual binds.
|
|
|
|
// ── Use QueryBuilder for the data query ──────────────────────────
|
|
let mut qb: QueryBuilder<'_, Postgres> =
|
|
QueryBuilder::new(format!("SELECT {select_clause} {from_clause}"));
|
|
let mut count_qb: QueryBuilder<'_, Postgres> =
|
|
QueryBuilder::new(format!("SELECT COUNT(*) AS total {from_clause}"));
|
|
|
|
// Helper: append the same condition to both builders.
|
|
// We need a tiny state machine since push_bind moves the value.
|
|
macro_rules! push_condition {
|
|
($cond_prefix:expr, $value:expr) => {{
|
|
let needs_where = conditions.is_empty();
|
|
conditions.push(String::new()); // just to track count
|
|
if needs_where {
|
|
qb.push(" WHERE ");
|
|
count_qb.push(" WHERE ");
|
|
} 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);
|
|
}};
|
|
}
|
|
|
|
macro_rules! push_raw_condition {
|
|
($cond:expr) => {{
|
|
let needs_where = conditions.is_empty();
|
|
conditions.push(String::new());
|
|
if needs_where {
|
|
qb.push(concat!(" WHERE ", $cond));
|
|
count_qb.push(concat!(" WHERE ", $cond));
|
|
} else {
|
|
qb.push(concat!(" AND ", $cond));
|
|
count_qb.push(concat!(" AND ", $cond));
|
|
}
|
|
}};
|
|
}
|
|
|
|
if let Some(status) = &filters.status {
|
|
push_condition!("e.status = ", *status);
|
|
}
|
|
if let Some(action_ref) = &filters.action_ref {
|
|
push_condition!("e.action_ref = ", action_ref.clone());
|
|
}
|
|
if let Some(pack_name) = &filters.pack_name {
|
|
let pattern = format!("{pack_name}.%");
|
|
push_condition!("e.action_ref LIKE ", pattern);
|
|
}
|
|
if let Some(enforcement_id) = filters.enforcement {
|
|
push_condition!("e.enforcement = ", enforcement_id);
|
|
}
|
|
if let Some(parent_id) = filters.parent {
|
|
push_condition!("e.parent = ", parent_id);
|
|
}
|
|
if filters.top_level_only {
|
|
push_raw_condition!("e.parent IS NULL");
|
|
}
|
|
if let Some(executor_id) = filters.executor {
|
|
push_condition!("e.executor = ", executor_id);
|
|
}
|
|
if let Some(rule_ref) = &filters.rule_ref {
|
|
push_condition!("enf.rule_ref = ", rule_ref.clone());
|
|
}
|
|
if let Some(trigger_ref) = &filters.trigger_ref {
|
|
push_condition!("enf.trigger_ref = ", trigger_ref.clone());
|
|
}
|
|
if let Some(search) = &filters.result_contains {
|
|
let pattern = format!("%{}%", search.to_lowercase());
|
|
push_condition!("LOWER(e.result::text) LIKE ", pattern);
|
|
}
|
|
|
|
// ── COUNT query ──────────────────────────────────────────────────
|
|
let total: i64 = count_qb.build_query_scalar().fetch_one(db).await?;
|
|
let total = total.max(0) as u64;
|
|
|
|
// ── Data query with ORDER BY + pagination ────────────────────────
|
|
qb.push(" ORDER BY e.created DESC");
|
|
qb.push(" LIMIT ");
|
|
qb.push_bind(filters.limit as i64);
|
|
qb.push(" OFFSET ");
|
|
qb.push_bind(filters.offset as i64);
|
|
|
|
let rows: Vec<ExecutionWithRefs> = qb.build_query_as().fetch_all(db).await?;
|
|
|
|
Ok(ExecutionSearchResult { rows, total })
|
|
}
|
|
}
|