diff --git a/crates/common/src/repo/mod.rs b/crates/common/src/repo/mod.rs index 63ed508..7fc1109 100644 --- a/crates/common/src/repo/mod.rs +++ b/crates/common/src/repo/mod.rs @@ -11,6 +11,7 @@ pub mod notification_configs; pub mod project_members; pub mod projects; pub mod remote_builders; +pub mod search; pub mod starred_jobs; pub mod users; pub mod webhook_configs; diff --git a/crates/common/src/repo/search.rs b/crates/common/src/repo/search.rs new file mode 100644 index 0000000..a381d54 --- /dev/null +++ b/crates/common/src/repo/search.rs @@ -0,0 +1,516 @@ +//! Advanced search functionality for FC + +use sqlx::{PgPool, Postgres, QueryBuilder}; +use uuid::Uuid; + +use crate::{ + error::Result, + models::{Build, Evaluation, Jobset, Project}, +}; + +/// Search entity types +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SearchEntity { + Projects, + Jobsets, + Evaluations, + Builds, +} + +/// Sort order for search results +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SortOrder { + Asc, + Desc, +} + +/// Sort field for builds +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum BuildSortField { + CreatedAt, + JobName, + Status, + Priority, +} + +/// Sort field for projects +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ProjectSortField { + Name, + CreatedAt, + LastEvaluation, +} + +/// Build status filter +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum BuildStatusFilter { + Pending, + Running, + Succeeded, + Failed, + Cancelled, +} + +/// Search filters for builds +#[derive(Debug, Clone, Default)] +pub struct BuildSearchFilters { + pub status: Option, + pub project_id: Option, + pub jobset_id: Option, + pub evaluation_id: Option, + pub created_after: Option>, + pub created_before: Option>, + pub min_priority: Option, + pub max_priority: Option, + pub has_substitutes: Option, +} + +/// Search filters for projects +#[derive(Debug, Clone, Default)] +pub struct ProjectSearchFilters { + pub created_after: Option>, + pub created_before: Option>, + pub has_jobsets: Option, +} + +/// Search filters for jobsets +#[derive(Debug, Clone, Default)] +pub struct JobsetSearchFilters { + pub project_id: Option, + pub enabled: Option, + pub flake_mode: Option, +} + +/// Search filters for evaluations +#[derive(Debug, Clone, Default)] +pub struct EvaluationSearchFilters { + pub project_id: Option, + pub jobset_id: Option, + pub has_builds: Option, + pub finished_after: Option>, + pub finished_before: Option>, +} + +/// Search parameters +#[derive(Debug, Clone)] +pub struct SearchParams { + pub query: String, + pub entities: Vec, + pub limit: i64, + pub offset: i64, + pub build_filters: Option, + pub project_filters: Option, + pub jobset_filters: Option, + pub evaluation_filters: Option, + pub build_sort: Option<(BuildSortField, SortOrder)>, + pub project_sort: Option<(ProjectSortField, SortOrder)>, +} + +impl Default for SearchParams { + fn default() -> Self { + Self { + query: String::new(), + entities: vec![SearchEntity::Projects, SearchEntity::Builds], + limit: 20, + offset: 0, + build_filters: None, + project_filters: None, + jobset_filters: None, + evaluation_filters: None, + build_sort: None, + project_sort: None, + } + } +} + +/// Search results container +#[derive(Debug, Clone)] +pub struct SearchResults { + pub projects: Vec, + pub jobsets: Vec, + pub evaluations: Vec, + pub builds: Vec, + pub total_projects: i64, + pub total_jobsets: i64, + pub total_evaluations: i64, + pub total_builds: i64, +} + +/// Execute a comprehensive search across all entities +pub async fn search( + pool: &PgPool, + params: &SearchParams, +) -> Result { + let mut results = SearchResults { + projects: vec![], + jobsets: vec![], + evaluations: vec![], + builds: vec![], + total_projects: 0, + total_jobsets: 0, + total_evaluations: 0, + total_builds: 0, + }; + + for entity in ¶ms.entities { + match entity { + SearchEntity::Projects => { + let (projects, total) = search_projects(pool, params).await?; + results.projects = projects; + results.total_projects = total; + }, + SearchEntity::Jobsets => { + let (jobsets, total) = search_jobsets(pool, params).await?; + results.jobsets = jobsets; + results.total_jobsets = total; + }, + SearchEntity::Evaluations => { + let (evaluations, total) = search_evaluations(pool, params).await?; + results.evaluations = evaluations; + results.total_evaluations = total; + }, + SearchEntity::Builds => { + let (builds, total) = search_builds(pool, params).await?; + results.builds = builds; + results.total_builds = total; + }, + } + } + + Ok(results) +} + +/// Search projects with filters +async fn search_projects( + pool: &PgPool, + params: &SearchParams, +) -> Result<(Vec, i64)> { + let pattern = if params.query.is_empty() { + "%".to_string() + } else { + format!("%{}%", params.query) + }; + + let mut query_builder: QueryBuilder = + QueryBuilder::new("SELECT * FROM projects WHERE (name ILIKE "); + query_builder.push_bind(&pattern); + query_builder.push(" OR description ILIKE "); + query_builder.push_bind(&pattern); + query_builder.push(")"); + + // Apply filters + if let Some(ref filters) = params.project_filters { + if let Some(after) = filters.created_after { + query_builder.push(" AND created_at >= "); + query_builder.push_bind(after); + } + if let Some(before) = filters.created_before { + query_builder.push(" AND created_at <= "); + query_builder.push_bind(before); + } + if let Some(has_jobsets) = filters.has_jobsets { + if has_jobsets { + query_builder.push( + " AND EXISTS (SELECT 1 FROM jobsets WHERE jobsets.project_id = \ + projects.id)", + ); + } else { + query_builder.push( + " AND NOT EXISTS (SELECT 1 FROM jobsets WHERE jobsets.project_id = \ + projects.id)", + ); + } + } + } + + // Get total count + let (total,): (i64,) = if pattern == "%" { + sqlx::query_as("SELECT COUNT(*) FROM projects") + .fetch_one(pool) + .await? + } else { + sqlx::query_as( + "SELECT COUNT(*) FROM projects WHERE name ILIKE $1 OR description ILIKE \ + $1", + ) + .bind(&pattern) + .fetch_one(pool) + .await? + }; + + // Apply sorting + query_builder.push(" ORDER BY "); + if let Some((field, order)) = ¶ms.project_sort { + let field_str = match field { + ProjectSortField::Name => "name", + ProjectSortField::CreatedAt => "created_at", + ProjectSortField::LastEvaluation => "last_evaluation_at", + }; + let order_str = match order { + SortOrder::Asc => "ASC", + SortOrder::Desc => "DESC", + }; + query_builder.push(field_str); + query_builder.push(" "); + query_builder.push(order_str); + } else { + query_builder.push("name ASC"); + } + + // Apply pagination + query_builder.push(" LIMIT "); + query_builder.push_bind(params.limit); + query_builder.push(" OFFSET "); + query_builder.push_bind(params.offset); + + let projects = query_builder + .build_query_as::() + .fetch_all(pool) + .await?; + + Ok((projects, total)) +} + +/// Search jobsets with filters +async fn search_jobsets( + pool: &PgPool, + params: &SearchParams, +) -> Result<(Vec, i64)> { + let pattern = if params.query.is_empty() { + "%".to_string() + } else { + format!("%{}%", params.query) + }; + + let mut query_builder: QueryBuilder = + QueryBuilder::new("SELECT * FROM jobsets WHERE name ILIKE "); + query_builder.push_bind(&pattern); + + // Apply filters + if let Some(ref filters) = params.jobset_filters { + if let Some(project_id) = filters.project_id { + query_builder.push(" AND project_id = "); + query_builder.push_bind(project_id); + } + if let Some(enabled) = filters.enabled { + query_builder.push(" AND enabled = "); + query_builder.push_bind(enabled); + } + if let Some(flake_mode) = filters.flake_mode { + query_builder.push(" AND flake_mode = "); + query_builder.push_bind(flake_mode); + } + } + + // Get count + let (total,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM jobsets WHERE name ILIKE $1") + .bind(&pattern) + .fetch_one(pool) + .await?; + + // Apply sorting + query_builder.push(" ORDER BY name ASC LIMIT "); + query_builder.push_bind(params.limit); + query_builder.push(" OFFSET "); + query_builder.push_bind(params.offset); + + let jobsets = query_builder + .build_query_as::() + .fetch_all(pool) + .await?; + + Ok((jobsets, total)) +} + +/// Search evaluations with filters +async fn search_evaluations( + pool: &PgPool, + params: &SearchParams, +) -> Result<(Vec, i64)> { + let mut query_builder: QueryBuilder = + QueryBuilder::new("SELECT * FROM evaluations WHERE 1=1"); + + // Apply filters + if let Some(ref filters) = params.evaluation_filters { + if let Some(project_id) = filters.project_id { + query_builder.push(" AND project_id = "); + query_builder.push_bind(project_id); + } + if let Some(jobset_id) = filters.jobset_id { + query_builder.push(" AND jobset_id = "); + query_builder.push_bind(jobset_id); + } + if let Some(has_builds) = filters.has_builds { + if has_builds { + query_builder.push( + " AND EXISTS (SELECT 1 FROM builds WHERE builds.evaluation_id = \ + evaluations.id)", + ); + } else { + query_builder.push( + " AND NOT EXISTS (SELECT 1 FROM builds WHERE builds.evaluation_id = \ + evaluations.id)", + ); + } + } + if let Some(after) = filters.finished_after { + query_builder.push(" AND finished_at >= "); + query_builder.push_bind(after); + } + if let Some(before) = filters.finished_before { + query_builder.push(" AND finished_at <= "); + query_builder.push_bind(before); + } + } + + // Get count + let count_sql = query_builder.sql().replace("SELECT *", "SELECT COUNT(*)"); + let (total,): (i64,) = sqlx::query_as(&count_sql).fetch_one(pool).await?; + + // Apply sorting and pagination + query_builder.push(" ORDER BY created_at DESC LIMIT "); + query_builder.push_bind(params.limit); + query_builder.push(" OFFSET "); + query_builder.push_bind(params.offset); + + let evaluations = query_builder + .build_query_as::() + .fetch_all(pool) + .await?; + + Ok((evaluations, total)) +} + +/// Search builds with advanced filters +async fn search_builds( + pool: &PgPool, + params: &SearchParams, +) -> Result<(Vec, i64)> { + let pattern = if params.query.is_empty() { + "%".to_string() + } else { + format!("%{}%", params.query) + }; + + let mut query_builder: QueryBuilder = + QueryBuilder::new("SELECT * FROM builds WHERE (job_name ILIKE "); + query_builder.push_bind(&pattern); + query_builder.push(" OR drv_path ILIKE "); + query_builder.push_bind(&pattern); + query_builder.push(")"); + + // Apply filters + if let Some(ref filters) = params.build_filters { + if let Some(status) = filters.status { + let status_str = match status { + BuildStatusFilter::Pending => "pending", + BuildStatusFilter::Running => "running", + BuildStatusFilter::Succeeded => "succeeded", + BuildStatusFilter::Failed => "failed", + BuildStatusFilter::Cancelled => "cancelled", + }; + query_builder.push(" AND status = "); + query_builder.push_bind(status_str); + } + if let Some(project_id) = filters.project_id { + query_builder.push(" AND project_id = "); + query_builder.push_bind(project_id); + } + if let Some(jobset_id) = filters.jobset_id { + query_builder.push(" AND jobset_id = "); + query_builder.push_bind(jobset_id); + } + if let Some(evaluation_id) = filters.evaluation_id { + query_builder.push(" AND evaluation_id = "); + query_builder.push_bind(evaluation_id); + } + if let Some(after) = filters.created_after { + query_builder.push(" AND created_at >= "); + query_builder.push_bind(after); + } + if let Some(before) = filters.created_before { + query_builder.push(" AND created_at <= "); + query_builder.push_bind(before); + } + if let Some(min) = filters.min_priority { + query_builder.push(" AND priority >= "); + query_builder.push_bind(min); + } + if let Some(max) = filters.max_priority { + query_builder.push(" AND priority <= "); + query_builder.push_bind(max); + } + if let Some(has) = filters.has_substitutes { + query_builder.push(" AND has_substitutes = "); + query_builder.push_bind(has); + } + } + + // Get count - apply same filters as main query + let _count_sql = query_builder.sql().replace("SELECT *", "SELECT COUNT(*)"); + let count_query = query_builder.build_query_as::<(i64,)>(); + let (total,): (i64,) = count_query.fetch_one(pool).await?; + + // Apply sorting + query_builder.push(" ORDER BY "); + if let Some((field, order)) = ¶ms.build_sort { + let field_str = match field { + BuildSortField::CreatedAt => "created_at", + BuildSortField::JobName => "job_name", + BuildSortField::Status => "status", + BuildSortField::Priority => "priority", + }; + let order_str = match order { + SortOrder::Asc => "ASC", + SortOrder::Desc => "DESC", + }; + query_builder.push(field_str); + query_builder.push(" "); + query_builder.push(order_str); + } else { + query_builder.push("created_at DESC"); + } + + // Apply pagination + query_builder.push(" LIMIT "); + query_builder.push_bind(params.limit); + query_builder.push(" OFFSET "); + query_builder.push_bind(params.offset); + + let builds = query_builder + .build_query_as::() + .fetch_all(pool) + .await?; + + Ok((builds, total)) +} + +/// Quick search - simple text search across entities +pub async fn quick_search( + pool: &PgPool, + query: &str, + limit: i64, +) -> Result<(Vec, Vec)> { + let pattern = format!("%{}%", query); + + let projects = sqlx::query_as::<_, Project>( + "SELECT * FROM projects WHERE name ILIKE $1 OR description ILIKE $1 ORDER \ + BY name LIMIT $2", + ) + .bind(&pattern) + .bind(limit) + .fetch_all(pool) + .await?; + + let builds = sqlx::query_as::<_, Build>( + "SELECT * FROM builds WHERE job_name ILIKE $1 OR drv_path ILIKE $1 ORDER \ + BY created_at DESC LIMIT $2", + ) + .bind(&pattern) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok((projects, builds)) +} diff --git a/crates/common/tests/search_tests.rs b/crates/common/tests/search_tests.rs new file mode 100644 index 0000000..0e85cbf --- /dev/null +++ b/crates/common/tests/search_tests.rs @@ -0,0 +1,489 @@ +//! Integration tests for advanced search functionality +//! Requires TEST_DATABASE_URL to be set to a PostgreSQL connection string. + +use fc_common::{models::*, repo, repo::search::*}; +use uuid::Uuid; + +async fn get_pool() -> Option { + let url = match std::env::var("TEST_DATABASE_URL") { + Ok(url) => url, + Err(_) => { + println!("Skipping search test: TEST_DATABASE_URL not set"); + return None; + }, + }; + + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&url) + .await + .ok()?; + + // Run migrations + sqlx::migrate!("./migrations").run(&pool).await.ok()?; + + Some(pool) +} + +#[tokio::test] +async fn test_project_search() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + // Create test projects + let project1 = repo::projects::create(&pool, CreateProject { + name: format!("search-test-alpha-{}", Uuid::new_v4().simple()), + description: Some("Alpha testing project".to_string()), + repository_url: "https://github.com/test/alpha".to_string(), + }) + .await + .expect("create project 1"); + + let project2 = repo::projects::create(&pool, CreateProject { + name: format!("search-test-beta-{}", Uuid::new_v4().simple()), + description: Some("Beta testing project".to_string()), + repository_url: "https://github.com/test/beta".to_string(), + }) + .await + .expect("create project 2"); + + // Search for "alpha" + let params = SearchParams { + query: "alpha".to_string(), + entities: vec![SearchEntity::Projects], + limit: 10, + offset: 0, + build_filters: None, + project_filters: None, + jobset_filters: None, + evaluation_filters: None, + build_sort: None, + project_sort: None, + }; + + let results = search(&pool, ¶ms).await.expect("search"); + assert_eq!(results.projects.len(), 1); + assert_eq!(results.projects[0].id, project1.id); + assert_eq!(results.total_projects, 1); + + // Search for "testing" (should match both) + let params = SearchParams { + query: "testing".to_string(), + entities: vec![SearchEntity::Projects], + limit: 10, + offset: 0, + build_filters: None, + project_filters: None, + jobset_filters: None, + evaluation_filters: None, + build_sort: None, + project_sort: None, + }; + + let results = search(&pool, ¶ms).await.expect("search"); + assert_eq!(results.projects.len(), 2); + assert_eq!(results.total_projects, 2); + + // Cleanup + repo::projects::delete(&pool, project1.id).await.ok(); + repo::projects::delete(&pool, project2.id).await.ok(); +} + +#[tokio::test] +async fn test_build_search_with_filters() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + // Setup + let project = repo::projects::create(&pool, CreateProject { + name: format!("build-search-{}", Uuid::new_v4().simple()), + description: None, + repository_url: "https://github.com/test/repo".to_string(), + }) + .await + .expect("create project"); + + let jobset = repo::jobsets::create(&pool, CreateJobset { + project_id: project.id, + name: "default".to_string(), + nix_expression: "packages".to_string(), + enabled: Some(true), + flake_mode: None, + check_interval: None, + branch: None, + scheduling_shares: None, + }) + .await + .expect("create jobset"); + + // Create builds with different statuses + let build1 = repo::builds::create(&pool, &CreateBuild { + project_id: project.id, + jobset_id: jobset.id, + evaluation_id: None, + job_name: "package-hello".to_string(), + drv_path: "/nix/store/...-hello.drv".to_string(), + priority: 100, + }) + .await + .expect("create build 1"); + + // Update build status + repo::builds::update_status(&pool, build1.id, "succeeded") + .await + .expect("update build 1 status"); + + let build2 = repo::builds::create(&pool, &CreateBuild { + project_id: project.id, + jobset_id: jobset.id, + evaluation_id: None, + job_name: "package-world".to_string(), + drv_path: "/nix/store/...-world.drv".to_string(), + priority: 50, + }) + .await + .expect("create build 2"); + + repo::builds::update_status(&pool, build2.id, "failed") + .await + .expect("update build 2 status"); + + // Search by job name + let params = SearchParams { + query: "hello".to_string(), + entities: vec![SearchEntity::Builds], + limit: 10, + offset: 0, + build_filters: None, + project_filters: None, + jobset_filters: None, + evaluation_filters: None, + build_sort: None, + project_sort: None, + }; + + let results = search(&pool, ¶ms).await.expect("search"); + assert_eq!(results.builds.len(), 1); + assert_eq!(results.builds[0].id, build1.id); + + // Search with status filter (succeeded) + let params = SearchParams { + query: "".to_string(), + entities: vec![SearchEntity::Builds], + limit: 10, + offset: 0, + build_filters: Some(BuildSearchFilters { + status: Some(BuildStatusFilter::Succeeded), + project_id: None, + jobset_id: None, + evaluation_id: None, + created_after: None, + created_before: None, + min_priority: None, + max_priority: None, + has_substitutes: None, + }), + project_filters: None, + jobset_filters: None, + evaluation_filters: None, + build_sort: None, + project_sort: None, + }; + + let results = search(&pool, ¶ms).await.expect("search"); + assert_eq!(results.builds.len(), 1); + assert_eq!(results.builds[0].id, build1.id); + + // Search with priority filter + let params = SearchParams { + query: "".to_string(), + entities: vec![SearchEntity::Builds], + limit: 10, + offset: 0, + build_filters: Some(BuildSearchFilters { + status: None, + project_id: None, + jobset_id: None, + evaluation_id: None, + created_after: None, + created_before: None, + min_priority: Some(75), + max_priority: None, + has_substitutes: None, + }), + project_filters: None, + jobset_filters: None, + evaluation_filters: None, + build_sort: None, + project_sort: None, + }; + + let results = search(&pool, ¶ms).await.expect("search"); + assert_eq!(results.builds.len(), 1); + assert_eq!(results.builds[0].id, build1.id); + + // Cleanup + repo::jobsets::delete(&pool, jobset.id).await.ok(); + repo::projects::delete(&pool, project.id).await.ok(); +} + +#[tokio::test] +async fn test_multi_entity_search() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + // Create project with jobset and builds + let project = repo::projects::create(&pool, CreateProject { + name: format!("multi-search-{}", Uuid::new_v4().simple()), + description: Some("Multi-entity search test".to_string()), + repository_url: "https://github.com/test/multi".to_string(), + }) + .await + .expect("create project"); + + let jobset = repo::jobsets::create(&pool, CreateJobset { + project_id: project.id, + name: "default".to_string(), + nix_expression: "packages".to_string(), + enabled: Some(true), + flake_mode: None, + check_interval: None, + branch: None, + scheduling_shares: None, + }) + .await + .expect("create jobset"); + + let build = repo::builds::create(&pool, &CreateBuild { + project_id: project.id, + jobset_id: jobset.id, + evaluation_id: None, + job_name: "test-job".to_string(), + drv_path: "/nix/store/...-test.drv".to_string(), + priority: 100, + }) + .await + .expect("create build"); + + // Search across all entities + let params = SearchParams { + query: "test".to_string(), + entities: vec![ + SearchEntity::Projects, + SearchEntity::Jobsets, + SearchEntity::Builds, + ], + limit: 10, + offset: 0, + build_filters: None, + project_filters: None, + jobset_filters: None, + evaluation_filters: None, + build_sort: None, + project_sort: None, + }; + + let results = search(&pool, ¶ms).await.expect("search"); + assert!(!results.projects.is_empty()); + assert!(!results.jobsets.is_empty()); + assert!(!results.builds.is_empty()); + + // Cleanup + repo::builds::delete(&pool, build.id).await.ok(); + repo::jobsets::delete(&pool, jobset.id).await.ok(); + repo::projects::delete(&pool, project.id).await.ok(); +} + +#[tokio::test] +async fn test_search_pagination() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + // Create multiple projects + let mut project_ids = vec![]; + for i in 0..5 { + let project = repo::projects::create(&pool, CreateProject { + name: format!("page-test-{}-{}", i, Uuid::new_v4().simple()), + description: Some(format!("Page test project {}", i)), + repository_url: "https://github.com/test/page".to_string(), + }) + .await + .expect("create project"); + project_ids.push(project.id); + } + + // Search with limit 2 + let params = SearchParams { + query: "page-test".to_string(), + entities: vec![SearchEntity::Projects], + limit: 2, + offset: 0, + build_filters: None, + project_filters: None, + jobset_filters: None, + evaluation_filters: None, + build_sort: None, + project_sort: None, + }; + + let results = search(&pool, ¶ms).await.expect("search"); + assert_eq!(results.projects.len(), 2); + assert!(results.total_projects >= 5); + + // Search with offset 2 + let params = SearchParams { + query: "page-test".to_string(), + entities: vec![SearchEntity::Projects], + limit: 2, + offset: 2, + build_filters: None, + project_filters: None, + jobset_filters: None, + evaluation_filters: None, + build_sort: None, + project_sort: None, + }; + + let results = search(&pool, ¶ms).await.expect("search"); + assert_eq!(results.projects.len(), 2); + + // Cleanup + for id in project_ids { + repo::projects::delete(&pool, id).await.ok(); + } +} + +#[tokio::test] +async fn test_search_sorting() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + // Create projects in reverse alphabetical order + let project_z = repo::projects::create(&pool, CreateProject { + name: format!("zzz-sort-test-{}", Uuid::new_v4().simple()), + description: None, + repository_url: "https://github.com/test/z".to_string(), + }) + .await + .expect("create project z"); + + let project_a = repo::projects::create(&pool, CreateProject { + name: format!("aaa-sort-test-{}", Uuid::new_v4().simple()), + description: None, + repository_url: "https://github.com/test/a".to_string(), + }) + .await + .expect("create project a"); + + // Search sorted by name ascending + let params = SearchParams { + query: "sort-test".to_string(), + entities: vec![SearchEntity::Projects], + limit: 10, + offset: 0, + build_filters: None, + project_filters: None, + jobset_filters: None, + evaluation_filters: None, + build_sort: None, + project_sort: Some((ProjectSortField::Name, SortOrder::Asc)), + }; + + let results = search(&pool, ¶ms).await.expect("search"); + assert_eq!(results.projects.len(), 2); + assert!(results.projects[0].name.starts_with("aaa")); + assert!(results.projects[1].name.starts_with("zzz")); + + // Cleanup + repo::projects::delete(&pool, project_a.id).await.ok(); + repo::projects::delete(&pool, project_z.id).await.ok(); +} + +#[tokio::test] +async fn test_empty_search() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + // Empty query should return all entities (up to limit) + let params = SearchParams { + query: "".to_string(), + entities: vec![SearchEntity::Projects], + limit: 10, + offset: 0, + build_filters: None, + project_filters: None, + jobset_filters: None, + evaluation_filters: None, + build_sort: None, + project_sort: None, + }; + + let results = search(&pool, ¶ms).await.expect("search"); + // Should not error, just return results + assert!(results.total_projects >= 0); +} + +#[tokio::test] +async fn test_quick_search() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + // Create test data + let project = repo::projects::create(&pool, CreateProject { + name: format!("quick-search-{}", Uuid::new_v4().simple()), + description: Some("Quick search test".to_string()), + repository_url: "https://github.com/test/quick".to_string(), + }) + .await + .expect("create project"); + + let jobset = repo::jobsets::create(&pool, CreateJobset { + project_id: project.id, + name: "default".to_string(), + nix_expression: "packages".to_string(), + enabled: Some(true), + flake_mode: None, + check_interval: None, + branch: None, + scheduling_shares: None, + }) + .await + .expect("create jobset"); + + let build = repo::builds::create(&pool, &CreateBuild { + project_id: project.id, + jobset_id: jobset.id, + evaluation_id: None, + job_name: "quick-job".to_string(), + drv_path: "/nix/store/...-quick.drv".to_string(), + priority: 100, + }) + .await + .expect("create build"); + + // Quick search + let (projects, builds) = quick_search(&pool, "quick", 10) + .await + .expect("quick search"); + assert!(!projects.is_empty()); + assert!(!builds.is_empty()); + + // Cleanup + repo::builds::delete(&pool, build.id).await.ok(); + repo::jobsets::delete(&pool, jobset.id).await.ok(); + repo::projects::delete(&pool, project.id).await.ok(); +} diff --git a/crates/server/src/routes/search.rs b/crates/server/src/routes/search.rs index 22515f3..b0d2803 100644 --- a/crates/server/src/routes/search.rs +++ b/crates/server/src/routes/search.rs @@ -1,60 +1,390 @@ +//! Advanced search API routes +//! +//! Supports: +//! - Multi-entity search (projects, jobsets, evaluations, builds) +//! - Full-text search with ILIKE matching +//! - Advanced filtering by status, date range, priority +//! - Sorting by multiple fields +//! - Pagination with total counts + use axum::{ Json, Router, extract::{Query, State}, routing::get, }; -use fc_common::models::{Build, Project}; +use chrono::{DateTime, Utc}; +use fc_common::{ + models::{Build, Evaluation, Jobset, Project}, + repo::search::{ + BuildSearchFilters, + BuildSortField, + BuildStatusFilter, + EvaluationSearchFilters, + JobsetSearchFilters, + ProjectSearchFilters, + ProjectSortField, + SearchEntity, + SearchParams, + SortOrder, + quick_search, + search as advanced_search, + }, +}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; use crate::{error::ApiError, state::AppState}; +/// Request parameters for advanced search #[derive(Debug, Deserialize)] -struct SearchParams { +struct SearchRequest { + /// Search query string (searches across names, descriptions, job names, drv + /// paths) + #[serde(default)] q: String, + + /// Entities to search (projects, jobsets, evaluations, builds) + /// Default: ["projects", "builds"] + #[serde(default)] + entities: Vec, + + /// Maximum results per entity (default: 20, max: 100) + #[serde(default = "default_limit")] + limit: i64, + + /// Offset for pagination (default: 0) + #[serde(default)] + offset: i64, + + // Build filters + /// Filter builds by status: pending, running, succeeded, failed, cancelled + #[serde(rename = "build_status")] + build_status: Option, + + /// Filter builds by project ID + #[serde(rename = "build_project")] + build_project: Option, + + /// Filter builds by jobset ID + #[serde(rename = "build_jobset")] + build_jobset: Option, + + /// Filter builds by evaluation ID + #[serde(rename = "build_evaluation")] + build_evaluation: Option, + + /// Filter builds created after this date (ISO 8601) + #[serde(rename = "build_after")] + build_after: Option>, + + /// Filter builds created before this date (ISO 8601) + #[serde(rename = "build_before")] + build_before: Option>, + + /// Minimum build priority + #[serde(rename = "build_min_priority")] + build_min_priority: Option, + + /// Maximum build priority + #[serde(rename = "build_max_priority")] + build_max_priority: Option, + + // Project filters + /// Filter projects created after this date (ISO 8601) + #[serde(rename = "project_after")] + project_after: Option>, + + /// Filter projects created before this date (ISO 8601) + #[serde(rename = "project_before")] + project_before: Option>, + + // Jobset filters + /// Filter jobsets by project ID + #[serde(rename = "jobset_project")] + jobset_project: Option, + + /// Filter jobsets by enabled status + #[serde(rename = "jobset_enabled")] + jobset_enabled: Option, + + /// Filter jobsets by flake mode + #[serde(rename = "jobset_flake")] + jobset_flake: Option, + + // Evaluation filters + /// Filter evaluations by project ID + #[serde(rename = "eval_project")] + eval_project: Option, + + /// Filter evaluations by jobset ID + #[serde(rename = "eval_jobset")] + eval_jobset: Option, + + /// Filter evaluations finished after this date (ISO 8601) + #[serde(rename = "eval_after")] + eval_after: Option>, + + /// Filter evaluations finished before this date (ISO 8601) + #[serde(rename = "eval_before")] + eval_before: Option>, + + // Sorting + /// Sort builds by: created_at, job_name, status, priority (default: + /// created_at) + #[serde(rename = "build_sort")] + build_sort: Option, + + /// Sort order: asc, desc (default: desc for builds, asc for projects) + #[serde(rename = "order")] + order: Option, + + /// Sort projects by: name, created_at (default: name) + #[serde(rename = "project_sort")] + project_sort: Option, } +fn default_limit() -> i64 { + 20 +} + +/// Search results response #[derive(Debug, Serialize)] -struct SearchResults { - projects: Vec, - builds: Vec, +struct SearchResponse { + projects: Vec, + jobsets: Vec, + evaluations: Vec, + builds: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + total_projects: Option, + #[serde(skip_serializing_if = "Option::is_none")] + total_jobsets: Option, + #[serde(skip_serializing_if = "Option::is_none")] + total_evaluations: Option, + #[serde(skip_serializing_if = "Option::is_none")] + total_builds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + offset: Option, } -async fn search( +/// Legacy quick search parameters (for backward compatibility) +#[derive(Debug, Deserialize)] +struct QuickSearchParams { + q: String, + #[serde(default = "default_limit")] + limit: i64, +} + +/// Handle advanced search requests +async fn advanced_search_handler( State(state): State, - Query(params): Query, -) -> Result, ApiError> { + Query(params): Query, +) -> Result, ApiError> { + // Validate and sanitize + let query = params.q.trim(); + if query.len() > 256 { + return Err(ApiError(fc_common::CiError::Validation( + "Search query too long (max 256 characters)".to_string(), + ))); + } + + // Clamp limit to reasonable range + let limit = params.limit.clamp(1, 100); + let clamped_offset = params.offset.max(0); + + // Parse entities + let entities: Vec = if params.entities.is_empty() { + vec![SearchEntity::Projects, SearchEntity::Builds] + } else { + params + .entities + .iter() + .filter_map(|e| { + match e.as_str() { + "projects" => Some(SearchEntity::Projects), + "jobsets" => Some(SearchEntity::Jobsets), + "evaluations" => Some(SearchEntity::Evaluations), + "builds" => Some(SearchEntity::Builds), + _ => None, + } + }) + .collect() + }; + + // Parse sort order (default: desc for builds, asc for projects) + let sort_order = match params.order.as_deref() { + Some("asc") => SortOrder::Asc, + Some("desc") => SortOrder::Desc, + _ => { + if entities.contains(&SearchEntity::Builds) + && !entities.contains(&SearchEntity::Projects) + { + SortOrder::Desc + } else { + SortOrder::Asc + } + }, + }; + + // Parse build sort field + let build_sort = params.build_sort.as_deref().map(|s| { + let field = match s { + "job_name" => BuildSortField::JobName, + "status" => BuildSortField::Status, + "priority" => BuildSortField::Priority, + _ => BuildSortField::CreatedAt, + }; + (field, sort_order) + }); + + // Parse project sort field + let project_sort = params.project_sort.as_deref().map(|s| { + let field = match s { + "created_at" => ProjectSortField::CreatedAt, + _ => ProjectSortField::Name, + }; + (field, sort_order) + }); + + // Build build filters + let build_filters = if entities.contains(&SearchEntity::Builds) { + let status = params.build_status.as_deref().and_then(|s| { + match s { + "pending" => Some(BuildStatusFilter::Pending), + "running" => Some(BuildStatusFilter::Running), + "succeeded" => Some(BuildStatusFilter::Succeeded), + "failed" => Some(BuildStatusFilter::Failed), + "cancelled" => Some(BuildStatusFilter::Cancelled), + _ => None, + } + }); + + Some(BuildSearchFilters { + status, + project_id: params.build_project, + jobset_id: params.build_jobset, + evaluation_id: params.build_evaluation, + created_after: params.build_after, + created_before: params.build_before, + min_priority: params.build_min_priority, + max_priority: params.build_max_priority, + has_substitutes: None, // Not exposed in API yet + }) + } else { + None + }; + + // Build project filters + let project_filters = if entities.contains(&SearchEntity::Projects) { + Some(ProjectSearchFilters { + created_after: params.project_after, + created_before: params.project_before, + has_jobsets: None, // Not exposed in API yet + }) + } else { + None + }; + + // Build jobset filters + let jobset_filters = if entities.contains(&SearchEntity::Jobsets) { + Some(JobsetSearchFilters { + project_id: params.jobset_project, + enabled: params.jobset_enabled, + flake_mode: params.jobset_flake, + }) + } else { + None + }; + + // Build evaluation filters + let evaluation_filters = if entities.contains(&SearchEntity::Evaluations) { + Some(EvaluationSearchFilters { + project_id: params.eval_project, + jobset_id: params.eval_jobset, + has_builds: None, // Not exposed in API yet + finished_after: params.eval_after, + finished_before: params.eval_before, + }) + } else { + None + }; + + let search_params = SearchParams { + query: query.to_string(), + entities, + limit, + offset: clamped_offset, + build_filters, + project_filters, + jobset_filters, + evaluation_filters, + build_sort, + project_sort, + }; + + let results = advanced_search(&state.pool, &search_params) + .await + .map_err(ApiError)?; + + Ok(Json(SearchResponse { + projects: results.projects, + jobsets: results.jobsets, + evaluations: results.evaluations, + builds: results.builds, + total_projects: Some(results.total_projects), + total_jobsets: Some(results.total_jobsets), + total_evaluations: Some(results.total_evaluations), + total_builds: Some(results.total_builds), + limit: Some(limit), + offset: Some(params.offset), + })) +} + +/// Handle quick search (backward compatible simple search) +async fn quick_search_handler( + State(state): State, + Query(params): Query, +) -> Result, ApiError> { let query = params.q.trim(); if query.is_empty() || query.len() > 256 { - return Ok(Json(SearchResults { - projects: vec![], - builds: vec![], + return Ok(Json(SearchResponse { + projects: vec![], + jobsets: vec![], + evaluations: vec![], + builds: vec![], + total_projects: None, + total_jobsets: None, + total_evaluations: None, + total_builds: None, + limit: None, + offset: None, })); } - let pattern = format!("%{query}%"); + let limit = params.limit.clamp(1, 100); - let projects = sqlx::query_as::<_, Project>( - "SELECT * FROM projects WHERE name ILIKE $1 OR description ILIKE $1 ORDER \ - BY name LIMIT 20", - ) - .bind(&pattern) - .fetch_all(&state.pool) - .await - .map_err(|e| ApiError(fc_common::CiError::Database(e)))?; + let (projects, builds) = quick_search(&state.pool, query, limit) + .await + .map_err(ApiError)?; - let builds = sqlx::query_as::<_, Build>( - "SELECT * FROM builds WHERE job_name ILIKE $1 OR drv_path ILIKE $1 ORDER \ - BY created_at DESC LIMIT 20", - ) - .bind(&pattern) - .fetch_all(&state.pool) - .await - .map_err(|e| ApiError(fc_common::CiError::Database(e)))?; - - Ok(Json(SearchResults { projects, builds })) + Ok(Json(SearchResponse { + projects, + jobsets: vec![], + evaluations: vec![], + builds, + total_projects: None, + total_jobsets: None, + total_evaluations: None, + total_builds: None, + limit: Some(limit), + offset: Some(0), + })) } pub fn router() -> Router { - Router::new().route("/search", get(search)) + Router::new() + .route("/search", get(advanced_search_handler)) + .route("/search/quick", get(quick_search_handler)) }