moving closer to something useful
This commit is contained in:
+347
-53
@@ -1,7 +1,7 @@
|
||||
use crate::db;
|
||||
use crate::types::{
|
||||
Config, LandingDto, LookupDto, MonthlyAggregate, MonthlySpeciesBreakdown, PriceTrend,
|
||||
SpeciesDto, SummaryDto, TopSpecies,
|
||||
AvailableFiltersDto, Config, LandingDto, LookupDto, MonthlyAggregate,
|
||||
MonthlySpeciesBreakdown, PriceTrend, SpeciesDto, SummaryDto, TopSpecies,
|
||||
};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
@@ -48,12 +48,189 @@ fn default_limit() -> u32 {
|
||||
|
||||
const MAX_LIMIT: u32 = 10000;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct SummaryQuery {
|
||||
pub species: Option<String>,
|
||||
pub species_multi: Option<String>,
|
||||
pub month: Option<String>,
|
||||
pub month_from: Option<String>,
|
||||
pub month_to: Option<String>,
|
||||
pub zone: Option<String>,
|
||||
pub zone_multi: Option<String>,
|
||||
pub gear: Option<String>,
|
||||
pub gear_multi: Option<String>,
|
||||
}
|
||||
|
||||
impl SummaryQuery {
|
||||
fn parse_multi(value: &Option<String>) -> Vec<String> {
|
||||
match value {
|
||||
Some(v) if !v.is_empty() => {
|
||||
v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_conditions(&self) -> (Vec<String>, Vec<Box<dyn duckdb::ToSql>>) {
|
||||
let mut conditions: Vec<String> = Vec::new();
|
||||
let mut args: Vec<Box<dyn duckdb::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(ref month) = self.month {
|
||||
conditions.push("month = ?".to_string());
|
||||
args.push(Box::new(month.clone()));
|
||||
}
|
||||
if let Some(ref month_from) = self.month_from {
|
||||
conditions.push("month >= ?".to_string());
|
||||
args.push(Box::new(month_from.clone()));
|
||||
}
|
||||
if let Some(ref month_to) = self.month_to {
|
||||
conditions.push("month <= ?".to_string());
|
||||
args.push(Box::new(month_to.clone()));
|
||||
}
|
||||
|
||||
let species_list = Self::parse_multi(&self.species_multi);
|
||||
if !species_list.is_empty() {
|
||||
let placeholders: Vec<String> = (0..species_list.len())
|
||||
.map(|_| "?".to_string())
|
||||
.collect();
|
||||
conditions.push(format!("species_code IN ({})", placeholders.join(", ")));
|
||||
for s in &species_list {
|
||||
args.push(Box::new(s.clone()));
|
||||
}
|
||||
} else if let Some(ref species) = self.species {
|
||||
conditions.push("species_code = ?".to_string());
|
||||
args.push(Box::new(species.clone()));
|
||||
}
|
||||
|
||||
let zone_list = Self::parse_multi(&self.zone_multi);
|
||||
if !zone_list.is_empty() {
|
||||
let placeholders: Vec<String> = (0..zone_list.len())
|
||||
.map(|_| "?".to_string())
|
||||
.collect();
|
||||
conditions.push(format!("zone_code IN ({})", placeholders.join(", ")));
|
||||
for z in &zone_list {
|
||||
args.push(Box::new(z.clone()));
|
||||
}
|
||||
} else if let Some(ref zone) = self.zone {
|
||||
conditions.push("zone_code = ?".to_string());
|
||||
args.push(Box::new(zone.clone()));
|
||||
}
|
||||
|
||||
let gear_list = Self::parse_multi(&self.gear_multi);
|
||||
if !gear_list.is_empty() {
|
||||
let placeholders: Vec<String> = (0..gear_list.len())
|
||||
.map(|_| "?".to_string())
|
||||
.collect();
|
||||
conditions.push(format!("gear_code IN ({})", placeholders.join(", ")));
|
||||
for g in &gear_list {
|
||||
args.push(Box::new(g.clone()));
|
||||
}
|
||||
} else if let Some(ref gear) = self.gear {
|
||||
conditions.push("gear_code = ?".to_string());
|
||||
args.push(Box::new(gear.clone()));
|
||||
}
|
||||
|
||||
(conditions, args)
|
||||
}
|
||||
|
||||
fn build_where_and(&self) -> (String, Vec<Box<dyn duckdb::ToSql>>) {
|
||||
let (conditions, args) = self.build_conditions();
|
||||
let clause = if conditions.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" AND {}", conditions.join(" AND "))
|
||||
};
|
||||
(clause, args)
|
||||
}
|
||||
|
||||
fn build_where_prefix(&self) -> (String, Vec<Box<dyn duckdb::ToSql>>) {
|
||||
let (conditions, args) = self.build_conditions();
|
||||
let clause = if conditions.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" WHERE {}", conditions.join(" AND "))
|
||||
};
|
||||
(clause, args)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AvailableFiltersQuery {
|
||||
pub month_from: Option<String>,
|
||||
pub month_to: Option<String>,
|
||||
pub species_multi: Option<String>,
|
||||
pub zone_multi: Option<String>,
|
||||
pub gear_multi: Option<String>,
|
||||
}
|
||||
|
||||
impl AvailableFiltersQuery {
|
||||
fn parse_multi(value: &Option<String>) -> Vec<String> {
|
||||
match value {
|
||||
Some(v) if !v.is_empty() => {
|
||||
v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_conditions_excluding(&self, exclude: &str) -> (Vec<String>, Vec<Box<dyn duckdb::ToSql>>) {
|
||||
let mut conditions: Vec<String> = Vec::new();
|
||||
let mut args: Vec<Box<dyn duckdb::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(ref month_from) = self.month_from {
|
||||
conditions.push("month >= ?".to_string());
|
||||
args.push(Box::new(month_from.clone()));
|
||||
}
|
||||
if let Some(ref month_to) = self.month_to {
|
||||
conditions.push("month <= ?".to_string());
|
||||
args.push(Box::new(month_to.clone()));
|
||||
}
|
||||
|
||||
if exclude != "species" {
|
||||
let species_list = Self::parse_multi(&self.species_multi);
|
||||
if !species_list.is_empty() {
|
||||
let placeholders: Vec<String> = species_list.iter().map(|_| "?".to_string()).collect();
|
||||
conditions.push(format!("species_code IN ({})", placeholders.join(", ")));
|
||||
for s in &species_list {
|
||||
args.push(Box::new(s.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if exclude != "zone" {
|
||||
let zone_list = Self::parse_multi(&self.zone_multi);
|
||||
if !zone_list.is_empty() {
|
||||
let placeholders: Vec<String> = zone_list.iter().map(|_| "?".to_string()).collect();
|
||||
conditions.push(format!("zone_code IN ({})", placeholders.join(", ")));
|
||||
for z in &zone_list {
|
||||
args.push(Box::new(z.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if exclude != "gear" {
|
||||
let gear_list = Self::parse_multi(&self.gear_multi);
|
||||
if !gear_list.is_empty() {
|
||||
let placeholders: Vec<String> = gear_list.iter().map(|_| "?".to_string()).collect();
|
||||
conditions.push(format!("gear_code IN ({})", placeholders.join(", ")));
|
||||
for g in &gear_list {
|
||||
args.push(Box::new(g.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(conditions, args)
|
||||
}
|
||||
|
||||
fn build_where_prefix_excluding(&self, exclude: &str) -> (String, Vec<Box<dyn duckdb::ToSql>>) {
|
||||
let (conditions, args) = self.build_conditions_excluding(exclude);
|
||||
let clause = if conditions.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" WHERE {}", conditions.join(" AND "))
|
||||
};
|
||||
(clause, args)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -127,6 +304,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/species", get(get_species))
|
||||
.route("/api/zones", get(get_zones))
|
||||
.route("/api/gear", get(get_gear))
|
||||
.route("/api/available-filters", get(get_available_filters))
|
||||
.route("/api/landings", get(get_landings))
|
||||
.route("/api/summary", get(get_summary))
|
||||
.route("/api/summary/monthly-breakdown", get(get_monthly_breakdown))
|
||||
@@ -262,31 +440,7 @@ async fn get_summary(
|
||||
let result = tokio::task::spawn_blocking(move || -> db::Result<SummaryDto> {
|
||||
let conn = conn.blocking_lock();
|
||||
|
||||
let mut conditions: Vec<String> = Vec::new();
|
||||
let mut args: Vec<Box<dyn duckdb::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(ref month) = params.month {
|
||||
conditions.push("month = ?".to_string());
|
||||
args.push(Box::new(month.clone()));
|
||||
}
|
||||
if let Some(ref month_from) = params.month_from {
|
||||
conditions.push("month >= ?".to_string());
|
||||
args.push(Box::new(month_from.clone()));
|
||||
}
|
||||
if let Some(ref month_to) = params.month_to {
|
||||
conditions.push("month <= ?".to_string());
|
||||
args.push(Box::new(month_to.clone()));
|
||||
}
|
||||
if let Some(ref species) = params.species {
|
||||
conditions.push("species_code = ?".to_string());
|
||||
args.push(Box::new(species.clone()));
|
||||
}
|
||||
|
||||
let where_clause = if conditions.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" AND {}", conditions.join(" AND "))
|
||||
};
|
||||
let (where_clause, args) = params.build_where_and();
|
||||
|
||||
let arg_refs: Vec<&dyn duckdb::ToSql> = args.iter().map(|b| b.as_ref()).collect();
|
||||
|
||||
@@ -394,31 +548,7 @@ async fn get_monthly_breakdown(
|
||||
tokio::task::spawn_blocking(move || -> db::Result<Vec<MonthlySpeciesBreakdown>> {
|
||||
let conn = conn.blocking_lock();
|
||||
|
||||
let mut conditions: Vec<String> = Vec::new();
|
||||
let mut args: Vec<Box<dyn duckdb::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(ref month) = params.month {
|
||||
conditions.push("month = ?".to_string());
|
||||
args.push(Box::new(month.clone()));
|
||||
}
|
||||
if let Some(ref month_from) = params.month_from {
|
||||
conditions.push("month >= ?".to_string());
|
||||
args.push(Box::new(month_from.clone()));
|
||||
}
|
||||
if let Some(ref month_to) = params.month_to {
|
||||
conditions.push("month <= ?".to_string());
|
||||
args.push(Box::new(month_to.clone()));
|
||||
}
|
||||
if let Some(ref species) = params.species {
|
||||
conditions.push("species_code = ?".to_string());
|
||||
args.push(Box::new(species.clone()));
|
||||
}
|
||||
|
||||
let where_clause = if conditions.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" WHERE {}", conditions.join(" AND "))
|
||||
};
|
||||
let (where_clause, args) = params.build_where_prefix();
|
||||
|
||||
let sql = format!(
|
||||
"SELECT month, species_code, MAX(species_label) as species_label, \
|
||||
@@ -455,6 +585,77 @@ async fn get_monthly_breakdown(
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
async fn get_available_filters(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<AvailableFiltersQuery>,
|
||||
) -> ApiResult<Json<AvailableFiltersDto>> {
|
||||
let conn = state.conn.clone();
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || -> db::Result<AvailableFiltersDto> {
|
||||
let conn = conn.blocking_lock();
|
||||
|
||||
let (species_clause, species_args) = params.build_where_prefix_excluding("species");
|
||||
let species_arg_refs: Vec<&dyn duckdb::ToSql> = species_args.iter().map(|b| b.as_ref()).collect();
|
||||
|
||||
let species_sql = if species_clause.is_empty() {
|
||||
"SELECT DISTINCT species_code FROM landings WHERE species_code != 'TOTAL' ORDER BY species_code"
|
||||
} else {
|
||||
"SELECT DISTINCT species_code FROM landings WHERE species_code != 'TOTAL'"
|
||||
};
|
||||
let species_sql = if species_clause.is_empty() {
|
||||
species_sql.to_string()
|
||||
} else {
|
||||
format!("SELECT DISTINCT species_code FROM landings{species_clause} AND species_code != 'TOTAL' ORDER BY species_code")
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(&species_sql)?;
|
||||
let species: Vec<String> = stmt
|
||||
.query_map(species_arg_refs.as_slice(), |row| row.get(0))?
|
||||
.collect::<std::result::Result<Vec<_>, duckdb::Error>>()?;
|
||||
|
||||
let (zone_clause, zone_args) = params.build_where_prefix_excluding("zone");
|
||||
let zone_arg_refs: Vec<&dyn duckdb::ToSql> = zone_args.iter().map(|b| b.as_ref()).collect();
|
||||
|
||||
let zone_sql = if zone_clause.is_empty() {
|
||||
"SELECT DISTINCT zone_code FROM landings ORDER BY zone_code".to_string()
|
||||
} else {
|
||||
format!("SELECT DISTINCT zone_code FROM landings{zone_clause} ORDER BY zone_code")
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(&zone_sql)?;
|
||||
let zones: Vec<String> = stmt
|
||||
.query_map(zone_arg_refs.as_slice(), |row| row.get(0))?
|
||||
.collect::<std::result::Result<Vec<_>, duckdb::Error>>()?;
|
||||
|
||||
let (gear_clause, gear_args) = params.build_where_prefix_excluding("gear");
|
||||
let gear_arg_refs: Vec<&dyn duckdb::ToSql> = gear_args.iter().map(|b| b.as_ref()).collect();
|
||||
|
||||
let gear_sql = if gear_clause.is_empty() {
|
||||
"SELECT DISTINCT gear_code FROM landings ORDER BY gear_code".to_string()
|
||||
} else {
|
||||
format!("SELECT DISTINCT gear_code FROM landings{gear_clause} ORDER BY gear_code")
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(&gear_sql)?;
|
||||
let gear: Vec<String> = stmt
|
||||
.query_map(gear_arg_refs.as_slice(), |row| row.get(0))?
|
||||
.collect::<std::result::Result<Vec<_>, duckdb::Error>>()?;
|
||||
|
||||
Ok(AvailableFiltersDto {
|
||||
species,
|
||||
zones,
|
||||
gear,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: format!("Task join error: {e}"),
|
||||
})??;
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
async fn export_parquet(State(state): State<AppState>) -> ApiResult<Response> {
|
||||
let conn = state.conn.clone();
|
||||
|
||||
@@ -929,6 +1130,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_summary_with_multi_species_filter() {
|
||||
let state = test_state();
|
||||
let base = spawn_test_server(state).await;
|
||||
|
||||
let resp = reqwest::get(format!("{base}/api/summary?species_multi=COD,HER"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body: SummaryDto = resp.json().await.unwrap();
|
||||
assert_eq!(body.monthly.len(), 2);
|
||||
|
||||
let jan = body.monthly.iter().find(|m| m.month == "2024M01").unwrap();
|
||||
assert!((jan.total_mass.unwrap() - 3000.0).abs() < f64::EPSILON);
|
||||
assert!((jan.total_value.unwrap() - 8000.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_summary_with_zone_filter() {
|
||||
let state = test_state();
|
||||
let base = spawn_test_server(state).await;
|
||||
|
||||
let resp = reqwest::get(format!("{base}/api/summary?zone=TOTAL"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body: SummaryDto = resp.json().await.unwrap();
|
||||
assert!(!body.monthly.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_summary_with_gear_filter() {
|
||||
let state = test_state();
|
||||
let base = spawn_test_server(state).await;
|
||||
|
||||
let resp = reqwest::get(format!("{base}/api/summary?gear=TOTAL"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body: SummaryDto = resp.json().await.unwrap();
|
||||
assert!(!body.monthly.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_summary_with_month_range_filter() {
|
||||
let state = test_state();
|
||||
@@ -1188,4 +1435,51 @@ mod tests {
|
||||
assert_eq!(row.species_label, "Toskur");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_available_filters_no_constraints() {
|
||||
let state = test_state();
|
||||
let base = spawn_test_server(state).await;
|
||||
|
||||
let resp = reqwest::get(format!("{base}/api/available-filters"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body: AvailableFiltersDto = resp.json().await.unwrap();
|
||||
assert!(body.species.contains(&"COD".to_string()));
|
||||
assert!(body.zones.contains(&"TOTAL".to_string()));
|
||||
assert!(body.gear.contains(&"TOTAL".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_available_filters_with_species_constraint() {
|
||||
let state = test_state();
|
||||
let base = spawn_test_server(state).await;
|
||||
|
||||
let resp = reqwest::get(format!("{base}/api/available-filters?species_multi=COD"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body: AvailableFiltersDto = resp.json().await.unwrap();
|
||||
assert!(body.species.contains(&"COD".to_string()));
|
||||
assert!(body.zones.contains(&"TOTAL".to_string()));
|
||||
assert!(body.gear.contains(&"TOTAL".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_available_filters_excludes_unrelated_species() {
|
||||
let state = test_state();
|
||||
let base = spawn_test_server(state).await;
|
||||
|
||||
let resp = reqwest::get(format!("{base}/api/available-filters?zone_multi=TOTAL"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body: AvailableFiltersDto = resp.json().await.unwrap();
|
||||
assert!(body.species.contains(&"COD".to_string()));
|
||||
assert!(body.species.contains(&"HER".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,13 @@ pub struct MetadataResponse {
|
||||
pub variables: Vec<VariableMeta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AvailableFiltersDto {
|
||||
pub species: Vec<String>,
|
||||
pub zones: Vec<String>,
|
||||
pub gear: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct VariableMeta {
|
||||
pub code: String,
|
||||
|
||||
Reference in New Issue
Block a user