Files
attune/crates/common/src/repositories/event.rs
David Culbreth f93e9229d2
Some checks failed
CI / Rustfmt (pull_request) Successful in 19s
CI / Cargo Audit & Deny (pull_request) Successful in 33s
CI / Security Blocking Checks (pull_request) Successful in 5s
CI / Web Blocking Checks (pull_request) Successful in 49s
CI / Web Advisory Checks (pull_request) Successful in 33s
CI / Clippy (pull_request) Has been cancelled
CI / Security Advisory Checks (pull_request) Has been cancelled
CI / Tests (pull_request) Has been cancelled
ha executor
2026-04-02 17:15:59 -05:00

803 lines
24 KiB
Rust

//! Event and Enforcement repository for database operations
//!
//! This module provides CRUD operations and queries for Event and Enforcement entities.
//! Note: Events are immutable time-series data — there is no Update impl for EventRepository.
use chrono::{DateTime, Utc};
use crate::models::{
enums::{EnforcementCondition, EnforcementStatus},
event::*,
Id, JsonDict,
};
use crate::Result;
use sqlx::{Executor, Postgres, QueryBuilder};
use super::{Create, Delete, FindById, List, Repository, Update};
// ============================================================================
// Event Search
// ============================================================================
/// Filters for [`EventRepository::search`].
///
/// All fields are optional. When set, the corresponding WHERE clause is added.
/// Pagination is always applied.
#[derive(Debug, Clone, Default)]
pub struct EventSearchFilters {
pub trigger: Option<Id>,
pub trigger_ref: Option<String>,
pub source: Option<Id>,
pub rule_ref: Option<String>,
pub limit: u32,
pub offset: u32,
}
/// Result of [`EventRepository::search`].
#[derive(Debug)]
pub struct EventSearchResult {
pub rows: Vec<Event>,
pub total: u64,
}
// ============================================================================
// Enforcement Search
// ============================================================================
/// Filters for [`EnforcementRepository::search`].
///
/// All fields are optional and combinable. Pagination is always applied.
#[derive(Debug, Clone, Default)]
pub struct EnforcementSearchFilters {
pub rule: Option<Id>,
pub event: Option<Id>,
pub status: Option<EnforcementStatus>,
pub trigger_ref: Option<String>,
pub rule_ref: Option<String>,
pub limit: u32,
pub offset: u32,
}
/// Result of [`EnforcementRepository::search`].
#[derive(Debug)]
pub struct EnforcementSearchResult {
pub rows: Vec<Enforcement>,
pub total: u64,
}
#[derive(Debug, Clone)]
pub struct EnforcementCreateOrGetResult {
pub enforcement: Enforcement,
pub created: bool,
}
/// Repository for Event operations
pub struct EventRepository;
impl Repository for EventRepository {
type Entity = Event;
fn table_name() -> &'static str {
"event"
}
}
/// Input for creating a new event
#[derive(Debug, Clone)]
pub struct CreateEventInput {
pub trigger: Option<Id>,
pub trigger_ref: String,
pub config: Option<JsonDict>,
pub payload: Option<JsonDict>,
pub source: Option<Id>,
pub source_ref: Option<String>,
pub rule: Option<Id>,
pub rule_ref: Option<String>,
}
#[async_trait::async_trait]
impl FindById for EventRepository {
async fn find_by_id<'e, E>(executor: E, id: i64) -> Result<Option<Self::Entity>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let event = sqlx::query_as::<_, Event>(
r#"
SELECT id, trigger, trigger_ref, config, payload, source, source_ref,
rule, rule_ref, created
FROM event
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(executor)
.await?;
Ok(event)
}
}
#[async_trait::async_trait]
impl List for EventRepository {
async fn list<'e, E>(executor: E) -> Result<Vec<Self::Entity>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let events = sqlx::query_as::<_, Event>(
r#"
SELECT id, trigger, trigger_ref, config, payload, source, source_ref,
rule, rule_ref, created
FROM event
ORDER BY created DESC
LIMIT 1000
"#,
)
.fetch_all(executor)
.await?;
Ok(events)
}
}
#[async_trait::async_trait]
impl Create for EventRepository {
type CreateInput = CreateEventInput;
async fn create<'e, E>(executor: E, input: Self::CreateInput) -> Result<Self::Entity>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let event = sqlx::query_as::<_, Event>(
r#"
INSERT INTO event (trigger, trigger_ref, config, payload, source, source_ref, rule, rule_ref)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, trigger, trigger_ref, config, payload, source, source_ref,
rule, rule_ref, created
"#,
)
.bind(input.trigger)
.bind(&input.trigger_ref)
.bind(&input.config)
.bind(&input.payload)
.bind(input.source)
.bind(&input.source_ref)
.bind(input.rule)
.bind(&input.rule_ref)
.fetch_one(executor)
.await?;
Ok(event)
}
}
#[async_trait::async_trait]
impl Delete for EventRepository {
async fn delete<'e, E>(executor: E, id: i64) -> Result<bool>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let result = sqlx::query("DELETE FROM event WHERE id = $1")
.bind(id)
.execute(executor)
.await?;
Ok(result.rows_affected() > 0)
}
}
impl EventRepository {
/// Find events by trigger ID
pub async fn find_by_trigger<'e, E>(executor: E, trigger_id: Id) -> Result<Vec<Event>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let events = sqlx::query_as::<_, Event>(
r#"
SELECT id, trigger, trigger_ref, config, payload, source, source_ref,
rule, rule_ref, created
FROM event
WHERE trigger = $1
ORDER BY created DESC
LIMIT 1000
"#,
)
.bind(trigger_id)
.fetch_all(executor)
.await?;
Ok(events)
}
/// Find events by trigger ref
pub async fn find_by_trigger_ref<'e, E>(executor: E, trigger_ref: &str) -> Result<Vec<Event>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let events = sqlx::query_as::<_, Event>(
r#"
SELECT id, trigger, trigger_ref, config, payload, source, source_ref,
rule, rule_ref, created
FROM event
WHERE trigger_ref = $1
ORDER BY created DESC
LIMIT 1000
"#,
)
.bind(trigger_ref)
.fetch_all(executor)
.await?;
Ok(events)
}
/// Search events with all filters pushed into SQL.
///
/// Builds a dynamic query so that every filter, pagination, and the total
/// count are handled in the database — no in-memory filtering or slicing.
pub async fn search<'e, E>(db: E, filters: &EventSearchFilters) -> Result<EventSearchResult>
where
E: Executor<'e, Database = Postgres> + Copy + 'e,
{
let select_cols = "id, trigger, trigger_ref, config, payload, source, source_ref, rule, rule_ref, created";
let mut qb: QueryBuilder<'_, Postgres> =
QueryBuilder::new(format!("SELECT {select_cols} FROM event"));
let mut count_qb: QueryBuilder<'_, Postgres> =
QueryBuilder::new("SELECT COUNT(*) FROM event");
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(trigger_id) = filters.trigger {
push_condition!("trigger = ", trigger_id);
}
if let Some(ref trigger_ref) = filters.trigger_ref {
push_condition!("trigger_ref = ", trigger_ref.clone());
}
if let Some(source_id) = filters.source {
push_condition!("source = ", source_id);
}
if let Some(ref rule_ref) = filters.rule_ref {
push_condition!(
"LOWER(rule_ref) LIKE ",
format!("%{}%", rule_ref.to_lowercase())
);
}
// 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 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<Event> = qb.build_query_as().fetch_all(db).await?;
Ok(EventSearchResult { rows, total })
}
}
// ============================================================================
// Enforcement Repository
// ============================================================================
/// Repository for Enforcement operations
pub struct EnforcementRepository;
impl Repository for EnforcementRepository {
type Entity = Enforcement;
fn table_name() -> &'static str {
"enforcement"
}
}
/// Input for creating a new enforcement
#[derive(Debug, Clone)]
pub struct CreateEnforcementInput {
pub rule: Option<Id>,
pub rule_ref: String,
pub trigger_ref: String,
pub config: Option<JsonDict>,
pub event: Option<Id>,
pub status: EnforcementStatus,
pub payload: JsonDict,
pub condition: EnforcementCondition,
pub conditions: serde_json::Value,
}
/// Input for updating an enforcement
#[derive(Debug, Clone, Default)]
pub struct UpdateEnforcementInput {
pub status: Option<EnforcementStatus>,
pub payload: Option<JsonDict>,
pub resolved_at: Option<DateTime<Utc>>,
}
#[async_trait::async_trait]
impl FindById for EnforcementRepository {
async fn find_by_id<'e, E>(executor: E, id: i64) -> Result<Option<Self::Entity>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let enforcement = sqlx::query_as::<_, Enforcement>(
r#"
SELECT id, rule, rule_ref, trigger_ref, config, event, status, payload,
condition, conditions, created, resolved_at
FROM enforcement
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(executor)
.await?;
Ok(enforcement)
}
}
#[async_trait::async_trait]
impl List for EnforcementRepository {
async fn list<'e, E>(executor: E) -> Result<Vec<Self::Entity>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let enforcements = sqlx::query_as::<_, Enforcement>(
r#"
SELECT id, rule, rule_ref, trigger_ref, config, event, status, payload,
condition, conditions, created, resolved_at
FROM enforcement
ORDER BY created DESC
LIMIT 1000
"#,
)
.fetch_all(executor)
.await?;
Ok(enforcements)
}
}
#[async_trait::async_trait]
impl Create for EnforcementRepository {
type CreateInput = CreateEnforcementInput;
async fn create<'e, E>(executor: E, input: Self::CreateInput) -> Result<Self::Entity>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let enforcement = sqlx::query_as::<_, Enforcement>(
r#"
INSERT INTO enforcement (rule, rule_ref, trigger_ref, config, event, status,
payload, condition, conditions)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, rule, rule_ref, trigger_ref, config, event, status, payload,
condition, conditions, created, resolved_at
"#,
)
.bind(input.rule)
.bind(&input.rule_ref)
.bind(&input.trigger_ref)
.bind(&input.config)
.bind(input.event)
.bind(input.status)
.bind(&input.payload)
.bind(input.condition)
.bind(&input.conditions)
.fetch_one(executor)
.await?;
Ok(enforcement)
}
}
#[async_trait::async_trait]
impl Update for EnforcementRepository {
type UpdateInput = UpdateEnforcementInput;
async fn update<'e, E>(executor: E, id: i64, input: Self::UpdateInput) -> Result<Self::Entity>
where
E: Executor<'e, Database = Postgres> + 'e,
{
if input.status.is_none() && input.payload.is_none() && input.resolved_at.is_none() {
return Self::get_by_id(executor, id).await;
}
Self::update_with_locator(executor, input, |query| {
query.push(" WHERE id = ");
query.push_bind(id);
})
.await
}
}
#[async_trait::async_trait]
impl Delete for EnforcementRepository {
async fn delete<'e, E>(executor: E, id: i64) -> Result<bool>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let result = sqlx::query("DELETE FROM enforcement WHERE id = $1")
.bind(id)
.execute(executor)
.await?;
Ok(result.rows_affected() > 0)
}
}
impl EnforcementRepository {
async fn update_with_locator<'e, E, F>(
executor: E,
input: UpdateEnforcementInput,
where_clause: F,
) -> Result<Enforcement>
where
E: Executor<'e, Database = Postgres> + 'e,
F: FnOnce(&mut QueryBuilder<'_, Postgres>),
{
let mut query = QueryBuilder::new("UPDATE enforcement SET ");
let mut has_updates = false;
if let Some(status) = input.status {
query.push("status = ");
query.push_bind(status);
has_updates = true;
}
if let Some(payload) = &input.payload {
if has_updates {
query.push(", ");
}
query.push("payload = ");
query.push_bind(payload);
has_updates = true;
}
if let Some(resolved_at) = input.resolved_at {
if has_updates {
query.push(", ");
}
query.push("resolved_at = ");
query.push_bind(resolved_at);
}
where_clause(&mut query);
query.push(
" RETURNING id, rule, rule_ref, trigger_ref, config, event, status, payload, \
condition, conditions, created, resolved_at",
);
let enforcement = query
.build_query_as::<Enforcement>()
.fetch_one(executor)
.await?;
Ok(enforcement)
}
/// Update an enforcement using the loaded row's primary key.
pub async fn update_loaded<'e, E>(
executor: E,
enforcement: &Enforcement,
input: UpdateEnforcementInput,
) -> Result<Enforcement>
where
E: Executor<'e, Database = Postgres> + 'e,
{
if input.status.is_none() && input.payload.is_none() && input.resolved_at.is_none() {
return Ok(enforcement.clone());
}
Self::update_with_locator(executor, input, |query| {
query.push(" WHERE id = ");
query.push_bind(enforcement.id);
})
.await
}
pub async fn update_loaded_if_status<'e, E>(
executor: E,
enforcement: &Enforcement,
expected_status: EnforcementStatus,
input: UpdateEnforcementInput,
) -> Result<Option<Enforcement>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
if input.status.is_none() && input.payload.is_none() && input.resolved_at.is_none() {
return Ok(Some(enforcement.clone()));
}
let mut query = QueryBuilder::new("UPDATE enforcement SET ");
let mut has_updates = false;
if let Some(status) = input.status {
query.push("status = ");
query.push_bind(status);
has_updates = true;
}
if let Some(payload) = &input.payload {
if has_updates {
query.push(", ");
}
query.push("payload = ");
query.push_bind(payload);
has_updates = true;
}
if let Some(resolved_at) = input.resolved_at {
if has_updates {
query.push(", ");
}
query.push("resolved_at = ");
query.push_bind(resolved_at);
has_updates = true;
}
if !has_updates {
return Ok(Some(enforcement.clone()));
}
query.push(" WHERE id = ");
query.push_bind(enforcement.id);
query.push(" AND status = ");
query.push_bind(expected_status);
query.push(
" RETURNING id, rule, rule_ref, trigger_ref, config, event, status, payload, \
condition, conditions, created, resolved_at",
);
query
.build_query_as::<Enforcement>()
.fetch_optional(executor)
.await
.map_err(Into::into)
}
/// Find enforcements by rule ID
pub async fn find_by_rule<'e, E>(executor: E, rule_id: Id) -> Result<Vec<Enforcement>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let enforcements = sqlx::query_as::<_, Enforcement>(
r#"
SELECT id, rule, rule_ref, trigger_ref, config, event, status, payload,
condition, conditions, created, resolved_at
FROM enforcement
WHERE rule = $1
ORDER BY created DESC
"#,
)
.bind(rule_id)
.fetch_all(executor)
.await?;
Ok(enforcements)
}
/// Find enforcements by status
pub async fn find_by_status<'e, E>(
executor: E,
status: EnforcementStatus,
) -> Result<Vec<Enforcement>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let enforcements = sqlx::query_as::<_, Enforcement>(
r#"
SELECT id, rule, rule_ref, trigger_ref, config, event, status, payload,
condition, conditions, created, resolved_at
FROM enforcement
WHERE status = $1
ORDER BY created DESC
"#,
)
.bind(status)
.fetch_all(executor)
.await?;
Ok(enforcements)
}
/// Find enforcements by event ID
pub async fn find_by_event<'e, E>(executor: E, event_id: Id) -> Result<Vec<Enforcement>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let enforcements = sqlx::query_as::<_, Enforcement>(
r#"
SELECT id, rule, rule_ref, trigger_ref, config, event, status, payload,
condition, conditions, created, resolved_at
FROM enforcement
WHERE event = $1
ORDER BY created DESC
"#,
)
.bind(event_id)
.fetch_all(executor)
.await?;
Ok(enforcements)
}
pub async fn find_by_rule_and_event<'e, E>(
executor: E,
rule_id: Id,
event_id: Id,
) -> Result<Option<Enforcement>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, Enforcement>(
r#"
SELECT id, rule, rule_ref, trigger_ref, config, event, status, payload,
condition, conditions, created, resolved_at
FROM enforcement
WHERE rule = $1 AND event = $2
LIMIT 1
"#,
)
.bind(rule_id)
.bind(event_id)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
pub async fn create_or_get_by_rule_event<'e, E>(
executor: E,
input: CreateEnforcementInput,
) -> Result<EnforcementCreateOrGetResult>
where
E: Executor<'e, Database = Postgres> + Copy + 'e,
{
let (Some(rule_id), Some(event_id)) = (input.rule, input.event) else {
let enforcement = Self::create(executor, input).await?;
return Ok(EnforcementCreateOrGetResult {
enforcement,
created: true,
});
};
let inserted = sqlx::query_as::<_, Enforcement>(
r#"
INSERT INTO enforcement (rule, rule_ref, trigger_ref, config, event, status,
payload, condition, conditions)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (rule, event) WHERE rule IS NOT NULL AND event IS NOT NULL DO NOTHING
RETURNING id, rule, rule_ref, trigger_ref, config, event, status, payload,
condition, conditions, created, resolved_at
"#,
)
.bind(input.rule)
.bind(&input.rule_ref)
.bind(&input.trigger_ref)
.bind(&input.config)
.bind(input.event)
.bind(input.status)
.bind(&input.payload)
.bind(input.condition)
.bind(&input.conditions)
.fetch_optional(executor)
.await?;
if let Some(enforcement) = inserted {
return Ok(EnforcementCreateOrGetResult {
enforcement,
created: true,
});
}
let enforcement = Self::find_by_rule_and_event(executor, rule_id, event_id)
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"enforcement for rule {} and event {} disappeared after dedupe conflict",
rule_id,
event_id
)
})?;
Ok(EnforcementCreateOrGetResult {
enforcement,
created: false,
})
}
/// Search enforcements with all filters pushed into SQL.
///
/// All filter fields are combinable (AND). Pagination is server-side.
pub async fn search<'e, E>(
db: E,
filters: &EnforcementSearchFilters,
) -> Result<EnforcementSearchResult>
where
E: Executor<'e, Database = Postgres> + Copy + 'e,
{
let select_cols = "id, rule, rule_ref, trigger_ref, config, event, status, payload, condition, conditions, created, resolved_at";
let mut qb: QueryBuilder<'_, Postgres> =
QueryBuilder::new(format!("SELECT {select_cols} FROM enforcement"));
let mut count_qb: QueryBuilder<'_, Postgres> =
QueryBuilder::new("SELECT COUNT(*) FROM enforcement");
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(status) = &filters.status {
push_condition!("status = ", *status);
}
if let Some(rule_id) = filters.rule {
push_condition!("rule = ", rule_id);
}
if let Some(event_id) = filters.event {
push_condition!("event = ", event_id);
}
if let Some(ref trigger_ref) = filters.trigger_ref {
push_condition!("trigger_ref = ", trigger_ref.clone());
}
if let Some(ref rule_ref) = filters.rule_ref {
push_condition!("rule_ref = ", rule_ref.clone());
}
// 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 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<Enforcement> = qb.build_query_as().fetch_all(db).await?;
Ok(EnforcementSearchResult { rows, total })
}
}