use std::sync::Arc;
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use bytes::Bytes;
use fraiseql_core::{
db::traits::DatabaseAdapter,
runtime::{Executor, QueryMatch},
security::SecurityContext,
};
use rust_xlsxwriter::Workbook;
use tempfile::NamedTempFile;
use super::super::{
export_config::ExportConfig,
handler::{PreferHeader, ResolvedGetQuery, RestError, RestHandler, set_request_id},
params::PaginationParams,
};
pub const XLSX_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
const XLSX_MAX_CELL_CHARS: usize = 32_767;
#[must_use]
pub fn accepts_xlsx(headers: &HeaderMap) -> bool {
headers.get("accept").and_then(|v| v.to_str().ok()).is_some_and(|accept| {
accept.split(',').any(|part| {
let media = part.split(';').next().unwrap_or(part).trim();
media.eq_ignore_ascii_case(XLSX_CONTENT_TYPE)
})
})
}
pub fn validate_xlsx_request(
prefer: &PreferHeader,
pagination: &PaginationParams,
) -> Result<(), RestError> {
if prefer.count_exact || prefer.count_planned || prefer.count_estimated {
return Err(RestError::bad_request("count not available for export responses"));
}
if let PaginationParams::Offset { offset, .. } = pagination {
if *offset > 0 {
return Err(RestError::bad_request(
"pagination not available for export; use filters to narrow results",
));
}
}
if matches!(pagination, PaginationParams::Cursor { .. }) {
return Err(RestError::bad_request(
"pagination not available for export; use filters to narrow results",
));
}
Ok(())
}
pub async fn handle_xlsx_get<A: DatabaseAdapter + 'static>(
handler: &RestHandler<'_, A>,
export_config: &ExportConfig,
relative_path: &str,
query_pairs: &[(&str, &str)],
headers: &HeaderMap,
security_context: Option<&SecurityContext>,
) -> Result<XlsxResponse, RestError> {
let resolved = handler.resolve_get_query(relative_path, query_pairs, security_context)?;
let prefer = PreferHeader::from_headers(headers);
validate_xlsx_request(&prefer, &resolved.params.pagination)?;
let ResolvedGetQuery {
query_name,
query_match,
variables,
..
} = resolved;
let mut response_headers = HeaderMap::new();
set_request_id(headers, &mut response_headers);
response_headers.insert("content-type", HeaderValue::from_static(XLSX_CONTENT_TYPE));
let filename = sanitize_filename(&query_name);
let disposition = if filename.is_empty() {
"attachment; filename=\"export.xlsx\"".to_string()
} else {
format!("attachment; filename=\"{filename}.xlsx\"")
};
response_headers.insert(
"content-disposition",
HeaderValue::from_str(&disposition)
.unwrap_or_else(|_| HeaderValue::from_static("attachment; filename=\"export.xlsx\"")),
);
let batch_size = handler.config().ndjson_batch_size.max(1);
let select_columns = extract_select_columns(query_pairs);
let executor = Arc::clone(handler.executor());
let security_ctx_owned = security_context.cloned();
let bytes = build_workbook(BuildContext {
executor,
query_name: query_name.clone(),
query_match,
variables,
security_ctx: security_ctx_owned,
batch_size,
max_rows: export_config.xlsx_max_rows,
select_columns,
temp_dir: export_config.xlsx_temp_dir.clone(),
})
.await?;
Ok(XlsxResponse {
headers: response_headers,
body: XlsxBody::Bytes(bytes),
})
}
fn sanitize_filename(name: &str) -> String {
name.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect()
}
pub struct XlsxResponse {
pub headers: HeaderMap,
pub body: XlsxBody,
}
#[non_exhaustive]
pub enum XlsxBody {
Bytes(Bytes),
}
impl XlsxBody {
pub fn into_body(self) -> axum::body::Body {
match self {
Self::Bytes(bytes) => axum::body::Body::from(bytes),
}
}
}
struct BuildContext<A: DatabaseAdapter> {
executor: Arc<Executor<A>>,
query_name: String,
query_match: QueryMatch,
variables: serde_json::Value,
security_ctx: Option<SecurityContext>,
batch_size: u64,
max_rows: u64,
select_columns: Option<Vec<String>>,
temp_dir: Option<std::path::PathBuf>,
}
async fn build_workbook<A: DatabaseAdapter>(ctx: BuildContext<A>) -> Result<Bytes, RestError> {
let temp_file = create_temp_file(ctx.temp_dir.as_deref())?;
let mut workbook = Workbook::new();
let worksheet = workbook.add_worksheet_with_constant_memory();
let mut columns: Option<Vec<String>> = None;
let mut rows_written: u64 = 0;
let mut offset: u64 = 0;
let mut done = false;
while !done {
let rows = fetch_batch(&ctx, offset).await?;
if rows.is_empty() {
done = true;
continue;
}
if columns.is_none() {
let cols = determine_columns(ctx.select_columns.as_deref(), &rows);
write_header_row(worksheet, &cols)?;
columns = Some(cols);
}
let active_columns =
columns.as_ref().expect("columns initialised on first non-empty batch above");
for row in &rows {
if rows_written >= ctx.max_rows {
return Err(too_many_rows_error(ctx.max_rows));
}
let row_idx = u32::try_from(rows_written + 1)
.map_err(|_| RestError::internal("XLSX row index overflow"))?;
write_data_row(worksheet, row_idx, active_columns, row)?;
rows_written += 1;
}
#[allow(clippy::cast_possible_truncation)]
let row_count = rows.len() as u64;
if row_count < ctx.batch_size {
done = true;
} else {
offset += ctx.batch_size;
}
}
workbook
.save(temp_file.path())
.map_err(|e| RestError::internal(format!("XLSX save failed: {e}")))?;
let bytes = tokio::fs::read(temp_file.path())
.await
.map_err(|e| RestError::internal(format!("XLSX temp-file read failed: {e}")))?;
drop(temp_file);
Ok(Bytes::from(bytes))
}
fn create_temp_file(dir: Option<&std::path::Path>) -> Result<NamedTempFile, RestError> {
let mut builder = tempfile::Builder::new();
builder.prefix("fraiseql-xlsx-").suffix(".xlsx");
let file = match dir {
Some(d) => builder.tempfile_in(d),
None => builder.tempfile(),
};
file.map_err(|e| RestError::internal(format!("XLSX temp-file create failed: {e}")))
}
async fn fetch_batch<A: DatabaseAdapter>(
ctx: &BuildContext<A>,
offset: u64,
) -> Result<Vec<serde_json::Value>, RestError> {
let mut batch_vars = ctx.variables.clone();
if let Some(obj) = batch_vars.as_object_mut() {
obj.insert("limit".to_string(), serde_json::json!(ctx.batch_size));
if offset > 0 {
obj.insert("offset".to_string(), serde_json::json!(offset));
}
}
let vars_ref = if batch_vars.as_object().is_none_or(serde_json::Map::is_empty) {
None
} else {
Some(&batch_vars)
};
let result_value = ctx
.executor
.execute_query_direct(&ctx.query_match, vars_ref, ctx.security_ctx.as_ref())
.await
.map_err(|e| RestError::internal(format!("XLSX query execution failed: {e}")))?;
super::helpers::extract_rows(&result_value, &ctx.query_name)
}
fn write_header_row(
worksheet: &mut rust_xlsxwriter::Worksheet,
columns: &[String],
) -> Result<(), RestError> {
for (col_idx, name) in columns.iter().enumerate() {
let col = u16::try_from(col_idx)
.map_err(|_| RestError::internal("XLSX column index overflow"))?;
worksheet
.write_string(0, col, truncate_for_xlsx(name))
.map_err(|e| RestError::internal(format!("XLSX header write failed: {e}")))?;
}
Ok(())
}
fn write_data_row(
worksheet: &mut rust_xlsxwriter::Worksheet,
row_idx: u32,
columns: &[String],
row: &serde_json::Value,
) -> Result<(), RestError> {
for (col_idx, col_name) in columns.iter().enumerate() {
let col = u16::try_from(col_idx)
.map_err(|_| RestError::internal("XLSX column index overflow"))?;
let value = row.get(col_name).unwrap_or(&serde_json::Value::Null);
write_cell(worksheet, row_idx, col, value)?;
}
Ok(())
}
fn write_cell(
worksheet: &mut rust_xlsxwriter::Worksheet,
row: u32,
col: u16,
value: &serde_json::Value,
) -> Result<(), RestError> {
use super::csv::guard_formula_injection;
match value {
serde_json::Value::Null => Ok(()),
serde_json::Value::Bool(b) => worksheet.write_boolean(row, col, *b).map(|_| ()),
serde_json::Value::Number(n) => match n.as_f64() {
Some(f) => worksheet.write_number(row, col, f).map(|_| ()),
None => worksheet
.write_string(row, col, truncate_for_xlsx(&guard_formula_injection(&n.to_string())))
.map(|_| ()),
},
serde_json::Value::String(s) => worksheet
.write_string(row, col, truncate_for_xlsx(&guard_formula_injection(s)))
.map(|_| ()),
other => worksheet
.write_string(
row,
col,
truncate_for_xlsx(&guard_formula_injection(
&serde_json::to_string(other).unwrap_or_default(),
)),
)
.map(|_| ()),
}
.map_err(|e| RestError::internal(format!("XLSX cell write failed: {e}")))
}
fn truncate_for_xlsx(s: &str) -> String {
if s.chars().count() <= XLSX_MAX_CELL_CHARS {
return s.to_string();
}
let mut out: String = s.chars().take(XLSX_MAX_CELL_CHARS - 1).collect();
out.push('…');
out
}
fn too_many_rows_error(max_rows: u64) -> RestError {
RestError {
status: StatusCode::PAYLOAD_TOO_LARGE,
code: "XLSX_ROW_LIMIT_EXCEEDED",
message: format!(
"XLSX export exceeds the {max_rows}-row cap; request `Accept: text/csv` for larger \
result sets"
),
details: None,
}
}
fn determine_columns(select_columns: Option<&[String]>, rows: &[serde_json::Value]) -> Vec<String> {
if let Some(cols) = select_columns {
return cols.to_vec();
}
rows.first()
.and_then(|v| v.as_object())
.map(|m| m.keys().cloned().collect())
.unwrap_or_default()
}
fn extract_select_columns(query_pairs: &[(&str, &str)]) -> Option<Vec<String>> {
let raw = query_pairs.iter().find(|(k, _)| *k == "select").map(|(_, v)| *v)?;
let cols = parse_select_top_level(raw);
if cols.is_empty() { None } else { Some(cols) }
}
fn parse_select_top_level(select_raw: &str) -> Vec<String> {
let mut cols = Vec::new();
let mut depth = 0_usize;
let mut current = String::new();
for c in select_raw.chars() {
match c {
'(' => {
depth += 1;
current.push(c);
},
')' => {
depth = depth.saturating_sub(1);
current.push(c);
},
',' if depth == 0 => {
push_top_level(&mut cols, ¤t);
current.clear();
},
_ => current.push(c),
}
}
push_top_level(&mut cols, ¤t);
cols
}
fn push_top_level(cols: &mut Vec<String>, current: &str) {
let trimmed = current.trim();
if trimmed.is_empty() {
return;
}
let head = trimmed.split('(').next().unwrap_or("").trim();
if !head.is_empty() {
cols.push(head.to_string());
}
}
#[cfg(test)]
mod tests;