Files
attune/crates/common/src/repositories/pack.rs
David Culbreth f96861d417
Some checks failed
CI / Clippy (push) Failing after 3m6s
CI / Rustfmt (push) Failing after 3m9s
CI / Cargo Audit & Deny (push) Successful in 5m2s
CI / Tests (push) Successful in 8m15s
CI / Security Blocking Checks (push) Successful in 10s
CI / Web Advisory Checks (push) Successful in 1m4s
CI / Web Blocking Checks (push) Failing after 4m52s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 2s
CI / Security Advisory Checks (push) Successful in 1m31s
Publish Images And Chart / Publish init-user (push) Failing after 30s
Publish Images And Chart / Publish init-packs (push) Failing after 1m41s
Publish Images And Chart / Publish migrations (push) Failing after 10s
Publish Images And Chart / Publish web (push) Failing after 11s
Publish Images And Chart / Publish sensor (push) Failing after 32s
Publish Images And Chart / Publish worker (push) Failing after 11s
Publish Images And Chart / Publish executor (push) Failing after 11s
Publish Images And Chart / Publish notifier (push) Failing after 9s
Publish Images And Chart / Publish api (push) Failing after 31s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
properly handling patch updates
2026-03-17 12:17:58 -05:00

551 lines
16 KiB
Rust

