Files
attune/crates/common/src/repositories/artifact.rs
2026-03-02 19:27:52 -06:00

703 lines
22 KiB
Rust

//! Artifact and ArtifactVersion repositories for database operations
use crate::models::{
artifact::*,
artifact_version::ArtifactVersion,
enums::{ArtifactType, OwnerType, RetentionPolicyType},
};
use crate::Result;
use sqlx::{Executor, Postgres, QueryBuilder};
use super::{Create, Delete, FindById, FindByRef, List, Repository, Update};
// ============================================================================
// ArtifactRepository
// ============================================================================
pub struct ArtifactRepository;
impl Repository for ArtifactRepository {
type Entity = Artifact;
fn table_name() -> &'static str {
"artifact"
}
}
#[derive(Debug, Clone)]
pub struct CreateArtifactInput {
pub r#ref: String,
pub scope: OwnerType,
pub owner: String,
pub r#type: ArtifactType,
pub retention_policy: RetentionPolicyType,
pub retention_limit: i32,
pub name: Option<String>,
pub description: Option<String>,
pub content_type: Option<String>,
pub execution: Option<i64>,
pub data: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Default)]
pub struct UpdateArtifactInput {
pub r#ref: Option<String>,
pub scope: Option<OwnerType>,
pub owner: Option<String>,
pub r#type: Option<ArtifactType>,
pub retention_policy: Option<RetentionPolicyType>,
pub retention_limit: Option<i32>,
pub name: Option<String>,
pub description: Option<String>,
pub content_type: Option<String>,
pub size_bytes: Option<i64>,
pub data: Option<serde_json::Value>,
}
/// Filters for searching artifacts
#[derive(Debug, Clone, Default)]
pub struct ArtifactSearchFilters {
pub scope: Option<OwnerType>,
pub owner: Option<String>,
pub r#type: Option<ArtifactType>,
pub execution: Option<i64>,
pub name_contains: Option<String>,
pub limit: u32,
pub offset: u32,
}
/// Search result with total count
pub struct ArtifactSearchResult {
pub rows: Vec<Artifact>,
pub total: i64,
}
#[async_trait::async_trait]
impl FindById for ArtifactRepository {
async fn find_by_id<'e, E>(executor: E, id: i64) -> Result<Option<Self::Entity>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!("SELECT {} FROM artifact WHERE id = $1", SELECT_COLUMNS);
sqlx::query_as::<_, Artifact>(&query)
.bind(id)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
}
#[async_trait::async_trait]
impl FindByRef for ArtifactRepository {
async fn find_by_ref<'e, E>(executor: E, ref_str: &str) -> Result<Option<Self::Entity>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!("SELECT {} FROM artifact WHERE ref = $1", SELECT_COLUMNS);
sqlx::query_as::<_, Artifact>(&query)
.bind(ref_str)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
}
#[async_trait::async_trait]
impl List for ArtifactRepository {
async fn list<'e, E>(executor: E) -> Result<Vec<Self::Entity>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM artifact ORDER BY created DESC LIMIT 1000",
SELECT_COLUMNS
);
sqlx::query_as::<_, Artifact>(&query)
.fetch_all(executor)
.await
.map_err(Into::into)
}
}
#[async_trait::async_trait]
impl Create for ArtifactRepository {
type CreateInput = CreateArtifactInput;
async fn create<'e, E>(executor: E, input: Self::CreateInput) -> Result<Self::Entity>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"INSERT INTO artifact (ref, scope, owner, type, retention_policy, retention_limit, \
name, description, content_type, execution, data) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
RETURNING {}",
SELECT_COLUMNS
);
sqlx::query_as::<_, Artifact>(&query)
.bind(&input.r#ref)
.bind(input.scope)
.bind(&input.owner)
.bind(input.r#type)
.bind(input.retention_policy)
.bind(input.retention_limit)
.bind(&input.name)
.bind(&input.description)
.bind(&input.content_type)
.bind(input.execution)
.bind(&input.data)
.fetch_one(executor)
.await
.map_err(Into::into)
}
}
#[async_trait::async_trait]
impl Update for ArtifactRepository {
type UpdateInput = UpdateArtifactInput;
async fn update<'e, E>(executor: E, id: i64, input: Self::UpdateInput) -> Result<Self::Entity>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let mut query = QueryBuilder::new("UPDATE artifact SET ");
let mut has_updates = false;
macro_rules! push_field {
($field:expr, $col:expr) => {
if let Some(val) = $field {
if has_updates {
query.push(", ");
}
query.push(concat!($col, " = ")).push_bind(val);
has_updates = true;
}
};
}
push_field!(&input.r#ref, "ref");
push_field!(input.scope, "scope");
push_field!(&input.owner, "owner");
push_field!(input.r#type, "type");
push_field!(input.retention_policy, "retention_policy");
push_field!(input.retention_limit, "retention_limit");
push_field!(&input.name, "name");
push_field!(&input.description, "description");
push_field!(&input.content_type, "content_type");
push_field!(input.size_bytes, "size_bytes");
push_field!(&input.data, "data");
if !has_updates {
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::<Artifact>()
.fetch_one(executor)
.await
.map_err(Into::into)
}
}
#[async_trait::async_trait]
impl Delete for ArtifactRepository {
async fn delete<'e, E>(executor: E, id: i64) -> Result<bool>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let result = sqlx::query("DELETE FROM artifact WHERE id = $1")
.bind(id)
.execute(executor)
.await?;
Ok(result.rows_affected() > 0)
}
}
impl ArtifactRepository {
/// Search artifacts with filters and pagination
pub async fn search<'e, E>(
executor: E,
filters: &ArtifactSearchFilters,
) -> Result<ArtifactSearchResult>
where
E: Executor<'e, Database = Postgres> + Copy + 'e,
{
// Build WHERE clauses
let mut conditions: Vec<String> = Vec::new();
let mut param_idx: usize = 0;
if filters.scope.is_some() {
param_idx += 1;
conditions.push(format!("scope = ${}", param_idx));
}
if filters.owner.is_some() {
param_idx += 1;
conditions.push(format!("owner = ${}", param_idx));
}
if filters.r#type.is_some() {
param_idx += 1;
conditions.push(format!("type = ${}", param_idx));
}
if filters.execution.is_some() {
param_idx += 1;
conditions.push(format!("execution = ${}", param_idx));
}
if filters.name_contains.is_some() {
param_idx += 1;
conditions.push(format!("name ILIKE '%' || ${} || '%'", param_idx));
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))
};
// Count query
let count_sql = format!("SELECT COUNT(*) AS cnt FROM artifact {}", where_clause);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
// Bind params for count
if let Some(scope) = filters.scope {
count_query = count_query.bind(scope);
}
if let Some(ref owner) = filters.owner {
count_query = count_query.bind(owner.clone());
}
if let Some(r#type) = filters.r#type {
count_query = count_query.bind(r#type);
}
if let Some(execution) = filters.execution {
count_query = count_query.bind(execution);
}
if let Some(ref name) = filters.name_contains {
count_query = count_query.bind(name.clone());
}
let total = count_query.fetch_one(executor).await?;
// Data query
let limit = filters.limit.min(1000);
let offset = filters.offset;
let data_sql = format!(
"SELECT {} FROM artifact {} ORDER BY created DESC LIMIT {} OFFSET {}",
SELECT_COLUMNS, where_clause, limit, offset
);
let mut data_query = sqlx::query_as::<_, Artifact>(&data_sql);
if let Some(scope) = filters.scope {
data_query = data_query.bind(scope);
}
if let Some(ref owner) = filters.owner {
data_query = data_query.bind(owner.clone());
}
if let Some(r#type) = filters.r#type {
data_query = data_query.bind(r#type);
}
if let Some(execution) = filters.execution {
data_query = data_query.bind(execution);
}
if let Some(ref name) = filters.name_contains {
data_query = data_query.bind(name.clone());
}
let rows = data_query.fetch_all(executor).await?;
Ok(ArtifactSearchResult { rows, total })
}
/// Find artifacts by scope
pub async fn find_by_scope<'e, E>(executor: E, scope: OwnerType) -> Result<Vec<Artifact>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM artifact WHERE scope = $1 ORDER BY created DESC",
SELECT_COLUMNS
);
sqlx::query_as::<_, Artifact>(&query)
.bind(scope)
.fetch_all(executor)
.await
.map_err(Into::into)
}
/// Find artifacts by owner
pub async fn find_by_owner<'e, E>(executor: E, owner: &str) -> Result<Vec<Artifact>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM artifact WHERE owner = $1 ORDER BY created DESC",
SELECT_COLUMNS
);
sqlx::query_as::<_, Artifact>(&query)
.bind(owner)
.fetch_all(executor)
.await
.map_err(Into::into)
}
/// Find artifacts by type
pub async fn find_by_type<'e, E>(
executor: E,
artifact_type: ArtifactType,
) -> Result<Vec<Artifact>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM artifact WHERE type = $1 ORDER BY created DESC",
SELECT_COLUMNS
);
sqlx::query_as::<_, Artifact>(&query)
.bind(artifact_type)
.fetch_all(executor)
.await
.map_err(Into::into)
}
/// Find artifacts by scope and owner
pub async fn find_by_scope_and_owner<'e, E>(
executor: E,
scope: OwnerType,
owner: &str,
) -> Result<Vec<Artifact>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM artifact WHERE scope = $1 AND owner = $2 ORDER BY created DESC",
SELECT_COLUMNS
);
sqlx::query_as::<_, Artifact>(&query)
.bind(scope)
.bind(owner)
.fetch_all(executor)
.await
.map_err(Into::into)
}
/// Find artifacts by execution ID
pub async fn find_by_execution<'e, E>(executor: E, execution_id: i64) -> Result<Vec<Artifact>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM artifact WHERE execution = $1 ORDER BY created DESC",
SELECT_COLUMNS
);
sqlx::query_as::<_, Artifact>(&query)
.bind(execution_id)
.fetch_all(executor)
.await
.map_err(Into::into)
}
/// Find artifacts by retention policy
pub async fn find_by_retention_policy<'e, E>(
executor: E,
retention_policy: RetentionPolicyType,
) -> Result<Vec<Artifact>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM artifact WHERE retention_policy = $1 ORDER BY created DESC",
SELECT_COLUMNS
);
sqlx::query_as::<_, Artifact>(&query)
.bind(retention_policy)
.fetch_all(executor)
.await
.map_err(Into::into)
}
/// Append data to a progress-type artifact.
///
/// If `artifact.data` is currently NULL, it is initialized as a JSON array
/// containing the new entry. Otherwise the entry is appended to the existing
/// array. This is done atomically in a single SQL statement.
pub async fn append_progress<'e, E>(
executor: E,
id: i64,
entry: &serde_json::Value,
) -> Result<Artifact>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"UPDATE artifact \
SET data = CASE \
WHEN data IS NULL THEN jsonb_build_array($2::jsonb) \
ELSE data || jsonb_build_array($2::jsonb) \
END, \
updated = NOW() \
WHERE id = $1 AND type = 'progress' \
RETURNING {}",
SELECT_COLUMNS
);
sqlx::query_as::<_, Artifact>(&query)
.bind(id)
.bind(entry)
.fetch_one(executor)
.await
.map_err(Into::into)
}
/// Replace the full data payload on a progress-type artifact (for "set" semantics).
pub async fn set_data<'e, E>(executor: E, id: i64, data: &serde_json::Value) -> Result<Artifact>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"UPDATE artifact SET data = $2, updated = NOW() \
WHERE id = $1 RETURNING {}",
SELECT_COLUMNS
);
sqlx::query_as::<_, Artifact>(&query)
.bind(id)
.bind(data)
.fetch_one(executor)
.await
.map_err(Into::into)
}
}
// ============================================================================
// ArtifactVersionRepository
// ============================================================================
use crate::models::artifact_version;
pub struct ArtifactVersionRepository;
impl Repository for ArtifactVersionRepository {
type Entity = ArtifactVersion;
fn table_name() -> &'static str {
"artifact_version"
}
}
#[derive(Debug, Clone)]
pub struct CreateArtifactVersionInput {
pub artifact: i64,
pub content_type: Option<String>,
pub content: Option<Vec<u8>>,
pub content_json: Option<serde_json::Value>,
pub meta: Option<serde_json::Value>,
pub created_by: Option<String>,
}
impl ArtifactVersionRepository {
/// Find a version by ID (without binary content for performance)
pub async fn find_by_id<'e, E>(executor: E, id: i64) -> Result<Option<ArtifactVersion>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM artifact_version WHERE id = $1",
artifact_version::SELECT_COLUMNS
);
sqlx::query_as::<_, ArtifactVersion>(&query)
.bind(id)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
/// Find a version by ID including binary content
pub async fn find_by_id_with_content<'e, E>(
executor: E,
id: i64,
) -> Result<Option<ArtifactVersion>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM artifact_version WHERE id = $1",
artifact_version::SELECT_COLUMNS_WITH_CONTENT
);
sqlx::query_as::<_, ArtifactVersion>(&query)
.bind(id)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
/// List all versions for an artifact (without binary content), newest first
pub async fn list_by_artifact<'e, E>(
executor: E,
artifact_id: i64,
) -> Result<Vec<ArtifactVersion>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM artifact_version WHERE artifact = $1 ORDER BY version DESC",
artifact_version::SELECT_COLUMNS
);
sqlx::query_as::<_, ArtifactVersion>(&query)
.bind(artifact_id)
.fetch_all(executor)
.await
.map_err(Into::into)
}
/// Get the latest version for an artifact (without binary content)
pub async fn find_latest<'e, E>(
executor: E,
artifact_id: i64,
) -> Result<Option<ArtifactVersion>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM artifact_version WHERE artifact = $1 ORDER BY version DESC LIMIT 1",
artifact_version::SELECT_COLUMNS
);
sqlx::query_as::<_, ArtifactVersion>(&query)
.bind(artifact_id)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
/// Get the latest version for an artifact (with binary content)
pub async fn find_latest_with_content<'e, E>(
executor: E,
artifact_id: i64,
) -> Result<Option<ArtifactVersion>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM artifact_version WHERE artifact = $1 ORDER BY version DESC LIMIT 1",
artifact_version::SELECT_COLUMNS_WITH_CONTENT
);
sqlx::query_as::<_, ArtifactVersion>(&query)
.bind(artifact_id)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
/// Get a specific version by artifact and version number (without binary content)
pub async fn find_by_version<'e, E>(
executor: E,
artifact_id: i64,
version: i32,
) -> Result<Option<ArtifactVersion>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM artifact_version WHERE artifact = $1 AND version = $2",
artifact_version::SELECT_COLUMNS
);
sqlx::query_as::<_, ArtifactVersion>(&query)
.bind(artifact_id)
.bind(version)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
/// Get a specific version by artifact and version number (with binary content)
pub async fn find_by_version_with_content<'e, E>(
executor: E,
artifact_id: i64,
version: i32,
) -> Result<Option<ArtifactVersion>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM artifact_version WHERE artifact = $1 AND version = $2",
artifact_version::SELECT_COLUMNS_WITH_CONTENT
);
sqlx::query_as::<_, ArtifactVersion>(&query)
.bind(artifact_id)
.bind(version)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
/// Create a new artifact version. The version number is auto-assigned
/// (MAX(version) + 1) and the retention trigger fires after insert.
pub async fn create<'e, E>(
executor: E,
input: CreateArtifactVersionInput,
) -> Result<ArtifactVersion>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let size_bytes = input.content.as_ref().map(|c| c.len() as i64).or_else(|| {
input
.content_json
.as_ref()
.map(|j| serde_json::to_string(j).unwrap_or_default().len() as i64)
});
let query = format!(
"INSERT INTO artifact_version \
(artifact, version, content_type, size_bytes, content, content_json, meta, created_by) \
VALUES ($1, next_artifact_version($1), $2, $3, $4, $5, $6, $7) \
RETURNING {}",
artifact_version::SELECT_COLUMNS_WITH_CONTENT
);
sqlx::query_as::<_, ArtifactVersion>(&query)
.bind(input.artifact)
.bind(&input.content_type)
.bind(size_bytes)
.bind(&input.content)
.bind(&input.content_json)
.bind(&input.meta)
.bind(&input.created_by)
.fetch_one(executor)
.await
.map_err(Into::into)
}
/// Delete a specific version by ID
pub async fn delete<'e, E>(executor: E, id: i64) -> Result<bool>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let result = sqlx::query("DELETE FROM artifact_version WHERE id = $1")
.bind(id)
.execute(executor)
.await?;
Ok(result.rows_affected() > 0)
}
/// Delete all versions for an artifact
pub async fn delete_all_for_artifact<'e, E>(executor: E, artifact_id: i64) -> Result<u64>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let result = sqlx::query("DELETE FROM artifact_version WHERE artifact = $1")
.bind(artifact_id)
.execute(executor)
.await?;
Ok(result.rows_affected())
}
/// Count versions for an artifact
pub async fn count_by_artifact<'e, E>(executor: E, artifact_id: i64) -> Result<i64>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM artifact_version WHERE artifact = $1")
.bind(artifact_id)
.fetch_one(executor)
.await
.map_err(Into::into)
}
}