use super::{
FactTableMetadata, FraiseQLError, FrameBoundary, FrameExclusion, FrameType, OrderByClause,
OrderDirection, Result, SelectColumn, WhereClause, WindowExecutionPlan, WindowFrame,
WindowFunction, WindowFunctionType,
};
use crate::compiler::window_allowlist::WindowAllowlist;
pub struct WindowFunctionPlanner;
fn validate_sql_expression(value: &str, context: &str) -> Result<()> {
let safe = value.chars().all(|c| {
c.is_alphanumeric() || matches!(c, '_' | '-' | '>' | '\'' | '.' | ' ' | '(' | ')')
});
if safe {
Ok(())
} else {
Err(FraiseQLError::Validation {
message: format!(
"Unsafe characters in window function {context}: {value:?}. \
Only identifiers, JSONB path operators (-> ->>), and quoted keys are allowed."
),
path: None,
})
}
}
impl WindowFunctionPlanner {
pub fn plan(
query: &serde_json::Value,
metadata: &FactTableMetadata,
) -> Result<WindowExecutionPlan> {
let allowlist = WindowAllowlist::from_metadata(metadata);
let table = query["table"]
.as_str()
.ok_or_else(|| FraiseQLError::validation("Missing 'table' field"))?
.to_string();
let select = Self::parse_select_columns(query)?;
let windows = Self::parse_window_functions(query, &allowlist)?;
let where_clause = query.get("where").map(|_| WhereClause::And(vec![]));
let order_by = query
.get("orderBy")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| {
let direction = match item.get("direction").and_then(|d| d.as_str()) {
Some("DESC") => OrderDirection::Desc,
_ => OrderDirection::Asc,
};
Some(OrderByClause::new(item["field"].as_str()?.to_string(), direction))
})
.collect()
})
.unwrap_or_default();
let limit = query
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| u32::try_from(n).unwrap_or(u32::MAX));
let offset = query
.get("offset")
.and_then(|v| v.as_u64())
.map(|n| u32::try_from(n).unwrap_or(u32::MAX));
Ok(WindowExecutionPlan {
table,
select,
windows,
where_clause,
order_by,
limit,
offset,
})
}
fn parse_select_columns(query: &serde_json::Value) -> Result<Vec<SelectColumn>> {
let default_array = vec![];
let select = query.get("select").and_then(|s| s.as_array()).unwrap_or(&default_array);
let columns = select
.iter()
.filter_map(|col| {
col.as_str().map(|col_str| SelectColumn {
expression: col_str.to_string(),
alias: col_str.to_string(),
})
})
.collect();
Ok(columns)
}
fn parse_window_functions(
query: &serde_json::Value,
allowlist: &WindowAllowlist,
) -> Result<Vec<WindowFunction>> {
let default_array = vec![];
let windows = query.get("windows").and_then(|w| w.as_array()).unwrap_or(&default_array);
windows.iter().map(|w| Self::parse_single_window(w, allowlist)).collect()
}
fn parse_single_window(
window: &serde_json::Value,
allowlist: &WindowAllowlist,
) -> Result<WindowFunction> {
let function = Self::parse_window_function_type(&window["function"])?;
let alias = window["alias"]
.as_str()
.ok_or_else(|| FraiseQLError::validation("Missing 'alias' in window function"))?
.to_string();
let partition_by = window
.get("partitionBy")
.and_then(|p| p.as_array())
.map(|arr| -> Result<Vec<String>> {
arr.iter()
.filter_map(|v| v.as_str())
.map(|col| {
validate_sql_expression(col, "partitionBy")?;
allowlist.validate(col, "PARTITION BY")?;
Ok(col.to_string())
})
.collect()
})
.transpose()?
.unwrap_or_default();
let order_by = window
.get("orderBy")
.and_then(|o| o.as_array())
.map(|arr| -> Result<Vec<OrderByClause>> {
arr.iter()
.filter_map(|item| {
let field = item["field"].as_str()?;
let direction = match item.get("direction").and_then(|d| d.as_str()) {
Some("DESC") => OrderDirection::Desc,
_ => OrderDirection::Asc,
};
Some((field, direction))
})
.map(|(field, direction)| {
validate_sql_expression(field, "orderBy.field")?;
allowlist.validate(field, "ORDER BY")?;
Ok(OrderByClause::new(field.to_string(), direction))
})
.collect()
})
.transpose()?
.unwrap_or_default();
let frame = window.get("frame").map(Self::parse_window_frame).transpose()?;
Ok(WindowFunction {
function,
alias,
partition_by,
order_by,
frame,
})
}
fn parse_window_function_type(func: &serde_json::Value) -> Result<WindowFunctionType> {
serde_json::from_value(func.clone()).map_err(|e| {
FraiseQLError::validation(format!("Unknown or invalid window function: {e}"))
})
}
fn parse_window_frame(frame: &serde_json::Value) -> Result<WindowFrame> {
let frame_type = match frame["frame_type"].as_str() {
Some("ROWS") => FrameType::Rows,
Some("RANGE") => FrameType::Range,
Some("GROUPS") => FrameType::Groups,
_ => return Err(FraiseQLError::validation("Invalid or missing 'frame_type'")),
};
let start = Self::parse_frame_boundary(&frame["start"])?;
let end = Self::parse_frame_boundary(&frame["end"])?;
let exclusion = frame.get("exclusion").map(|e| match e.as_str() {
Some("current_row") => FrameExclusion::CurrentRow,
Some("group") => FrameExclusion::Group,
Some("ties") => FrameExclusion::Ties,
_ => FrameExclusion::NoOthers,
});
Ok(WindowFrame {
frame_type,
start,
end,
exclusion,
})
}
fn parse_frame_boundary(boundary: &serde_json::Value) -> Result<FrameBoundary> {
match boundary["type"].as_str() {
Some("unbounded_preceding") => Ok(FrameBoundary::UnboundedPreceding),
Some("n_preceding") => {
let n = u32::try_from(
boundary["n"]
.as_u64()
.ok_or_else(|| FraiseQLError::validation("Missing 'n' in N PRECEDING"))?,
)
.unwrap_or(u32::MAX);
Ok(FrameBoundary::NPreceding { n })
},
Some("current_row") => Ok(FrameBoundary::CurrentRow),
Some("n_following") => {
let n = u32::try_from(
boundary["n"]
.as_u64()
.ok_or_else(|| FraiseQLError::validation("Missing 'n' in N FOLLOWING"))?,
)
.unwrap_or(u32::MAX);
Ok(FrameBoundary::NFollowing { n })
},
Some("unbounded_following") => Ok(FrameBoundary::UnboundedFollowing),
_ => Err(FraiseQLError::validation("Invalid frame boundary type")),
}
}
pub fn validate(
plan: &WindowExecutionPlan,
_metadata: &FactTableMetadata,
database_target: crate::db::types::DatabaseType,
) -> Result<()> {
use crate::db::types::DatabaseType;
for window in &plan.windows {
if let Some(frame) = &window.frame {
if frame.frame_type == FrameType::Groups
&& !matches!(database_target, DatabaseType::PostgreSQL)
{
return Err(FraiseQLError::validation(
"GROUPS frame type only supported on PostgreSQL",
));
}
if frame.exclusion.is_some() && !matches!(database_target, DatabaseType::PostgreSQL)
{
return Err(FraiseQLError::validation(
"Frame exclusion only supported on PostgreSQL",
));
}
}
match window.function {
WindowFunctionType::PercentRank | WindowFunctionType::CumeDist => {
if matches!(database_target, DatabaseType::SQLite) {
return Err(FraiseQLError::validation(
"PERCENT_RANK and CUME_DIST not supported on SQLite",
));
}
},
_ => {},
}
}
Ok(())
}
}