//! Pack repository for database operations on packs
//!
//! This module provides CRUD operations and queries for Pack entities.
use crate::models::{pack::Pack, JsonDict, JsonSchema};
use crate::{Error, Result};
use sqlx::{Executor, Postgres, QueryBuilder};
use super::{Create, Delete, FindById, FindByRef, List, Pagination, Patch, Repository, Update};
/// Repository for Pack operations
pub struct PackRepository;
impl Repository for PackRepository {
type Entity = Pack;
fn table_name() -> &'static str {
"pack"
}
}
/// Input for creating a new pack
#[derive(Debug, Clone)]
pub struct CreatePackInput {
pub r#ref: String,
pub label: String,
pub description: Option<String>,
pub version: String,
pub conf_schema: JsonSchema,
pub config: JsonDict,
pub meta: JsonDict,
pub tags: Vec<String>,
pub runtime_deps: Vec<String>,
pub dependencies: Vec<String>,
pub is_standard: bool,
pub installers: JsonDict,
}
/// Input for updating a pack
#[derive(Debug, Clone, Default)]
pub struct UpdatePackInput {
pub label: Option<String>,
pub description: Option<Patch<String>>,
pub version: Option<String>,
pub conf_schema: Option<JsonSchema>,
pub config: Option<JsonDict>,
pub meta: Option<JsonDict>,
pub tags: Option<Vec<String>>,
pub runtime_deps: Option<Vec<String>>,
pub dependencies: Option<Vec<String>>,
pub is_standard: Option<bool>,
pub installers: Option<JsonDict>,
}
const PACK_COLUMNS: &str = "id, ref, label, description, version, conf_schema, config, meta, tags, runtime_deps, dependencies, is_standard, installers, source_type, source_url, source_ref, checksum, checksum_verified, installed_at, installed_by, installation_method, storage_path, created, updated";
#[async_trait::async_trait]
impl FindById for PackRepository {
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 pack WHERE id = $1", PACK_COLUMNS);
let pack = sqlx::query_as::<_, Pack>(&query)
.bind(id)
.fetch_optional(executor)
.await?;
Ok(pack)
}
}
#[async_trait::async_trait]
impl FindByRef for PackRepository {
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 pack WHERE ref = $1", PACK_COLUMNS);
let pack = sqlx::query_as::<_, Pack>(&query)
.bind(ref_str)
.fetch_optional(executor)
.await?;
Ok(pack)
}
}
#[async_trait::async_trait]
impl List for PackRepository {
async fn list<'e, E>(executor: E) -> Result<Vec<Self::Entity>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!("SELECT {} FROM pack ORDER BY ref ASC", PACK_COLUMNS);
let packs = sqlx::query_as::<_, Pack>(&query)
.fetch_all(executor)
.await?;
Ok(packs)
}
}
#[async_trait::async_trait]
impl Create for PackRepository {
type CreateInput = CreatePackInput;
async fn create<'e, E>(executor: E, input: Self::CreateInput) -> Result<Self::Entity>
where
E: Executor<'e, Database = Postgres> + 'e,
{
// Validate ref format (alphanumeric, dots, underscores, hyphens)
if !input
.r#ref
.chars()
.all(|c| c.is_alphanumeric() || c == '.' || c == '_' || c == '-')
{
return Err(Error::validation(
"Pack ref must contain only alphanumeric characters, dots, underscores, and hyphens",
));
}
let query = format!(
r#"
INSERT INTO pack (ref, label, description, version, conf_schema, config, meta,
tags, runtime_deps, dependencies, is_standard, installers)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING {}
"#,
PACK_COLUMNS
);
// Try to insert - database will enforce uniqueness constraint
let pack = sqlx::query_as::<_, Pack>(&query)
.bind(&input.r#ref)
.bind(&input.label)
.bind(&input.description)
.bind(&input.version)
.bind(&input.conf_schema)
.bind(&input.config)
.bind(&input.meta)
.bind(&input.tags)
.bind(&input.runtime_deps)
.bind(&input.dependencies)
.bind(input.is_standard)
.bind(&input.installers)
.fetch_one(executor)
.await
.map_err(|e| {
// Convert unique constraint violation to AlreadyExists error
if let sqlx::Error::Database(db_err) = &e {
if db_err.is_unique_violation() {
return Error::already_exists("Pack", "ref", &input.r#ref);
}
}
e.into()
})?;
Ok(pack)
}
}
#[async_trait::async_trait]
impl Update for PackRepository {
type UpdateInput = UpdatePackInput;
async fn update<'e, E>(executor: E, id: i64, input: Self::UpdateInput) -> Result<Self::Entity>
where
E: Executor<'e, Database = Postgres> + 'e,
{
// Build dynamic UPDATE query
let mut query = QueryBuilder::new("UPDATE pack SET ");
let mut has_updates = false;
if let Some(label) = &input.label {
if has_updates {
query.push(", ");
}
query.push("label = ");
query.push_bind(label);
has_updates = true;
}
if let Some(description) = &input.description {
if has_updates {
query.push(", ");
}
query.push("description = ");
match description {
Patch::Set(value) => query.push_bind(value),
Patch::Clear => query.push_bind(Option::<String>::None),
};
has_updates = true;
}
if let Some(version) = &input.version {
if has_updates {
query.push(", ");
}
query.push("version = ");
query.push_bind(version);
has_updates = true;
}
if let Some(conf_schema) = &input.conf_schema {
if has_updates {
query.push(", ");
}
query.push("conf_schema = ");
query.push_bind(conf_schema);
has_updates = true;
}
if let Some(config) = &input.config {
if has_updates {
query.push(", ");
}
query.push("config = ");
query.push_bind(config);
has_updates = true;
}
if let Some(meta) = &input.meta {
if has_updates {
query.push(", ");
}
query.push("meta = ");
query.push_bind(meta);
has_updates = true;
}
if let Some(tags) = &input.tags {
if has_updates {
query.push(", ");
}
query.push("tags = ");
query.push_bind(tags);
has_updates = true;
}
if let Some(runtime_deps) = &input.runtime_deps {
if has_updates {
query.push(", ");
}
query.push("runtime_deps = ");
query.push_bind(runtime_deps);
has_updates = true;
}
if let Some(dependencies) = &input.dependencies {
if has_updates {
query.push(", ");
}
query.push("dependencies = ");
query.push_bind(dependencies);
has_updates = true;
}
if let Some(is_standard) = input.is_standard {
if has_updates {
query.push(", ");
}
query.push("is_standard = ");
query.push_bind(is_standard);
has_updates = true;
}
if let Some(installers) = &input.installers {
if has_updates {
query.push(", ");
}
query.push("installers = ");
query.push_bind(installers);
has_updates = true;
}
if !has_updates {
// No updates requested, fetch and return existing pack
return Self::find_by_id(executor, id)
.await?
.ok_or_else(|| Error::not_found("pack", "id", id.to_string()));
}
// Add updated timestamp
query.push(", updated = NOW() WHERE id = ");
query.push_bind(id);
query.push(" RETURNING ");
query.push(PACK_COLUMNS);
let pack = query
.build_query_as::<Pack>()
.fetch_one(executor)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => Error::not_found("pack", "id", id.to_string()),
_ => e.into(),
})?;
Ok(pack)
}
}
#[async_trait::async_trait]
impl Delete for PackRepository {
async fn delete<'e, E>(executor: E, id: i64) -> Result<bool>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let result = sqlx::query("DELETE FROM pack WHERE id = $1")
.bind(id)
.execute(executor)
.await?;
Ok(result.rows_affected() > 0)
}
}
impl PackRepository {
/// List packs with pagination
pub async fn list_paginated<'e, E>(executor: E, pagination: Pagination) -> Result<Vec<Pack>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM pack ORDER BY ref ASC LIMIT $1 OFFSET $2",
PACK_COLUMNS
);
let packs = sqlx::query_as::<_, Pack>(&query)
.bind(pagination.limit())
.bind(pagination.offset())
.fetch_all(executor)
.await?;
Ok(packs)
}
/// Count total number of packs
pub async fn count<'e, E>(executor: E) -> Result<i64>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM pack")
.fetch_one(executor)
.await?;
Ok(count.0)
}
/// Find packs by tag
pub async fn find_by_tag<'e, E>(executor: E, tag: &str) -> Result<Vec<Pack>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM pack WHERE $1 = ANY(tags) ORDER BY ref ASC",
PACK_COLUMNS
);
let packs = sqlx::query_as::<_, Pack>(&query)
.bind(tag)
.fetch_all(executor)
.await?;
Ok(packs)
}
/// Find standard packs
pub async fn find_standard<'e, E>(executor: E) -> Result<Vec<Pack>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM pack WHERE is_standard = true ORDER BY ref ASC",
PACK_COLUMNS
);
let packs = sqlx::query_as::<_, Pack>(&query)
.fetch_all(executor)
.await?;
Ok(packs)
}
/// Search packs by name/label (case-insensitive)
pub async fn search<'e, E>(executor: E, query: &str) -> Result<Vec<Pack>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let search_pattern = format!("%{}%", query.to_lowercase());
let sql = format!(
"SELECT {} FROM pack WHERE LOWER(ref) LIKE $1 OR LOWER(label) LIKE $1 OR LOWER(description) LIKE $1 ORDER BY ref ASC",
PACK_COLUMNS
);
let packs = sqlx::query_as::<_, Pack>(&sql)
.bind(&search_pattern)
.fetch_all(executor)
.await?;
Ok(packs)
}
/// Check if a pack with the given ref exists
pub async fn exists_by_ref<'e, E>(executor: E, ref_str: &str) -> Result<bool>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let exists: (bool,) = sqlx::query_as("SELECT EXISTS(SELECT 1 FROM pack WHERE ref = $1)")
.bind(ref_str)
.fetch_one(executor)
.await?;
Ok(exists.0)
}
/// Update installation metadata for a pack
#[allow(clippy::too_many_arguments)]
pub async fn update_installation_metadata<'e, E>(
executor: E,
id: i64,
source_type: String,
source_url: Option<String>,
source_ref: Option<String>,
checksum: Option<String>,
checksum_verified: bool,
installed_by: Option<i64>,
installation_method: String,
storage_path: String,
) -> Result<Pack>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
r#"
UPDATE pack
SET source_type = $2,
source_url = $3,
source_ref = $4,
checksum = $5,
checksum_verified = $6,
installed_at = NOW(),
installed_by = $7,
installation_method = $8,
storage_path = $9,
updated = NOW()
WHERE id = $1
RETURNING {}
"#,
PACK_COLUMNS
);
let pack = sqlx::query_as::<_, Pack>(&query)
.bind(id)
.bind(source_type)
.bind(source_url)
.bind(source_ref)
.bind(checksum)
.bind(checksum_verified)
.bind(installed_by)
.bind(installation_method)
.bind(storage_path)
.fetch_one(executor)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => Error::not_found("pack", "id", id.to_string()),
_ => e.into(),
})?;
Ok(pack)
}
/// Check if a pack has installation metadata
pub async fn is_installed<'e, E>(executor: E, pack_id: i64) -> Result<bool>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let exists: (bool,) = sqlx::query_as(
"SELECT EXISTS(SELECT 1 FROM pack WHERE id = $1 AND installed_at IS NOT NULL)",
)
.bind(pack_id)
.fetch_one(executor)
.await?;
Ok(exists.0)
}
/// List all installed packs
pub async fn list_installed<'e, E>(executor: E) -> Result<Vec<Pack>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM pack WHERE installed_at IS NOT NULL ORDER BY installed_at DESC",
PACK_COLUMNS
);
let packs = sqlx::query_as::<_, Pack>(&query)
.fetch_all(executor)
.await?;
Ok(packs)
}
/// List packs by source type
pub async fn list_by_source_type<'e, E>(executor: E, source_type: &str) -> Result<Vec<Pack>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT {} FROM pack WHERE source_type = $1 ORDER BY installed_at DESC",
PACK_COLUMNS
);
let packs = sqlx::query_as::<_, Pack>(&query)
.bind(source_type)
.fetch_all(executor)
.await?;
Ok(packs)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_pack_input() {
let input = CreatePackInput {
r#ref: "test.pack".to_string(),
label: "Test Pack".to_string(),
description: Some("A test pack".to_string()),
version: "1.0.0".to_string(),
conf_schema: serde_json::json!({}),
config: serde_json::json!({}),
meta: serde_json::json!({}),
tags: vec!["test".to_string()],
runtime_deps: vec![],
dependencies: vec![],
is_standard: false,
installers: serde_json::json!({}),
};
assert_eq!(input.r#ref, "test.pack");
assert_eq!(input.label, "Test Pack");
}
#[test]
fn test_update_pack_input_default() {
let input = UpdatePackInput::default();
assert!(input.label.is_none());
assert!(input.description.is_none());
assert!(input.version.is_none());
assert!(input.dependencies.is_none());
}
}