fc-server: add (more) advanced search API
Basically, implements a multi-entity search functionality with filters for projects, jobsets, evaluations and builds. Also fixes COUNT query to apply same filters as main query, and fixes an offset mismatch in response. Some integration tests have also been added, but chances are we'll want to write VM tests for this. Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Icdda77966a7218f54fd34b78bdc9b55c6a6a6964
This commit is contained in:
parent
b3125a89d8
commit
dec4753567
4 changed files with 1369 additions and 33 deletions
|
|
@ -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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// Filter builds by project ID
|
||||
#[serde(rename = "build_project")]
|
||||
build_project: Option<Uuid>,
|
||||
|
||||
/// Filter builds by jobset ID
|
||||
#[serde(rename = "build_jobset")]
|
||||
build_jobset: Option<Uuid>,
|
||||
|
||||
/// Filter builds by evaluation ID
|
||||
#[serde(rename = "build_evaluation")]
|
||||
build_evaluation: Option<Uuid>,
|
||||
|
||||
/// Filter builds created after this date (ISO 8601)
|
||||
#[serde(rename = "build_after")]
|
||||
build_after: Option<DateTime<Utc>>,
|
||||
|
||||
/// Filter builds created before this date (ISO 8601)
|
||||
#[serde(rename = "build_before")]
|
||||
build_before: Option<DateTime<Utc>>,
|
||||
|
||||
/// Minimum build priority
|
||||
#[serde(rename = "build_min_priority")]
|
||||
build_min_priority: Option<i32>,
|
||||
|
||||
/// Maximum build priority
|
||||
#[serde(rename = "build_max_priority")]
|
||||
build_max_priority: Option<i32>,
|
||||
|
||||
// Project filters
|
||||
/// Filter projects created after this date (ISO 8601)
|
||||
#[serde(rename = "project_after")]
|
||||
project_after: Option<DateTime<Utc>>,
|
||||
|
||||
/// Filter projects created before this date (ISO 8601)
|
||||
#[serde(rename = "project_before")]
|
||||
project_before: Option<DateTime<Utc>>,
|
||||
|
||||
// Jobset filters
|
||||
/// Filter jobsets by project ID
|
||||
#[serde(rename = "jobset_project")]
|
||||
jobset_project: Option<Uuid>,
|
||||
|
||||
/// Filter jobsets by enabled status
|
||||
#[serde(rename = "jobset_enabled")]
|
||||
jobset_enabled: Option<bool>,
|
||||
|
||||
/// Filter jobsets by flake mode
|
||||
#[serde(rename = "jobset_flake")]
|
||||
jobset_flake: Option<bool>,
|
||||
|
||||
// Evaluation filters
|
||||
/// Filter evaluations by project ID
|
||||
#[serde(rename = "eval_project")]
|
||||
eval_project: Option<Uuid>,
|
||||
|
||||
/// Filter evaluations by jobset ID
|
||||
#[serde(rename = "eval_jobset")]
|
||||
eval_jobset: Option<Uuid>,
|
||||
|
||||
/// Filter evaluations finished after this date (ISO 8601)
|
||||
#[serde(rename = "eval_after")]
|
||||
eval_after: Option<DateTime<Utc>>,
|
||||
|
||||
/// Filter evaluations finished before this date (ISO 8601)
|
||||
#[serde(rename = "eval_before")]
|
||||
eval_before: Option<DateTime<Utc>>,
|
||||
|
||||
// Sorting
|
||||
/// Sort builds by: created_at, job_name, status, priority (default:
|
||||
/// created_at)
|
||||
#[serde(rename = "build_sort")]
|
||||
build_sort: Option<String>,
|
||||
|
||||
/// Sort order: asc, desc (default: desc for builds, asc for projects)
|
||||
#[serde(rename = "order")]
|
||||
order: Option<String>,
|
||||
|
||||
/// Sort projects by: name, created_at (default: name)
|
||||
#[serde(rename = "project_sort")]
|
||||
project_sort: Option<String>,
|
||||
}
|
||||
|
||||
fn default_limit() -> i64 {
|
||||
20
|
||||
}
|
||||
|
||||
/// Search results response
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SearchResults {
|
||||
projects: Vec<Project>,
|
||||
builds: Vec<Build>,
|
||||
struct SearchResponse {
|
||||
projects: Vec<Project>,
|
||||
jobsets: Vec<Jobset>,
|
||||
evaluations: Vec<Evaluation>,
|
||||
builds: Vec<Build>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
total_projects: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
total_jobsets: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
total_evaluations: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
total_builds: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
limit: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
offset: Option<i64>,
|
||||
}
|
||||
|
||||
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<AppState>,
|
||||
Query(params): Query<SearchParams>,
|
||||
) -> Result<Json<SearchResults>, ApiError> {
|
||||
Query(params): Query<SearchRequest>,
|
||||
) -> Result<Json<SearchResponse>, 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<SearchEntity> = 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<AppState>,
|
||||
Query(params): Query<QuickSearchParams>,
|
||||
) -> Result<Json<SearchResponse>, 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<AppState> {
|
||||
Router::new().route("/search", get(search))
|
||||
Router::new()
|
||||
.route("/search", get(advanced_search_handler))
|
||||
.route("/search/quick", get(quick_search_handler))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue