use anyhow::Result; use serde_json::Value; use sqlx::PgPool; use std::collections::HashMap; use uuid::Uuid; use crate::models::{Entry, SecretField}; pub const FETCH_ALL_LIMIT: u32 = 100_000; pub struct SearchParams<'a> { pub namespace: Option<&'a str>, pub kind: Option<&'a str>, pub name: Option<&'a str>, pub tags: &'a [String], pub query: Option<&'a str>, pub sort: &'a str, pub limit: u32, pub offset: u32, /// Multi-user: filter by this user_id. None = single-user / no filter. pub user_id: Option, } #[derive(Debug, serde::Serialize)] pub struct SearchResult { pub entries: Vec, pub secret_schemas: HashMap>, } pub async fn run(pool: &PgPool, params: SearchParams<'_>) -> Result { let entries = fetch_entries_paged(pool, ¶ms).await?; let entry_ids: Vec = entries.iter().map(|e| e.id).collect(); let secret_schemas = if !entry_ids.is_empty() { fetch_secret_schemas(pool, &entry_ids).await? } else { HashMap::new() }; Ok(SearchResult { entries, secret_schemas, }) } /// Fetch entries matching the given filters — returns all matching entries up to FETCH_ALL_LIMIT. pub async fn fetch_entries( pool: &PgPool, namespace: Option<&str>, kind: Option<&str>, name: Option<&str>, tags: &[String], query: Option<&str>, user_id: Option, ) -> Result> { let params = SearchParams { namespace, kind, name, tags, query, sort: "name", limit: FETCH_ALL_LIMIT, offset: 0, user_id, }; fetch_entries_paged(pool, ¶ms).await } async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result> { let mut conditions: Vec = Vec::new(); let mut idx: i32 = 1; // user_id filtering — always comes first when present if a.user_id.is_some() { conditions.push(format!("user_id = ${}", idx)); idx += 1; } else { conditions.push("user_id IS NULL".to_string()); } if a.namespace.is_some() { conditions.push(format!("namespace = ${}", idx)); idx += 1; } if a.kind.is_some() { conditions.push(format!("kind = ${}", idx)); idx += 1; } if a.name.is_some() { conditions.push(format!("name = ${}", idx)); idx += 1; } if !a.tags.is_empty() { let placeholders: Vec = a .tags .iter() .map(|_| { let p = format!("${}", idx); idx += 1; p }) .collect(); conditions.push(format!( "tags @> ARRAY[{}]::text[]", placeholders.join(", ") )); } if a.query.is_some() { conditions.push(format!( "(name ILIKE ${i} ESCAPE '\\' OR namespace ILIKE ${i} ESCAPE '\\' \ OR kind ILIKE ${i} ESCAPE '\\' OR metadata::text ILIKE ${i} ESCAPE '\\' \ OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i} ESCAPE '\\'))", i = idx )); idx += 1; } let order = match a.sort { "updated" => "updated_at DESC", "created" => "created_at DESC", _ => "name ASC", }; let limit_idx = idx; idx += 1; let offset_idx = idx; let where_clause = if conditions.is_empty() { String::new() } else { format!("WHERE {}", conditions.join(" AND ")) }; let sql = format!( "SELECT id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'::uuid) AS user_id, \ namespace, kind, name, tags, metadata, version, created_at, updated_at \ FROM entries {where_clause} ORDER BY {order} LIMIT ${limit_idx} OFFSET ${offset_idx}" ); let mut q = sqlx::query_as::<_, EntryRaw>(&sql); if let Some(uid) = a.user_id { q = q.bind(uid); } if let Some(v) = a.namespace { q = q.bind(v); } if let Some(v) = a.kind { q = q.bind(v); } if let Some(v) = a.name { q = q.bind(v); } for tag in a.tags { q = q.bind(tag); } if let Some(v) = a.query { let pattern = format!("%{}%", v.replace('%', "\\%").replace('_', "\\_")); q = q.bind(pattern); } q = q.bind(a.limit as i64).bind(a.offset as i64); let rows = q.fetch_all(pool).await?; Ok(rows.into_iter().map(Entry::from).collect()) } /// Fetch secret field names for a set of entry ids (no decryption). pub async fn fetch_secret_schemas( pool: &PgPool, entry_ids: &[Uuid], ) -> Result>> { if entry_ids.is_empty() { return Ok(HashMap::new()); } let fields: Vec = sqlx::query_as( "SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name", ) .bind(entry_ids) .fetch_all(pool) .await?; let mut map: HashMap> = HashMap::new(); for f in fields { map.entry(f.entry_id).or_default().push(f); } Ok(map) } /// Fetch all secret fields (including encrypted bytes) for a set of entry ids. pub async fn fetch_secrets_for_entries( pool: &PgPool, entry_ids: &[Uuid], ) -> Result>> { if entry_ids.is_empty() { return Ok(HashMap::new()); } let fields: Vec = sqlx::query_as( "SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name", ) .bind(entry_ids) .fetch_all(pool) .await?; let mut map: HashMap> = HashMap::new(); for f in fields { map.entry(f.entry_id).or_default().push(f); } Ok(map) } // ── Internal raw row (because user_id is nullable in DB) ───────────────────── #[derive(sqlx::FromRow)] struct EntryRaw { id: Uuid, #[allow(dead_code)] // Selected for row shape; Entry model has no user_id field user_id: Uuid, namespace: String, kind: String, name: String, tags: Vec, metadata: Value, version: i64, created_at: chrono::DateTime, updated_at: chrono::DateTime, } impl From for Entry { fn from(r: EntryRaw) -> Self { Entry { id: r.id, namespace: r.namespace, kind: r.kind, name: r.name, tags: r.tags, metadata: r.metadata, version: r.version, created_at: r.created_at, updated_at: r.updated_at, } } }