use serde_json::Value;
use crate::{
backend::where_clause::{WhereClause, WhereOperator},
compiler::{
aggregate_types::{AggregateFunction, HavingOperator, TemporalBucket},
aggregation::{
AggregateSelection, AggregationRequest, GroupBySelection, HavingCondition,
OrderByClause, OrderDirection, TreeGrouping,
},
fact_table::FactTableMetadata,
},
error::{FraiseQLError, Result},
};
pub struct AggregateQueryParser;
impl AggregateQueryParser {
pub fn parse(
query_json: &Value,
metadata: &FactTableMetadata,
native_columns: &std::collections::HashMap<String, String>,
) -> Result<AggregationRequest> {
let table_name = query_json
.get("table")
.and_then(|v| v.as_str())
.ok_or_else(|| FraiseQLError::Validation {
message: "Missing 'table' field in aggregate query".to_string(),
path: None,
})?
.to_string();
let where_clause = if let Some(where_obj) = query_json.get("where") {
Some(Self::parse_where_clause(where_obj, metadata, native_columns)?)
} else {
None
};
let group_by = if let Some(group_by_obj) = query_json.get("groupBy") {
Self::parse_group_by(group_by_obj, metadata, native_columns)?
} else {
vec![]
};
let aggregates = if let Some(agg_array) = query_json.get("aggregates") {
Self::parse_aggregates(agg_array, metadata)?
} else {
vec![]
};
let having = if let Some(having_obj) = query_json.get("having") {
Self::parse_having(having_obj, &aggregates, metadata)?
} else {
vec![]
};
let order_by = if let Some(order_obj) = query_json.get("orderBy") {
Self::parse_order_by(order_obj)?
} else {
vec![]
};
let limit = query_json
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| u32::try_from(n).unwrap_or(u32::MAX));
let offset = query_json
.get("offset")
.and_then(|v| v.as_u64())
.map(|n| u32::try_from(n).unwrap_or(u32::MAX));
Ok(AggregationRequest {
table_name,
where_clause,
group_by,
aggregates,
having,
order_by,
limit,
offset,
})
}
fn parse_where_clause(
where_obj: &Value,
metadata: &FactTableMetadata,
native_columns: &std::collections::HashMap<String, String>,
) -> Result<WhereClause> {
let Some(obj) = where_obj.as_object() else {
return Ok(WhereClause::And(vec![]));
};
let mut conditions = Vec::new();
for (key, value) in obj {
let (field, operator) = split_where_key(key, metadata, native_columns)?;
let clause = if let Some(column) = mapped_column(metadata, field) {
WhereClause::NativeField {
column: column.clone(),
pg_cast: native_columns.get(column.as_str()).cloned().unwrap_or_default(),
operator,
value: value.clone(),
}
} else if let Some(pg_cast) = native_columns.get(field) {
WhereClause::NativeField {
column: field.to_string(),
pg_cast: pg_cast.clone(),
operator,
value: value.clone(),
}
} else {
WhereClause::Field {
path: vec![crate::utils::to_snake_case(field)],
operator,
value: value.clone(),
}
};
conditions.push(clause);
}
Ok(WhereClause::And(conditions))
}
fn parse_group_by(
group_by_obj: &Value,
metadata: &FactTableMetadata,
native_columns: &std::collections::HashMap<String, String>,
) -> Result<Vec<GroupBySelection>> {
let mut selections = Vec::new();
if let Some(obj) = group_by_obj.as_object() {
for (key, value) in obj {
if value.as_bool() == Some(true) {
if let Some(calendar_sel) = Self::try_parse_calendar_bucket(key, metadata)? {
selections.push(calendar_sel);
} else if let Some(bucket_sel) = Self::parse_temporal_bucket(key, metadata)? {
selections.push(bucket_sel);
} else if let Some(mapped_col) = mapped_column(metadata, key) {
selections.push(GroupBySelection::NativeDimension {
column: mapped_col.clone(),
pg_cast: native_columns
.get(mapped_col.as_str())
.cloned()
.unwrap_or_default(),
alias: key.clone(),
});
} else if let Some(pg_cast) = native_columns.get(key.as_str()) {
selections.push(GroupBySelection::NativeDimension {
column: key.clone(),
pg_cast: pg_cast.clone(),
alias: key.clone(),
});
} else {
validate_dimension_key(key)?;
selections.push(GroupBySelection::Dimension {
path: crate::utils::to_snake_case(key),
alias: key.clone(),
});
}
} else if value.as_str() == Some("depth") {
selections.push(Self::tree_level(key, TreeGrouping::Depth, metadata)?);
} else if let Some(level) = value.as_object().and_then(|o| o.get("level")) {
let level = level
.as_u64()
.filter(|n| *n >= 1)
.and_then(|n| u32::try_from(n).ok())
.ok_or_else(|| FraiseQLError::Validation {
message: format!(
"groupBy '{key}': level must be a positive integer, got {level}"
),
path: None,
})?;
selections.push(Self::tree_level(key, TreeGrouping::Level(level), metadata)?);
} else if let Some(bucket_str) = value.as_str() {
let bucket = TemporalBucket::from_str(bucket_str)?;
if let Some(calendar_sel) =
Self::try_find_calendar_bucket(key, bucket, metadata)
{
selections.push(calendar_sel);
} else {
let column_exists =
metadata.denormalized_filters.iter().any(|f| f.name == *key);
if !column_exists {
return Err(FraiseQLError::Validation {
message: format!(
"Temporal bucketing column '{}' not found in denormalized filters",
key
),
path: None,
});
}
selections.push(GroupBySelection::TemporalBucket {
column: key.clone(),
bucket,
alias: key.clone(),
});
}
}
}
}
Ok(selections)
}
fn tree_level(
key: &str,
grouping: TreeGrouping,
metadata: &FactTableMetadata,
) -> Result<GroupBySelection> {
if !metadata.denormalized_filters.iter().any(|f| f.name == key) {
return Err(FraiseQLError::Validation {
message: format!(
"groupBy '{key}': a tree level or depth needs an ltree denormalized filter \
column, and '{key}' is not a denormalized filter of '{}'",
metadata.table_name
),
path: None,
});
}
let alias = match grouping {
TreeGrouping::Depth => format!("{key}_depth"),
TreeGrouping::Level(_) => key.to_string(),
};
Ok(GroupBySelection::TreeLevel {
column: key.to_string(),
grouping,
alias,
})
}
fn parse_temporal_bucket(
key: &str,
metadata: &FactTableMetadata,
) -> Result<Option<GroupBySelection>> {
for filter_col in &metadata.denormalized_filters {
for bucket in &[
("_second", TemporalBucket::Second),
("_minute", TemporalBucket::Minute),
("_hour", TemporalBucket::Hour),
("_day", TemporalBucket::Day),
("_week", TemporalBucket::Week),
("_month", TemporalBucket::Month),
("_quarter", TemporalBucket::Quarter),
("_year", TemporalBucket::Year),
] {
let expected_key = format!("{}{}", filter_col.name, bucket.0);
if key == expected_key {
return Ok(Some(GroupBySelection::TemporalBucket {
column: filter_col.name.clone(),
bucket: bucket.1,
alias: key.to_string(),
}));
}
}
}
Ok(None)
}
fn try_parse_calendar_bucket(
key: &str,
metadata: &FactTableMetadata,
) -> Result<Option<GroupBySelection>> {
for calendar_dim in &metadata.calendar_dimensions {
for (suffix, bucket_type) in &[
("_second", TemporalBucket::Second),
("_minute", TemporalBucket::Minute),
("_hour", TemporalBucket::Hour),
("_day", TemporalBucket::Day),
("_week", TemporalBucket::Week),
("_month", TemporalBucket::Month),
("_quarter", TemporalBucket::Quarter),
("_year", TemporalBucket::Year),
] {
let expected_key = format!("{}{}", calendar_dim.source_column, suffix);
if key == expected_key {
if let Some((gran, bucket)) =
Self::find_calendar_bucket(calendar_dim, *bucket_type)
{
return Ok(Some(GroupBySelection::CalendarDimension {
source_column: calendar_dim.source_column.clone(),
calendar_column: gran.column_name.clone(),
json_key: bucket.json_key.clone(),
bucket: bucket.bucket_type,
alias: key.to_string(),
}));
}
}
}
}
Ok(None)
}
fn try_find_calendar_bucket(
column: &str,
bucket: TemporalBucket,
metadata: &FactTableMetadata,
) -> Option<GroupBySelection> {
for calendar_dim in &metadata.calendar_dimensions {
if calendar_dim.source_column == column {
if let Some((gran, cal_bucket)) = Self::find_calendar_bucket(calendar_dim, bucket) {
return Some(GroupBySelection::CalendarDimension {
source_column: calendar_dim.source_column.clone(),
calendar_column: gran.column_name.clone(),
json_key: cal_bucket.json_key.clone(),
bucket: cal_bucket.bucket_type,
alias: column.to_string(),
});
}
}
}
None
}
fn find_calendar_bucket(
calendar_dim: &crate::compiler::fact_table::CalendarDimension,
bucket: TemporalBucket,
) -> Option<(
&crate::compiler::fact_table::CalendarGranularity,
&crate::compiler::fact_table::CalendarBucket,
)> {
for granularity in &calendar_dim.granularities {
for cal_bucket in &granularity.buckets {
if cal_bucket.bucket_type == bucket {
return Some((granularity, cal_bucket));
}
}
}
None
}
fn parse_aggregates(
agg_array: &Value,
metadata: &FactTableMetadata,
) -> Result<Vec<AggregateSelection>> {
let mut aggregates = Vec::new();
if let Some(arr) = agg_array.as_array() {
for item in arr {
if let Some(obj) = item.as_object() {
for (agg_name, _value) in obj {
aggregates.push(Self::parse_aggregate_selection(agg_name, metadata)?);
}
}
}
}
Ok(aggregates)
}
fn parse_aggregate_selection(
agg_name: &str,
metadata: &FactTableMetadata,
) -> Result<AggregateSelection> {
if agg_name == "count" {
return Ok(AggregateSelection::Count {
alias: "count".to_string(),
});
}
if agg_name == "count_distinct" {
let default_field = Self::extract_dimension_paths(metadata)
.first()
.cloned()
.unwrap_or_else(|| "id".to_string());
return Ok(AggregateSelection::CountDistinct {
field: default_field,
alias: "count_distinct".to_string(),
});
}
if let Some(stripped) = agg_name.strip_suffix("_count_distinct") {
let dimension_paths = Self::extract_dimension_paths(metadata);
if dimension_paths.iter().any(|p| p == stripped) {
return Ok(AggregateSelection::CountDistinct {
field: stripped.to_string(),
alias: agg_name.to_string(),
});
}
if metadata.measures.iter().any(|m| m.name == stripped) {
return Ok(AggregateSelection::CountDistinct {
field: stripped.to_string(),
alias: agg_name.to_string(),
});
}
return Err(FraiseQLError::Validation {
message: format!(
"COUNT DISTINCT field '{}' not found in dimensions or measures. Available: {:?}",
stripped, dimension_paths
),
path: None,
});
}
for dimension_path in Self::extract_dimension_paths(metadata) {
if let Some(stripped) = agg_name.strip_suffix("_bool_and") {
if stripped == dimension_path {
return Ok(AggregateSelection::BoolAggregate {
field: dimension_path,
function: crate::compiler::aggregate_types::BoolAggregateFunction::And,
alias: agg_name.to_string(),
});
}
}
if let Some(stripped) = agg_name.strip_suffix("_bool_or") {
if stripped == dimension_path {
return Ok(AggregateSelection::BoolAggregate {
field: dimension_path,
function: crate::compiler::aggregate_types::BoolAggregateFunction::Or,
alias: agg_name.to_string(),
});
}
}
}
for measure in &metadata.measures {
for func in &[
("_sum", AggregateFunction::Sum),
("_avg", AggregateFunction::Avg),
("_min", AggregateFunction::Min),
("_max", AggregateFunction::Max),
("_stddev", AggregateFunction::Stddev),
("_variance", AggregateFunction::Variance),
("_array_agg", AggregateFunction::ArrayAgg),
("_json_agg", AggregateFunction::JsonAgg),
("_jsonb_agg", AggregateFunction::JsonbAgg),
("_string_agg", AggregateFunction::StringAgg),
] {
let expected_name = format!("{}{}", measure.name, func.0);
if agg_name == expected_name {
return Ok(AggregateSelection::MeasureAggregate {
measure: measure.name.clone(),
function: func.1,
alias: agg_name.to_string(),
});
}
}
}
for jsonb_path in metadata.native_measures.keys() {
for func in &[
("_sum", AggregateFunction::Sum),
("_avg", AggregateFunction::Avg),
("_min", AggregateFunction::Min),
("_max", AggregateFunction::Max),
("_stddev", AggregateFunction::Stddev),
("_variance", AggregateFunction::Variance),
("_array_agg", AggregateFunction::ArrayAgg),
("_json_agg", AggregateFunction::JsonAgg),
("_jsonb_agg", AggregateFunction::JsonbAgg),
("_string_agg", AggregateFunction::StringAgg),
] {
let expected_name = format!("{}{}", jsonb_path, func.0);
if agg_name == expected_name {
return Ok(AggregateSelection::MeasureAggregate {
measure: jsonb_path.clone(),
function: func.1,
alias: agg_name.to_string(),
});
}
}
}
for dimension_path in Self::extract_dimension_paths(metadata) {
for func in &[
("_array_agg", AggregateFunction::ArrayAgg),
("_json_agg", AggregateFunction::JsonAgg),
("_jsonb_agg", AggregateFunction::JsonbAgg),
("_string_agg", AggregateFunction::StringAgg),
] {
let expected_name = format!("{}{}", dimension_path, func.0);
if agg_name == expected_name {
return Ok(AggregateSelection::MeasureAggregate {
measure: dimension_path,
function: func.1,
alias: agg_name.to_string(),
});
}
}
}
Err(FraiseQLError::Validation {
message: format!("Unknown aggregate selection: {agg_name}"),
path: None,
})
}
fn extract_dimension_paths(metadata: &FactTableMetadata) -> Vec<String> {
let mut paths = Vec::new();
for dim_path in &metadata.dimensions.paths {
paths.push(dim_path.name.clone());
}
for filter in &metadata.denormalized_filters {
paths.push(filter.name.clone());
}
paths
}
fn parse_having(
having_obj: &Value,
aggregates: &[AggregateSelection],
_metadata: &FactTableMetadata,
) -> Result<Vec<HavingCondition>> {
let mut conditions = Vec::new();
if let Some(obj) = having_obj.as_object() {
for (key, value) in obj {
if let Some((agg_name, operator)) = Self::parse_having_key(key) {
let aggregate = aggregates
.iter()
.find(|a| a.alias() == agg_name)
.ok_or_else(|| FraiseQLError::Validation {
message: format!(
"HAVING condition references non-selected aggregate: {agg_name}"
),
path: None,
})?
.clone();
conditions.push(HavingCondition {
aggregate,
operator,
value: value.clone(),
});
}
}
}
Ok(conditions)
}
fn parse_having_key(key: &str) -> Option<(&str, HavingOperator)> {
for (suffix, op) in &[
("_gt", HavingOperator::Gt),
("_gte", HavingOperator::Gte),
("_lt", HavingOperator::Lt),
("_lte", HavingOperator::Lte),
("_eq", HavingOperator::Eq),
("_neq", HavingOperator::Neq),
] {
if let Some(agg_name) = key.strip_suffix(suffix) {
return Some((agg_name, *op));
}
}
None
}
fn parse_order_by(order_obj: &Value) -> Result<Vec<OrderByClause>> {
let mut clauses = Vec::new();
if let Some(obj) = order_obj.as_object() {
for (field, value) in obj {
#[allow(clippy::match_same_arms)]
let direction = match value.as_str() {
Some("ASC" | "asc") => OrderDirection::Asc,
Some("DESC" | "desc") => OrderDirection::Desc,
_ => OrderDirection::Asc, };
clauses.push(OrderByClause::new(field.clone(), direction));
}
}
Ok(clauses)
}
}
fn validate_dimension_key(key: &str) -> Result<()> {
let mut chars = key.chars();
let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
let rest_ok = chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
if first_ok && rest_ok {
Ok(())
} else {
Err(FraiseQLError::Validation {
message: format!(
"groupBy dimension '{key}' contains invalid characters; \
only [_A-Za-z][_0-9A-Za-z]* is allowed"
),
path: None,
})
}
}
pub(super) fn mapped_column<'m>(metadata: &'m FactTableMetadata, key: &str) -> Option<&'m String> {
let wanted = crate::compiler::fact_table::dimension_key(key);
metadata
.native_dimension_mapping
.iter()
.find(|(declared, _)| crate::compiler::fact_table::dimension_key(declared) == wanted)
.map(|(_, column)| column)
}
pub(super) fn split_where_key<'k>(
key: &'k str,
metadata: &FactTableMetadata,
native_columns: &std::collections::HashMap<String, String>,
) -> Result<(&'k str, WhereOperator)> {
let is_declared = |field: &str| {
let snake = crate::utils::to_snake_case(field);
native_columns.contains_key(field)
|| metadata.denormalized_filters.iter().any(|f| f.name == field || f.name == snake)
|| metadata.dimensions.paths.iter().any(|p| p.name == field || p.name == snake)
};
let candidates: Vec<(&str, &str, WhereOperator)> = key
.match_indices('_')
.filter_map(|(i, _)| {
let (field, suffix) = (&key[..i], &key[i + 1..]);
let operator = WhereOperator::from_str(suffix).ok()?;
(!field.is_empty()).then_some((field, suffix, operator))
})
.collect();
let declared: Vec<_> = candidates.iter().filter(|(f, ..)| is_declared(f)).collect();
let chosen = match (candidates.as_slice(), declared.as_slice()) {
([], _) => {
return Err(FraiseQLError::validation(format!(
"`where` key `{key}` is not `<field>_<operator>` with a supported operator"
)));
},
([only], _) => only,
(_, [only]) => *only,
(many, _) => {
let readings: Vec<String> =
many.iter().map(|(f, op, _)| format!("`{f}` `{op}`")).collect();
return Err(FraiseQLError::validation(format!(
"`where` key `{key}` is ambiguous: it reads as {}; declare the field as a \
filter column or a dimension path",
readings.join(" or ")
)));
},
};
let (field, _, operator) = chosen;
Ok((field, operator.clone()))
}
#[cfg(test)]
mod alias_injection_tests {
#![allow(clippy::unwrap_used, clippy::panic)] use std::collections::HashMap;
use super::*;
fn empty_metadata() -> FactTableMetadata {
serde_json::from_value(serde_json::json!({
"table_name": "tf_sales",
"measures": [],
"dimensions": { "name": "dimensions", "paths": [] },
"denormalized_filters": []
}))
.expect("valid empty fact-table metadata")
}
#[test]
fn validate_dimension_key_accepts_plain_identifiers() {
assert!(validate_dimension_key("category").is_ok());
assert!(validate_dimension_key("occurred_at_day").is_ok());
assert!(validate_dimension_key("_private").is_ok());
}
#[test]
fn validate_dimension_key_rejects_injection_shapes() {
assert!(validate_dimension_key("a, (SELECT 1) AS x").is_err());
assert!(validate_dimension_key("x; DROP TABLE t").is_err());
assert!(validate_dimension_key("1leading_digit").is_err());
assert!(validate_dimension_key("dotted.path").is_err());
assert!(validate_dimension_key("").is_err());
}
#[test]
fn parse_rejects_hostile_group_by_key_with_empty_allowlist() {
let query = serde_json::json!({
"table": "tf_sales",
"groupBy": {
"a, (SELECT string_agg(rolpassword, ',') FROM pg_authid) AS leak": true
}
});
let err = AggregateQueryParser::parse(&query, &empty_metadata(), &HashMap::new())
.expect_err("hostile groupBy key must be rejected at parse time");
assert!(matches!(err, FraiseQLError::Validation { .. }), "got {err:?}");
}
#[test]
fn parse_accepts_plain_group_by_key() {
let query = serde_json::json!({
"table": "tf_sales",
"groupBy": { "category": true }
});
let req = AggregateQueryParser::parse(&query, &empty_metadata(), &HashMap::new())
.expect("plain groupBy key must parse");
assert_eq!(req.group_by.len(), 1);
}
#[test]
fn camel_group_by_dimension_recases_path_but_keeps_alias() {
let query = serde_json::json!({
"table": "tf_sales",
"groupBy": { "machineStatus": true }
});
let req = AggregateQueryParser::parse(&query, &empty_metadata(), &HashMap::new())
.expect("camel groupBy key must parse");
match req.group_by.as_slice() {
[GroupBySelection::Dimension { path, alias }] => {
assert_eq!(path, "machine_status", "path is the snake JSONB key");
assert_eq!(alias, "machineStatus", "alias is the camel response key");
},
other => panic!("expected a single Dimension, got {other:?}"),
}
}
}