proper sql filtering

This commit is contained in:
2026-03-01 20:43:48 -06:00
parent 6b9d7d6cf2
commit bbe94d75f8
54 changed files with 6692 additions and 928 deletions

View File

@@ -319,6 +319,10 @@ pub struct EnforcementQueryParams {
#[param(example = "core.webhook")]
pub trigger_ref: Option<String>,
/// Filter by rule reference
#[param(example = "core.on_webhook")]
pub rule_ref: Option<String>,
/// Page number (1-indexed)
#[serde(default = "default_page")]
#[param(example = 1, minimum = 1)]

View File

@@ -7,6 +7,7 @@ use utoipa::{IntoParams, ToSchema};
use attune_common::models::enums::ExecutionStatus;
use attune_common::models::execution::WorkflowTaskMetadata;
use attune_common::repositories::execution::ExecutionWithRefs;
/// Request DTO for creating a manual execution
#[derive(Debug, Clone, Deserialize, ToSchema)]
@@ -63,6 +64,12 @@ pub struct ExecutionResponse {
#[schema(value_type = Object, example = json!({"message_id": "1234567890.123456"}))]
pub result: Option<JsonValue>,
/// When the execution actually started running (worker picked it up).
/// Null if the execution hasn't started running yet.
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(example = "2024-01-13T10:31:00Z", nullable = true)]
pub started_at: Option<DateTime<Utc>>,
/// Workflow task metadata (only populated for workflow task executions)
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<Object>, nullable = true)]
@@ -108,6 +115,12 @@ pub struct ExecutionSummary {
#[schema(example = "core.timer")]
pub trigger_ref: Option<String>,
/// When the execution actually started running (worker picked it up).
/// Null if the execution hasn't started running yet.
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(example = "2024-01-13T10:31:00Z", nullable = true)]
pub started_at: Option<DateTime<Utc>>,
/// Workflow task metadata (only populated for workflow task executions)
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<Object>, nullable = true)]
@@ -207,6 +220,7 @@ impl From<attune_common::models::execution::Execution> for ExecutionResponse {
result: execution
.result
.map(|r| serde_json::to_value(r).unwrap_or(JsonValue::Null)),
started_at: execution.started_at,
workflow_task: execution.workflow_task,
created: execution.created,
updated: execution.updated,
@@ -225,6 +239,7 @@ impl From<attune_common::models::execution::Execution> for ExecutionSummary {
enforcement: execution.enforcement,
rule_ref: None, // Populated separately via enforcement lookup
trigger_ref: None, // Populated separately via enforcement lookup
started_at: execution.started_at,
workflow_task: execution.workflow_task,
created: execution.created,
updated: execution.updated,
@@ -232,6 +247,26 @@ impl From<attune_common::models::execution::Execution> for ExecutionSummary {
}
}
/// Convert from the joined query result (execution + enforcement refs).
/// `rule_ref` and `trigger_ref` are already populated from the SQL JOIN.
impl From<ExecutionWithRefs> for ExecutionSummary {
fn from(row: ExecutionWithRefs) -> Self {
Self {
id: row.id,
action_ref: row.action_ref,
status: row.status,
parent: row.parent,
enforcement: row.enforcement,
rule_ref: row.rule_ref,
trigger_ref: row.trigger_ref,
started_at: row.started_at,
workflow_task: row.workflow_task,
created: row.created,
updated: row.updated,
}
}
}
fn default_page() -> u32 {
1
}

View File

@@ -11,10 +11,10 @@ use std::sync::Arc;
use validator::Validate;
use attune_common::repositories::{
action::{ActionRepository, CreateActionInput, UpdateActionInput},
action::{ActionRepository, ActionSearchFilters, CreateActionInput, UpdateActionInput},
pack::PackRepository,
queue_stats::QueueStatsRepository,
Create, Delete, FindByRef, List, Update,
Create, Delete, FindByRef, Update,
};
use crate::{
@@ -47,21 +47,20 @@ pub async fn list_actions(
RequireAuth(_user): RequireAuth,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<impl IntoResponse> {
// Get all actions (we'll implement pagination in repository later)
let actions = ActionRepository::list(&state.db).await?;
// All filtering and pagination happen in a single SQL query.
let filters = ActionSearchFilters {
pack: None,
query: None,
limit: pagination.limit(),
offset: pagination.offset(),
};
// Calculate pagination
let total = actions.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(actions.len());
let result = ActionRepository::list_search(&state.db, &filters).await?;
// Get paginated slice
let paginated_actions: Vec<ActionSummary> = actions[start..end]
.iter()
.map(|a| ActionSummary::from(a.clone()))
.collect();
let paginated_actions: Vec<ActionSummary> =
result.rows.into_iter().map(ActionSummary::from).collect();
let response = PaginatedResponse::new(paginated_actions, &pagination, total);
let response = PaginatedResponse::new(paginated_actions, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}
@@ -92,21 +91,20 @@ pub async fn list_actions_by_pack(
.await?
.ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", pack_ref)))?;
// Get actions for this pack
let actions = ActionRepository::find_by_pack(&state.db, pack.id).await?;
// All filtering and pagination happen in a single SQL query.
let filters = ActionSearchFilters {
pack: Some(pack.id),
query: None,
limit: pagination.limit(),
offset: pagination.offset(),
};
// Calculate pagination
let total = actions.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(actions.len());
let result = ActionRepository::list_search(&state.db, &filters).await?;
// Get paginated slice
let paginated_actions: Vec<ActionSummary> = actions[start..end]
.iter()
.map(|a| ActionSummary::from(a.clone()))
.collect();
let paginated_actions: Vec<ActionSummary> =
result.rows.into_iter().map(ActionSummary::from).collect();
let response = PaginatedResponse::new(paginated_actions, &pagination, total);
let response = PaginatedResponse::new(paginated_actions, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}

View File

@@ -16,9 +16,12 @@ use validator::Validate;
use attune_common::{
mq::{EventCreatedPayload, MessageEnvelope, MessageType},
repositories::{
event::{CreateEventInput, EnforcementRepository, EventRepository},
event::{
CreateEventInput, EnforcementRepository, EnforcementSearchFilters, EventRepository,
EventSearchFilters,
},
trigger::TriggerRepository,
Create, FindById, FindByRef, List,
Create, FindById, FindByRef,
},
};
@@ -220,53 +223,27 @@ pub async fn list_events(
State(state): State<Arc<AppState>>,
Query(query): Query<EventQueryParams>,
) -> ApiResult<impl IntoResponse> {
// Get events based on filters
let events = if let Some(trigger_id) = query.trigger {
// Filter by trigger ID
EventRepository::find_by_trigger(&state.db, trigger_id).await?
} else if let Some(trigger_ref) = &query.trigger_ref {
// Filter by trigger reference
EventRepository::find_by_trigger_ref(&state.db, trigger_ref).await?
} else {
// Get all events
EventRepository::list(&state.db).await?
// All filtering and pagination happen in a single SQL query.
let filters = EventSearchFilters {
trigger: query.trigger,
trigger_ref: query.trigger_ref.clone(),
source: query.source,
rule_ref: query.rule_ref.clone(),
limit: query.limit(),
offset: query.offset(),
};
// Apply additional filters in memory
let mut filtered_events = events;
let result = EventRepository::search(&state.db, &filters).await?;
if let Some(source_id) = query.source {
filtered_events.retain(|e| e.source == Some(source_id));
}
let paginated_events: Vec<EventSummary> =
result.rows.into_iter().map(EventSummary::from).collect();
if let Some(rule_ref) = &query.rule_ref {
let rule_ref_lower = rule_ref.to_lowercase();
filtered_events.retain(|e| {
e.rule_ref
.as_ref()
.map(|r| r.to_lowercase().contains(&rule_ref_lower))
.unwrap_or(false)
});
}
// Calculate pagination
let total = filtered_events.len() as u64;
let start = query.offset() as usize;
let end = (start + query.limit() as usize).min(filtered_events.len());
// Get paginated slice
let paginated_events: Vec<EventSummary> = filtered_events[start..end]
.iter()
.map(|event| EventSummary::from(event.clone()))
.collect();
// Convert query params to pagination params for response
let pagination_params = PaginationParams {
page: query.page,
page_size: query.per_page,
};
let response = PaginatedResponse::new(paginated_events, &pagination_params, total);
let response = PaginatedResponse::new(paginated_events, &pagination_params, result.total);
Ok((StatusCode::OK, Json(response)))
}
@@ -319,46 +296,32 @@ pub async fn list_enforcements(
State(state): State<Arc<AppState>>,
Query(query): Query<EnforcementQueryParams>,
) -> ApiResult<impl IntoResponse> {
// Get enforcements based on filters
let enforcements = if let Some(status) = query.status {
// Filter by status
EnforcementRepository::find_by_status(&state.db, status).await?
} else if let Some(rule_id) = query.rule {
// Filter by rule ID
EnforcementRepository::find_by_rule(&state.db, rule_id).await?
} else if let Some(event_id) = query.event {
// Filter by event ID
EnforcementRepository::find_by_event(&state.db, event_id).await?
} else {
// Get all enforcements
EnforcementRepository::list(&state.db).await?
// All filtering and pagination happen in a single SQL query.
// Filters are combinable (AND), not mutually exclusive.
let filters = EnforcementSearchFilters {
status: query.status,
rule: query.rule,
event: query.event,
trigger_ref: query.trigger_ref.clone(),
rule_ref: query.rule_ref.clone(),
limit: query.limit(),
offset: query.offset(),
};
// Apply additional filters in memory
let mut filtered_enforcements = enforcements;
let result = EnforcementRepository::search(&state.db, &filters).await?;
if let Some(trigger_ref) = &query.trigger_ref {
filtered_enforcements.retain(|e| e.trigger_ref == *trigger_ref);
}
// Calculate pagination
let total = filtered_enforcements.len() as u64;
let start = query.offset() as usize;
let end = (start + query.limit() as usize).min(filtered_enforcements.len());
// Get paginated slice
let paginated_enforcements: Vec<EnforcementSummary> = filtered_enforcements[start..end]
.iter()
.map(|enforcement| EnforcementSummary::from(enforcement.clone()))
let paginated_enforcements: Vec<EnforcementSummary> = result
.rows
.into_iter()
.map(EnforcementSummary::from)
.collect();
// Convert query params to pagination params for response
let pagination_params = PaginationParams {
page: query.page,
page_size: query.per_page,
};
let response = PaginatedResponse::new(paginated_enforcements, &pagination_params, total);
let response = PaginatedResponse::new(paginated_enforcements, &pagination_params, result.total);
Ok((StatusCode::OK, Json(response)))
}

View File

@@ -18,9 +18,10 @@ use attune_common::models::enums::ExecutionStatus;
use attune_common::mq::{ExecutionRequestedPayload, MessageEnvelope, MessageType};
use attune_common::repositories::{
action::ActionRepository,
execution::{CreateExecutionInput, ExecutionRepository},
Create, EnforcementRepository, FindById, FindByRef, List,
execution::{CreateExecutionInput, ExecutionRepository, ExecutionSearchFilters},
Create, FindById, FindByRef,
};
use sqlx::Row;
use crate::{
auth::middleware::RequireAuth,
@@ -125,117 +126,37 @@ pub async fn list_executions(
RequireAuth(_user): RequireAuth,
Query(query): Query<ExecutionQueryParams>,
) -> ApiResult<impl IntoResponse> {
// Get executions based on filters
let executions = if let Some(status) = query.status {
// Filter by status
ExecutionRepository::find_by_status(&state.db, status).await?
} else if let Some(enforcement_id) = query.enforcement {
// Filter by enforcement
ExecutionRepository::find_by_enforcement(&state.db, enforcement_id).await?
} else {
// Get all executions
ExecutionRepository::list(&state.db).await?
// All filtering, pagination, and the enforcement JOIN happen in a single
// SQL query — no in-memory filtering or post-fetch lookups.
let filters = ExecutionSearchFilters {
status: query.status,
action_ref: query.action_ref.clone(),
pack_name: query.pack_name.clone(),
rule_ref: query.rule_ref.clone(),
trigger_ref: query.trigger_ref.clone(),
executor: query.executor,
result_contains: query.result_contains.clone(),
enforcement: query.enforcement,
parent: query.parent,
top_level_only: query.top_level_only == Some(true),
limit: query.limit(),
offset: query.offset(),
};
// Apply additional filters in memory (could be optimized with database queries)
let mut filtered_executions = executions;
let result = ExecutionRepository::search(&state.db, &filters).await?;
if let Some(action_ref) = &query.action_ref {
filtered_executions.retain(|e| e.action_ref == *action_ref);
}
if let Some(pack_name) = &query.pack_name {
filtered_executions.retain(|e| {
// action_ref format is "pack.action"
e.action_ref.starts_with(&format!("{}.", pack_name))
});
}
if let Some(result_search) = &query.result_contains {
let search_lower = result_search.to_lowercase();
filtered_executions.retain(|e| {
if let Some(result) = &e.result {
// Convert result to JSON string and search case-insensitively
let result_str = serde_json::to_string(result).unwrap_or_default();
result_str.to_lowercase().contains(&search_lower)
} else {
false
}
});
}
if let Some(parent_id) = query.parent {
filtered_executions.retain(|e| e.parent == Some(parent_id));
}
if query.top_level_only == Some(true) {
filtered_executions.retain(|e| e.parent.is_none());
}
if let Some(executor_id) = query.executor {
filtered_executions.retain(|e| e.executor == Some(executor_id));
}
// Fetch enforcements for all executions to populate rule_ref and trigger_ref
let enforcement_ids: Vec<i64> = filtered_executions
.iter()
.filter_map(|e| e.enforcement)
let paginated_executions: Vec<ExecutionSummary> = result
.rows
.into_iter()
.map(ExecutionSummary::from)
.collect();
let enforcement_map: std::collections::HashMap<i64, _> = if !enforcement_ids.is_empty() {
let enforcements = EnforcementRepository::list(&state.db).await?;
enforcements.into_iter().map(|enf| (enf.id, enf)).collect()
} else {
std::collections::HashMap::new()
};
// Filter by rule_ref if specified
if let Some(rule_ref) = &query.rule_ref {
filtered_executions.retain(|e| {
e.enforcement
.and_then(|enf_id| enforcement_map.get(&enf_id))
.map(|enf| enf.rule_ref == *rule_ref)
.unwrap_or(false)
});
}
// Filter by trigger_ref if specified
if let Some(trigger_ref) = &query.trigger_ref {
filtered_executions.retain(|e| {
e.enforcement
.and_then(|enf_id| enforcement_map.get(&enf_id))
.map(|enf| enf.trigger_ref == *trigger_ref)
.unwrap_or(false)
});
}
// Calculate pagination
let total = filtered_executions.len() as u64;
let start = query.offset() as usize;
let end = (start + query.limit() as usize).min(filtered_executions.len());
// Get paginated slice and populate rule_ref/trigger_ref from enforcements
let paginated_executions: Vec<ExecutionSummary> = filtered_executions[start..end]
.iter()
.map(|e| {
let mut summary = ExecutionSummary::from(e.clone());
if let Some(enf_id) = e.enforcement {
if let Some(enforcement) = enforcement_map.get(&enf_id) {
summary.rule_ref = Some(enforcement.rule_ref.clone());
summary.trigger_ref = Some(enforcement.trigger_ref.clone());
}
}
summary
})
.collect();
// Convert query params to pagination params for response
let pagination_params = PaginationParams {
page: query.page,
page_size: query.per_page,
};
let response = PaginatedResponse::new(paginated_executions, &pagination_params, total);
let response = PaginatedResponse::new(paginated_executions, &pagination_params, result.total);
Ok((StatusCode::OK, Json(response)))
}
@@ -310,21 +231,23 @@ pub async fn list_executions_by_status(
}
};
// Get executions by status
let executions = ExecutionRepository::find_by_status(&state.db, status).await?;
// Use the search method for SQL-side filtering + pagination.
let filters = ExecutionSearchFilters {
status: Some(status),
limit: pagination.limit(),
offset: pagination.offset(),
..Default::default()
};
// Calculate pagination
let total = executions.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(executions.len());
let result = ExecutionRepository::search(&state.db, &filters).await?;
// Get paginated slice
let paginated_executions: Vec<ExecutionSummary> = executions[start..end]
.iter()
.map(|e| ExecutionSummary::from(e.clone()))
let paginated_executions: Vec<ExecutionSummary> = result
.rows
.into_iter()
.map(ExecutionSummary::from)
.collect();
let response = PaginatedResponse::new(paginated_executions, &pagination, total);
let response = PaginatedResponse::new(paginated_executions, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}
@@ -350,21 +273,23 @@ pub async fn list_executions_by_enforcement(
Path(enforcement_id): Path<i64>,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<impl IntoResponse> {
// Get executions by enforcement
let executions = ExecutionRepository::find_by_enforcement(&state.db, enforcement_id).await?;
// Use the search method for SQL-side filtering + pagination.
let filters = ExecutionSearchFilters {
enforcement: Some(enforcement_id),
limit: pagination.limit(),
offset: pagination.offset(),
..Default::default()
};
// Calculate pagination
let total = executions.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(executions.len());
let result = ExecutionRepository::search(&state.db, &filters).await?;
// Get paginated slice
let paginated_executions: Vec<ExecutionSummary> = executions[start..end]
.iter()
.map(|e| ExecutionSummary::from(e.clone()))
let paginated_executions: Vec<ExecutionSummary> = result
.rows
.into_iter()
.map(ExecutionSummary::from)
.collect();
let response = PaginatedResponse::new(paginated_executions, &pagination, total);
let response = PaginatedResponse::new(paginated_executions, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}
@@ -384,34 +309,37 @@ pub async fn get_execution_stats(
State(state): State<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
) -> ApiResult<impl IntoResponse> {
// Get all executions (limited by repository to 1000)
let executions = ExecutionRepository::list(&state.db).await?;
// Use a single SQL query with COUNT + GROUP BY instead of fetching all rows.
let rows = sqlx::query(
"SELECT status::text AS status, COUNT(*) AS cnt FROM execution GROUP BY status",
)
.fetch_all(&state.db)
.await?;
// Calculate statistics
let total = executions.len();
let completed = executions
.iter()
.filter(|e| e.status == attune_common::models::enums::ExecutionStatus::Completed)
.count();
let failed = executions
.iter()
.filter(|e| e.status == attune_common::models::enums::ExecutionStatus::Failed)
.count();
let running = executions
.iter()
.filter(|e| e.status == attune_common::models::enums::ExecutionStatus::Running)
.count();
let pending = executions
.iter()
.filter(|e| {
matches!(
e.status,
attune_common::models::enums::ExecutionStatus::Requested
| attune_common::models::enums::ExecutionStatus::Scheduling
| attune_common::models::enums::ExecutionStatus::Scheduled
)
})
.count();
let mut completed: i64 = 0;
let mut failed: i64 = 0;
let mut running: i64 = 0;
let mut pending: i64 = 0;
let mut cancelled: i64 = 0;
let mut timeout: i64 = 0;
let mut abandoned: i64 = 0;
let mut total: i64 = 0;
for row in &rows {
let status: &str = row.get("status");
let cnt: i64 = row.get("cnt");
total += cnt;
match status {
"completed" => completed = cnt,
"failed" => failed = cnt,
"running" => running = cnt,
"requested" | "scheduling" | "scheduled" => pending += cnt,
"cancelled" | "canceling" => cancelled += cnt,
"timeout" => timeout = cnt,
"abandoned" => abandoned = cnt,
_ => {}
}
}
let stats = serde_json::json!({
"total": total,
@@ -419,9 +347,9 @@ pub async fn get_execution_stats(
"failed": failed,
"running": running,
"pending": pending,
"cancelled": executions.iter().filter(|e| e.status == attune_common::models::enums::ExecutionStatus::Cancelled).count(),
"timeout": executions.iter().filter(|e| e.status == attune_common::models::enums::ExecutionStatus::Timeout).count(),
"abandoned": executions.iter().filter(|e| e.status == attune_common::models::enums::ExecutionStatus::Abandoned).count(),
"cancelled": cancelled,
"timeout": timeout,
"abandoned": abandoned,
});
let response = ApiResponse::new(stats);

View File

@@ -14,8 +14,10 @@ use attune_common::{
mq::{InquiryRespondedPayload, MessageEnvelope, MessageType},
repositories::{
execution::ExecutionRepository,
inquiry::{CreateInquiryInput, InquiryRepository, UpdateInquiryInput},
Create, Delete, FindById, List, Update,
inquiry::{
CreateInquiryInput, InquiryRepository, InquirySearchFilters, UpdateInquiryInput,
},
Create, Delete, FindById, Update,
},
};
@@ -51,45 +53,30 @@ pub async fn list_inquiries(
State(state): State<Arc<AppState>>,
Query(query): Query<InquiryQueryParams>,
) -> ApiResult<impl IntoResponse> {
// Get inquiries based on filters
let inquiries = if let Some(status) = query.status {
// Filter by status
InquiryRepository::find_by_status(&state.db, status).await?
} else if let Some(execution_id) = query.execution {
// Filter by execution
InquiryRepository::find_by_execution(&state.db, execution_id).await?
} else {
// Get all inquiries
InquiryRepository::list(&state.db).await?
// All filtering and pagination happen in a single SQL query.
// Filters are combinable (AND), not mutually exclusive.
let limit = query.limit.unwrap_or(50).min(500) as u32;
let offset = query.offset.unwrap_or(0) as u32;
let filters = InquirySearchFilters {
status: query.status,
execution: query.execution,
assigned_to: query.assigned_to,
limit,
offset,
};
// Apply additional filters in memory
let mut filtered_inquiries = inquiries;
let result = InquiryRepository::search(&state.db, &filters).await?;
if let Some(assigned_to) = query.assigned_to {
filtered_inquiries.retain(|i| i.assigned_to == Some(assigned_to));
}
let paginated_inquiries: Vec<InquirySummary> =
result.rows.into_iter().map(InquirySummary::from).collect();
// Calculate pagination
let total = filtered_inquiries.len() as u64;
let offset = query.offset.unwrap_or(0);
let limit = query.limit.unwrap_or(50).min(500);
let start = offset;
let end = (start + limit).min(filtered_inquiries.len());
// Get paginated slice
let paginated_inquiries: Vec<InquirySummary> = filtered_inquiries[start..end]
.iter()
.map(|inquiry| InquirySummary::from(inquiry.clone()))
.collect();
// Convert to pagination params for response
let pagination_params = PaginationParams {
page: (offset / limit.max(1)) as u32 + 1,
page_size: limit as u32,
page: (offset / limit.max(1)) + 1,
page_size: limit,
};
let response = PaginatedResponse::new(paginated_inquiries, &pagination_params, total);
let response = PaginatedResponse::new(paginated_inquiries, &pagination_params, result.total);
Ok((StatusCode::OK, Json(response)))
}
@@ -161,20 +148,21 @@ pub async fn list_inquiries_by_status(
}
};
let inquiries = InquiryRepository::find_by_status(&state.db, status).await?;
// Use the search method for SQL-side filtering + pagination.
let filters = InquirySearchFilters {
status: Some(status),
execution: None,
assigned_to: None,
limit: pagination.limit(),
offset: pagination.offset(),
};
// Calculate pagination
let total = inquiries.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(inquiries.len());
let result = InquiryRepository::search(&state.db, &filters).await?;
// Get paginated slice
let paginated_inquiries: Vec<InquirySummary> = inquiries[start..end]
.iter()
.map(|inquiry| InquirySummary::from(inquiry.clone()))
.collect();
let paginated_inquiries: Vec<InquirySummary> =
result.rows.into_iter().map(InquirySummary::from).collect();
let response = PaginatedResponse::new(paginated_inquiries, &pagination, total);
let response = PaginatedResponse::new(paginated_inquiries, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}
@@ -209,20 +197,21 @@ pub async fn list_inquiries_by_execution(
ApiError::NotFound(format!("Execution with ID {} not found", execution_id))
})?;
let inquiries = InquiryRepository::find_by_execution(&state.db, execution_id).await?;
// Use the search method for SQL-side filtering + pagination.
let filters = InquirySearchFilters {
status: None,
execution: Some(execution_id),
assigned_to: None,
limit: pagination.limit(),
offset: pagination.offset(),
};
// Calculate pagination
let total = inquiries.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(inquiries.len());
let result = InquiryRepository::search(&state.db, &filters).await?;
// Get paginated slice
let paginated_inquiries: Vec<InquirySummary> = inquiries[start..end]
.iter()
.map(|inquiry| InquirySummary::from(inquiry.clone()))
.collect();
let paginated_inquiries: Vec<InquirySummary> =
result.rows.into_iter().map(InquirySummary::from).collect();
let response = PaginatedResponse::new(paginated_inquiries, &pagination, total);
let response = PaginatedResponse::new(paginated_inquiries, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}

View File

@@ -13,10 +13,10 @@ use validator::Validate;
use attune_common::models::OwnerType;
use attune_common::repositories::{
action::ActionRepository,
key::{CreateKeyInput, KeyRepository, UpdateKeyInput},
key::{CreateKeyInput, KeyRepository, KeySearchFilters, UpdateKeyInput},
pack::PackRepository,
trigger::SensorRepository,
Create, Delete, FindByRef, List, Update,
Create, Delete, FindByRef, Update,
};
use crate::auth::RequireAuth;
@@ -46,40 +46,24 @@ pub async fn list_keys(
State(state): State<Arc<AppState>>,
Query(query): Query<KeyQueryParams>,
) -> ApiResult<impl IntoResponse> {
// Get keys based on filters
let keys = if let Some(owner_type) = query.owner_type {
// Filter by owner type
KeyRepository::find_by_owner_type(&state.db, owner_type).await?
} else {
// Get all keys
KeyRepository::list(&state.db).await?
// All filtering and pagination happen in a single SQL query.
let filters = KeySearchFilters {
owner_type: query.owner_type,
owner: query.owner.clone(),
limit: query.limit(),
offset: query.offset(),
};
// Apply additional filters in memory
let mut filtered_keys = keys;
let result = KeyRepository::search(&state.db, &filters).await?;
if let Some(owner) = &query.owner {
filtered_keys.retain(|k| k.owner.as_ref() == Some(owner));
}
let paginated_keys: Vec<KeySummary> = result.rows.into_iter().map(KeySummary::from).collect();
// Calculate pagination
let total = filtered_keys.len() as u64;
let start = query.offset() as usize;
let end = (start + query.limit() as usize).min(filtered_keys.len());
// Get paginated slice (values redacted in summary)
let paginated_keys: Vec<KeySummary> = filtered_keys[start..end]
.iter()
.map(|key| KeySummary::from(key.clone()))
.collect();
// Convert query params to pagination params for response
let pagination_params = PaginationParams {
page: query.page,
page_size: query.per_page,
};
let response = PaginatedResponse::new(paginated_keys, &pagination_params, total);
let response = PaginatedResponse::new(paginated_keys, &pagination_params, result.total);
Ok((StatusCode::OK, Json(response)))
}

View File

@@ -17,9 +17,9 @@ use attune_common::mq::{
use attune_common::repositories::{
action::ActionRepository,
pack::PackRepository,
rule::{CreateRuleInput, RuleRepository, UpdateRuleInput},
rule::{CreateRuleInput, RuleRepository, RuleSearchFilters, UpdateRuleInput},
trigger::TriggerRepository,
Create, Delete, FindByRef, List, Update,
Create, Delete, FindByRef, Update,
};
use crate::{
@@ -50,21 +50,21 @@ pub async fn list_rules(
RequireAuth(_user): RequireAuth,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<impl IntoResponse> {
// Get all rules
let rules = RuleRepository::list(&state.db).await?;
let filters = RuleSearchFilters {
pack: None,
action: None,
trigger: None,
enabled: None,
limit: pagination.limit(),
offset: pagination.offset(),
};
// Calculate pagination
let total = rules.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(rules.len());
let result = RuleRepository::list_search(&state.db, &filters).await?;
// Get paginated slice
let paginated_rules: Vec<RuleSummary> = rules[start..end]
.iter()
.map(|r| RuleSummary::from(r.clone()))
.collect();
let paginated_rules: Vec<RuleSummary> =
result.rows.into_iter().map(RuleSummary::from).collect();
let response = PaginatedResponse::new(paginated_rules, &pagination, total);
let response = PaginatedResponse::new(paginated_rules, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}
@@ -85,21 +85,21 @@ pub async fn list_enabled_rules(
RequireAuth(_user): RequireAuth,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<impl IntoResponse> {
// Get enabled rules
let rules = RuleRepository::find_enabled(&state.db).await?;
let filters = RuleSearchFilters {
pack: None,
action: None,
trigger: None,
enabled: Some(true),
limit: pagination.limit(),
offset: pagination.offset(),
};
// Calculate pagination
let total = rules.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(rules.len());
let result = RuleRepository::list_search(&state.db, &filters).await?;
// Get paginated slice
let paginated_rules: Vec<RuleSummary> = rules[start..end]
.iter()
.map(|r| RuleSummary::from(r.clone()))
.collect();
let paginated_rules: Vec<RuleSummary> =
result.rows.into_iter().map(RuleSummary::from).collect();
let response = PaginatedResponse::new(paginated_rules, &pagination, total);
let response = PaginatedResponse::new(paginated_rules, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}
@@ -130,21 +130,21 @@ pub async fn list_rules_by_pack(
.await?
.ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", pack_ref)))?;
// Get rules for this pack
let rules = RuleRepository::find_by_pack(&state.db, pack.id).await?;
let filters = RuleSearchFilters {
pack: Some(pack.id),
action: None,
trigger: None,
enabled: None,
limit: pagination.limit(),
offset: pagination.offset(),
};
// Calculate pagination
let total = rules.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(rules.len());
let result = RuleRepository::list_search(&state.db, &filters).await?;
// Get paginated slice
let paginated_rules: Vec<RuleSummary> = rules[start..end]
.iter()
.map(|r| RuleSummary::from(r.clone()))
.collect();
let paginated_rules: Vec<RuleSummary> =
result.rows.into_iter().map(RuleSummary::from).collect();
let response = PaginatedResponse::new(paginated_rules, &pagination, total);
let response = PaginatedResponse::new(paginated_rules, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}
@@ -175,21 +175,21 @@ pub async fn list_rules_by_action(
.await?
.ok_or_else(|| ApiError::NotFound(format!("Action '{}' not found", action_ref)))?;
// Get rules for this action
let rules = RuleRepository::find_by_action(&state.db, action.id).await?;
let filters = RuleSearchFilters {
pack: None,
action: Some(action.id),
trigger: None,
enabled: None,
limit: pagination.limit(),
offset: pagination.offset(),
};
// Calculate pagination
let total = rules.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(rules.len());
let result = RuleRepository::list_search(&state.db, &filters).await?;
// Get paginated slice
let paginated_rules: Vec<RuleSummary> = rules[start..end]
.iter()
.map(|r| RuleSummary::from(r.clone()))
.collect();
let paginated_rules: Vec<RuleSummary> =
result.rows.into_iter().map(RuleSummary::from).collect();
let response = PaginatedResponse::new(paginated_rules, &pagination, total);
let response = PaginatedResponse::new(paginated_rules, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}
@@ -220,21 +220,21 @@ pub async fn list_rules_by_trigger(
.await?
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
// Get rules for this trigger
let rules = RuleRepository::find_by_trigger(&state.db, trigger.id).await?;
let filters = RuleSearchFilters {
pack: None,
action: None,
trigger: Some(trigger.id),
enabled: None,
limit: pagination.limit(),
offset: pagination.offset(),
};
// Calculate pagination
let total = rules.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(rules.len());
let result = RuleRepository::list_search(&state.db, &filters).await?;
// Get paginated slice
let paginated_rules: Vec<RuleSummary> = rules[start..end]
.iter()
.map(|r| RuleSummary::from(r.clone()))
.collect();
let paginated_rules: Vec<RuleSummary> =
result.rows.into_iter().map(RuleSummary::from).collect();
let response = PaginatedResponse::new(paginated_rules, &pagination, total);
let response = PaginatedResponse::new(paginated_rules, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}

View File

@@ -14,10 +14,10 @@ use attune_common::repositories::{
pack::PackRepository,
runtime::RuntimeRepository,
trigger::{
CreateSensorInput, CreateTriggerInput, SensorRepository, TriggerRepository,
UpdateSensorInput, UpdateTriggerInput,
CreateSensorInput, CreateTriggerInput, SensorRepository, SensorSearchFilters,
TriggerRepository, TriggerSearchFilters, UpdateSensorInput, UpdateTriggerInput,
},
Create, Delete, FindByRef, List, Update,
Create, Delete, FindByRef, Update,
};
use crate::{
@@ -54,21 +54,19 @@ pub async fn list_triggers(
RequireAuth(_user): RequireAuth,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<impl IntoResponse> {
// Get all triggers
let triggers = TriggerRepository::list(&state.db).await?;
let filters = TriggerSearchFilters {
pack: None,
enabled: None,
limit: pagination.limit(),
offset: pagination.offset(),
};
// Calculate pagination
let total = triggers.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(triggers.len());
let result = TriggerRepository::list_search(&state.db, &filters).await?;
// Get paginated slice
let paginated_triggers: Vec<TriggerSummary> = triggers[start..end]
.iter()
.map(|t| TriggerSummary::from(t.clone()))
.collect();
let paginated_triggers: Vec<TriggerSummary> =
result.rows.into_iter().map(TriggerSummary::from).collect();
let response = PaginatedResponse::new(paginated_triggers, &pagination, total);
let response = PaginatedResponse::new(paginated_triggers, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}
@@ -89,21 +87,19 @@ pub async fn list_enabled_triggers(
RequireAuth(_user): RequireAuth,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<impl IntoResponse> {
// Get enabled triggers
let triggers = TriggerRepository::find_enabled(&state.db).await?;
let filters = TriggerSearchFilters {
pack: None,
enabled: Some(true),
limit: pagination.limit(),
offset: pagination.offset(),
};
// Calculate pagination
let total = triggers.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(triggers.len());
let result = TriggerRepository::list_search(&state.db, &filters).await?;
// Get paginated slice
let paginated_triggers: Vec<TriggerSummary> = triggers[start..end]
.iter()
.map(|t| TriggerSummary::from(t.clone()))
.collect();
let paginated_triggers: Vec<TriggerSummary> =
result.rows.into_iter().map(TriggerSummary::from).collect();
let response = PaginatedResponse::new(paginated_triggers, &pagination, total);
let response = PaginatedResponse::new(paginated_triggers, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}
@@ -134,21 +130,19 @@ pub async fn list_triggers_by_pack(
.await?
.ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", pack_ref)))?;
// Get triggers for this pack
let triggers = TriggerRepository::find_by_pack(&state.db, pack.id).await?;
let filters = TriggerSearchFilters {
pack: Some(pack.id),
enabled: None,
limit: pagination.limit(),
offset: pagination.offset(),
};
// Calculate pagination
let total = triggers.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(triggers.len());
let result = TriggerRepository::list_search(&state.db, &filters).await?;
// Get paginated slice
let paginated_triggers: Vec<TriggerSummary> = triggers[start..end]
.iter()
.map(|t| TriggerSummary::from(t.clone()))
.collect();
let paginated_triggers: Vec<TriggerSummary> =
result.rows.into_iter().map(TriggerSummary::from).collect();
let response = PaginatedResponse::new(paginated_triggers, &pagination, total);
let response = PaginatedResponse::new(paginated_triggers, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}
@@ -438,21 +432,20 @@ pub async fn list_sensors(
RequireAuth(_user): RequireAuth,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<impl IntoResponse> {
// Get all sensors
let sensors = SensorRepository::list(&state.db).await?;
let filters = SensorSearchFilters {
pack: None,
trigger: None,
enabled: None,
limit: pagination.limit(),
offset: pagination.offset(),
};
// Calculate pagination
let total = sensors.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(sensors.len());
let result = SensorRepository::list_search(&state.db, &filters).await?;
// Get paginated slice
let paginated_sensors: Vec<SensorSummary> = sensors[start..end]
.iter()
.map(|s| SensorSummary::from(s.clone()))
.collect();
let paginated_sensors: Vec<SensorSummary> =
result.rows.into_iter().map(SensorSummary::from).collect();
let response = PaginatedResponse::new(paginated_sensors, &pagination, total);
let response = PaginatedResponse::new(paginated_sensors, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}
@@ -473,21 +466,20 @@ pub async fn list_enabled_sensors(
RequireAuth(_user): RequireAuth,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<impl IntoResponse> {
// Get enabled sensors
let sensors = SensorRepository::find_enabled(&state.db).await?;
let filters = SensorSearchFilters {
pack: None,
trigger: None,
enabled: Some(true),
limit: pagination.limit(),
offset: pagination.offset(),
};
// Calculate pagination
let total = sensors.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(sensors.len());
let result = SensorRepository::list_search(&state.db, &filters).await?;
// Get paginated slice
let paginated_sensors: Vec<SensorSummary> = sensors[start..end]
.iter()
.map(|s| SensorSummary::from(s.clone()))
.collect();
let paginated_sensors: Vec<SensorSummary> =
result.rows.into_iter().map(SensorSummary::from).collect();
let response = PaginatedResponse::new(paginated_sensors, &pagination, total);
let response = PaginatedResponse::new(paginated_sensors, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}
@@ -518,21 +510,20 @@ pub async fn list_sensors_by_pack(
.await?
.ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", pack_ref)))?;
// Get sensors for this pack
let sensors = SensorRepository::find_by_pack(&state.db, pack.id).await?;
let filters = SensorSearchFilters {
pack: Some(pack.id),
trigger: None,
enabled: None,
limit: pagination.limit(),
offset: pagination.offset(),
};
// Calculate pagination
let total = sensors.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(sensors.len());
let result = SensorRepository::list_search(&state.db, &filters).await?;
// Get paginated slice
let paginated_sensors: Vec<SensorSummary> = sensors[start..end]
.iter()
.map(|s| SensorSummary::from(s.clone()))
.collect();
let paginated_sensors: Vec<SensorSummary> =
result.rows.into_iter().map(SensorSummary::from).collect();
let response = PaginatedResponse::new(paginated_sensors, &pagination, total);
let response = PaginatedResponse::new(paginated_sensors, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}
@@ -563,21 +554,20 @@ pub async fn list_sensors_by_trigger(
.await?
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
// Get sensors for this trigger
let sensors = SensorRepository::find_by_trigger(&state.db, trigger.id).await?;
let filters = SensorSearchFilters {
pack: None,
trigger: Some(trigger.id),
enabled: None,
limit: pagination.limit(),
offset: pagination.offset(),
};
// Calculate pagination
let total = sensors.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(sensors.len());
let result = SensorRepository::list_search(&state.db, &filters).await?;
// Get paginated slice
let paginated_sensors: Vec<SensorSummary> = sensors[start..end]
.iter()
.map(|s| SensorSummary::from(s.clone()))
.collect();
let paginated_sensors: Vec<SensorSummary> =
result.rows.into_iter().map(SensorSummary::from).collect();
let response = PaginatedResponse::new(paginated_sensors, &pagination, total);
let response = PaginatedResponse::new(paginated_sensors, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}

View File

@@ -16,8 +16,9 @@ use attune_common::repositories::{
pack::PackRepository,
workflow::{
CreateWorkflowDefinitionInput, UpdateWorkflowDefinitionInput, WorkflowDefinitionRepository,
WorkflowSearchFilters,
},
Create, Delete, FindByRef, List, Update,
Create, Delete, FindByRef, Update,
};
use crate::{
@@ -54,64 +55,30 @@ pub async fn list_workflows(
// Validate search params
search_params.validate()?;
// Get workflows based on filters
let mut workflows = if let Some(tags_str) = &search_params.tags {
// Filter by tags
let tags: Vec<&str> = tags_str.split(',').map(|s| s.trim()).collect();
let mut results = Vec::new();
for tag in tags {
let mut tag_results = WorkflowDefinitionRepository::find_by_tag(&state.db, tag).await?;
results.append(&mut tag_results);
}
// Remove duplicates by ID
results.sort_by_key(|w| w.id);
results.dedup_by_key(|w| w.id);
results
} else if search_params.enabled == Some(true) {
// Filter by enabled status (only return enabled workflows)
WorkflowDefinitionRepository::find_enabled(&state.db).await?
} else {
// Get all workflows
WorkflowDefinitionRepository::list(&state.db).await?
// Parse comma-separated tags into a Vec if provided
let tags = search_params.tags.as_ref().map(|t| {
t.split(',')
.map(|s| s.trim().to_string())
.collect::<Vec<_>>()
});
// All filtering and pagination happen in a single SQL query.
let filters = WorkflowSearchFilters {
pack: None,
pack_ref: search_params.pack_ref.clone(),
enabled: search_params.enabled,
tags,
search: search_params.search.clone(),
limit: pagination.limit(),
offset: pagination.offset(),
};
// Apply enabled filter if specified and not already filtered by it
if let Some(enabled) = search_params.enabled {
if search_params.tags.is_some() {
// If we filtered by tags, also apply enabled filter
workflows.retain(|w| w.enabled == enabled);
}
}
let result = WorkflowDefinitionRepository::list_search(&state.db, &filters).await?;
// Apply search filter if provided
if let Some(search_term) = &search_params.search {
let search_lower = search_term.to_lowercase();
workflows.retain(|w| {
w.label.to_lowercase().contains(&search_lower)
|| w.description
.as_ref()
.map(|d| d.to_lowercase().contains(&search_lower))
.unwrap_or(false)
});
}
let paginated_workflows: Vec<WorkflowSummary> =
result.rows.into_iter().map(WorkflowSummary::from).collect();
// Apply pack_ref filter if provided
if let Some(pack_ref) = &search_params.pack_ref {
workflows.retain(|w| w.pack_ref == *pack_ref);
}
// Calculate pagination
let total = workflows.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(workflows.len());
// Get paginated slice
let paginated_workflows: Vec<WorkflowSummary> = workflows[start..end]
.iter()
.map(|w| WorkflowSummary::from(w.clone()))
.collect();
let response = PaginatedResponse::new(paginated_workflows, &pagination, total);
let response = PaginatedResponse::new(paginated_workflows, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}
@@ -138,25 +105,27 @@ pub async fn list_workflows_by_pack(
Query(pagination): Query<PaginationParams>,
) -> ApiResult<impl IntoResponse> {
// Verify pack exists
let pack = PackRepository::find_by_ref(&state.db, &pack_ref)
let _pack = PackRepository::find_by_ref(&state.db, &pack_ref)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", pack_ref)))?;
// Get workflows for this pack
let workflows = WorkflowDefinitionRepository::find_by_pack(&state.db, pack.id).await?;
// All filtering and pagination happen in a single SQL query.
let filters = WorkflowSearchFilters {
pack: None,
pack_ref: Some(pack_ref),
enabled: None,
tags: None,
search: None,
limit: pagination.limit(),
offset: pagination.offset(),
};
// Calculate pagination
let total = workflows.len() as u64;
let start = ((pagination.page - 1) * pagination.limit()) as usize;
let end = (start + pagination.limit() as usize).min(workflows.len());
let result = WorkflowDefinitionRepository::list_search(&state.db, &filters).await?;
// Get paginated slice
let paginated_workflows: Vec<WorkflowSummary> = workflows[start..end]
.iter()
.map(|w| WorkflowSummary::from(w.clone()))
.collect();
let paginated_workflows: Vec<WorkflowSummary> =
result.rows.into_iter().map(WorkflowSummary::from).collect();
let response = PaginatedResponse::new(paginated_workflows, &pagination, total);
let response = PaginatedResponse::new(paginated_workflows, &pagination, result.total);
Ok((StatusCode::OK, Json(response)))
}