use crate::expr::node::{AggOp, CmpOp, ExprNode, SubqueryNode};
use crate::pg::accumulator::SqlAccumulator;
use crate::query::portable::{PortablePredicateError, SqlEmitContext};
pub(crate) fn check_aggregate_legality(node: &ExprNode) -> Result<(), crate::DjogiError> {
match node {
ExprNode::Aggregate {
op,
distinct,
arg,
arg2,
filter,
order_by,
within_group_order_by,
window,
cast_to: _,
} => {
if *distinct {
match op {
AggOp::CountStar => {
return Err(crate::DjogiError::UnsupportedAggregate {
op: "COUNT(*)",
reason: "COUNT(DISTINCT *) is not valid SQL — \
use COUNT(DISTINCT col) via FieldRef::count() instead",
});
}
AggOp::StringAgg(_) if order_by.is_empty() => {
return Err(crate::DjogiError::UnsupportedAggregate {
op: "STRING_AGG",
reason: "STRING_AGG(DISTINCT col, sep) requires a per-aggregate \
ORDER BY clause — chain `.order_by(other_field.asc())` \
to disambiguate, otherwise Postgres rejects the call",
});
}
_ => {}
}
}
if matches!(op, AggOp::CountStar) && !order_by.is_empty() {
return Err(crate::DjogiError::UnsupportedAggregate {
op: "COUNT(*)",
reason: "COUNT(*) does not accept a per-aggregate ORDER BY clause — \
COUNT counts every row and ordering inside the aggregate has \
no effect; chain ORDER BY at the QuerySet level instead, or \
use COUNT(col ORDER BY ...) via FieldRef::count()",
});
}
let ordered_or_hypothetical = matches!(
op,
AggOp::PercentileCont
| AggOp::PercentileDisc
| AggOp::Mode
| AggOp::HypotheticalRank
| AggOp::HypotheticalDenseRank
| AggOp::HypotheticalPercentRank
| AggOp::HypotheticalCumeDist
);
debug_assert!(
!ordered_or_hypothetical || (!*distinct && order_by.is_empty() && window.is_none()),
"ordered-set / hypothetical-set aggregate must not carry DISTINCT, \
per-aggregate ORDER BY, or window modifiers — the typed kind-state \
surface does not expose those methods"
);
debug_assert!(
!ordered_or_hypothetical || !within_group_order_by.is_empty(),
"ordered-set / hypothetical-set aggregate must carry a non-empty \
within_group_order_by — typed constructor `AggregateExpr::ordered_set` \
populates this at build time; reaching this assertion indicates a \
direct-IR construction that bypassed the typed surface"
);
debug_assert!(
!matches!(op, AggOp::Grouping)
|| (!*distinct
&& filter.is_none()
&& order_by.is_empty()
&& window.is_none()
&& within_group_order_by.is_empty()),
"GROUPING aggregate must not carry modifiers — the metadata kind-state \
surface does not expose DISTINCT, FILTER, ORDER BY, OVER, or \
WITHIN GROUP modifiers"
);
check_aggregate_legality(arg)?;
if let Some(a2) = arg2 {
check_aggregate_legality(a2)?;
}
if let Some(f) = filter {
check_aggregate_legality(f)?;
}
Ok(())
}
ExprNode::Add(l, r)
| ExprNode::Sub(l, r)
| ExprNode::Mul(l, r)
| ExprNode::Div(l, r)
| ExprNode::And(l, r)
| ExprNode::Or(l, r) => {
check_aggregate_legality(l)?;
check_aggregate_legality(r)
}
ExprNode::Not(expr) => check_aggregate_legality(expr),
ExprNode::Cmp { lhs, rhs, .. } => {
check_aggregate_legality(lhs)?;
check_aggregate_legality(rhs)
}
ExprNode::Case { arms, otherwise } => {
for (cond, val) in arms {
check_aggregate_legality(cond)?;
check_aggregate_legality(val)?;
}
check_aggregate_legality(otherwise)
}
ExprNode::GroupingVariadic { args } => {
for a in args {
check_aggregate_legality(a)?;
}
Ok(())
}
#[cfg(feature = "spatial")]
ExprNode::RowAggregate { .. } => Ok(()),
_ => Ok(()),
}
}
pub(crate) fn emit_expr(
acc: &mut SqlAccumulator,
node: &ExprNode,
ctx: SqlEmitContext,
) -> Result<(), PortablePredicateError> {
match node {
ExprNode::Field { column } => {
ctx.push_column(acc, column);
}
ExprNode::RawSql(s) => {
acc.push_sql("(");
acc.push_sql(s);
acc.push_sql(")");
}
ExprNode::Literal(v) => {
crate::query::sql::push_filter_value(acc, v.clone());
}
ExprNode::Add(lhs, rhs) => {
emit_arith(acc, lhs, " + ", rhs, ctx)?;
}
ExprNode::Sub(lhs, rhs) => {
emit_arith(acc, lhs, " - ", rhs, ctx)?;
}
ExprNode::Mul(lhs, rhs) => {
emit_arith(acc, lhs, " * ", rhs, ctx)?;
}
ExprNode::Div(lhs, rhs) => {
emit_arith(acc, lhs, " / ", rhs, ctx)?;
}
ExprNode::And(lhs, rhs) => {
emit_arith(acc, lhs, " AND ", rhs, ctx)?;
}
ExprNode::Or(lhs, rhs) => {
emit_arith(acc, lhs, " OR ", rhs, ctx)?;
}
ExprNode::Not(expr) => {
acc.push_sql("NOT (");
emit_expr(acc, expr, ctx)?;
acc.push_sql(")");
}
ExprNode::Cmp { op, lhs, rhs } => {
emit_expr(acc, lhs, ctx)?;
acc.push_sql(match op {
CmpOp::Eq => " = ",
CmpOp::Neq => " <> ",
CmpOp::Gt => " > ",
CmpOp::Gte => " >= ",
CmpOp::Lt => " < ",
CmpOp::Lte => " <= ",
CmpOp::IsDistinctFrom => " IS DISTINCT FROM ",
CmpOp::IsNotDistinctFrom => " IS NOT DISTINCT FROM ",
});
emit_expr(acc, rhs, ctx)?;
}
ExprNode::Aggregate {
op,
arg,
arg2,
filter,
cast_to: _,
distinct,
window: _,
order_by,
within_group_order_by,
} => {
match op {
AggOp::CountStar => acc.push_sql("COUNT(*)"),
AggOp::StringAgg(sep) => {
acc.push_sql("STRING_AGG(");
if *distinct {
acc.push_sql("DISTINCT ");
}
emit_expr(acc, arg, ctx)?;
acc.push_sql(", ");
acc.push_bind(sep.clone());
push_aggregate_order_by(acc, order_by);
acc.push_sql(")");
}
AggOp::Count => emit_unary_agg(acc, "COUNT(", *distinct, arg, order_by, ctx)?,
AggOp::Sum => emit_unary_agg(acc, "SUM(", *distinct, arg, order_by, ctx)?,
AggOp::Avg => emit_unary_agg(acc, "AVG(", *distinct, arg, order_by, ctx)?,
AggOp::Min => emit_unary_agg(acc, "MIN(", *distinct, arg, order_by, ctx)?,
AggOp::Max => emit_unary_agg(acc, "MAX(", *distinct, arg, order_by, ctx)?,
AggOp::ArrayAgg => {
emit_unary_agg(acc, "ARRAY_AGG(", *distinct, arg, order_by, ctx)?
}
AggOp::JsonAgg => emit_unary_agg(acc, "JSONB_AGG(", *distinct, arg, order_by, ctx)?,
AggOp::BoolAnd => emit_unary_agg(acc, "BOOL_AND(", *distinct, arg, order_by, ctx)?,
AggOp::BoolOr => emit_unary_agg(acc, "BOOL_OR(", *distinct, arg, order_by, ctx)?,
AggOp::Every => emit_unary_agg(acc, "EVERY(", *distinct, arg, order_by, ctx)?,
AggOp::BitAnd => emit_unary_agg(acc, "BIT_AND(", *distinct, arg, order_by, ctx)?,
AggOp::BitOr => emit_unary_agg(acc, "BIT_OR(", *distinct, arg, order_by, ctx)?,
AggOp::BitXor => emit_unary_agg(acc, "BIT_XOR(", *distinct, arg, order_by, ctx)?,
AggOp::StddevPop => {
emit_unary_agg(acc, "STDDEV_POP(", *distinct, arg, order_by, ctx)?
}
AggOp::StddevSamp => {
emit_unary_agg(acc, "STDDEV_SAMP(", *distinct, arg, order_by, ctx)?
}
AggOp::Stddev => emit_unary_agg(acc, "STDDEV(", *distinct, arg, order_by, ctx)?,
AggOp::VarPop => emit_unary_agg(acc, "VAR_POP(", *distinct, arg, order_by, ctx)?,
AggOp::VarSamp => emit_unary_agg(acc, "VAR_SAMP(", *distinct, arg, order_by, ctx)?,
AggOp::Variance => emit_unary_agg(acc, "VARIANCE(", *distinct, arg, order_by, ctx)?,
AggOp::CovarPop => emit_binary_agg(
acc,
"COVAR_POP(",
*distinct,
arg,
arg2.as_deref()
.expect("CovarPop aggregate must have arg2 set"),
order_by,
ctx,
)?,
AggOp::CovarSamp => emit_binary_agg(
acc,
"COVAR_SAMP(",
*distinct,
arg,
arg2.as_deref()
.expect("CovarSamp aggregate must have arg2 set"),
order_by,
ctx,
)?,
AggOp::Corr => emit_binary_agg(
acc,
"CORR(",
*distinct,
arg,
arg2.as_deref().expect("Corr aggregate must have arg2 set"),
order_by,
ctx,
)?,
AggOp::RegrAvgx => emit_binary_agg(
acc,
"REGR_AVGX(",
*distinct,
arg,
arg2.as_deref()
.expect("RegrAvgx aggregate must have arg2 set"),
order_by,
ctx,
)?,
AggOp::RegrAvgy => emit_binary_agg(
acc,
"REGR_AVGY(",
*distinct,
arg,
arg2.as_deref()
.expect("RegrAvgy aggregate must have arg2 set"),
order_by,
ctx,
)?,
AggOp::RegrCount => emit_binary_agg(
acc,
"REGR_COUNT(",
*distinct,
arg,
arg2.as_deref()
.expect("RegrCount aggregate must have arg2 set"),
order_by,
ctx,
)?,
AggOp::RegrIntercept => emit_binary_agg(
acc,
"REGR_INTERCEPT(",
*distinct,
arg,
arg2.as_deref()
.expect("RegrIntercept aggregate must have arg2 set"),
order_by,
ctx,
)?,
AggOp::RegrR2 => emit_binary_agg(
acc,
"REGR_R2(",
*distinct,
arg,
arg2.as_deref()
.expect("RegrR2 aggregate must have arg2 set"),
order_by,
ctx,
)?,
AggOp::RegrSlope => emit_binary_agg(
acc,
"REGR_SLOPE(",
*distinct,
arg,
arg2.as_deref()
.expect("RegrSlope aggregate must have arg2 set"),
order_by,
ctx,
)?,
AggOp::RegrSxx => emit_binary_agg(
acc,
"REGR_SXX(",
*distinct,
arg,
arg2.as_deref()
.expect("RegrSxx aggregate must have arg2 set"),
order_by,
ctx,
)?,
AggOp::RegrSxy => emit_binary_agg(
acc,
"REGR_SXY(",
*distinct,
arg,
arg2.as_deref()
.expect("RegrSxy aggregate must have arg2 set"),
order_by,
ctx,
)?,
AggOp::RegrSyy => emit_binary_agg(
acc,
"REGR_SYY(",
*distinct,
arg,
arg2.as_deref()
.expect("RegrSyy aggregate must have arg2 set"),
order_by,
ctx,
)?,
AggOp::JsonObjectAgg => emit_binary_agg(
acc,
"JSON_OBJECT_AGG(",
*distinct,
arg,
arg2.as_deref()
.expect("JsonObjectAgg aggregate must have arg2 set"),
order_by,
ctx,
)?,
AggOp::JsonbObjectAgg => emit_binary_agg(
acc,
"JSONB_OBJECT_AGG(",
*distinct,
arg,
arg2.as_deref()
.expect("JsonbObjectAgg aggregate must have arg2 set"),
order_by,
ctx,
)?,
AggOp::Grouping => emit_unary_agg(acc, "GROUPING(", *distinct, arg, order_by, ctx)?,
AggOp::PercentileCont => {
acc.push_sql("PERCENTILE_CONT(");
emit_expr(acc, arg, ctx)?;
acc.push_sql(") WITHIN GROUP (ORDER BY ");
emit_within_group_target(acc, within_group_order_by);
acc.push_sql(")");
}
AggOp::PercentileDisc => {
acc.push_sql("PERCENTILE_DISC(");
emit_expr(acc, arg, ctx)?;
acc.push_sql(") WITHIN GROUP (ORDER BY ");
emit_within_group_target(acc, within_group_order_by);
acc.push_sql(")");
}
AggOp::Mode => {
acc.push_sql("MODE() WITHIN GROUP (ORDER BY ");
emit_within_group_target(acc, within_group_order_by);
acc.push_sql(")");
}
AggOp::HypotheticalRank => {
acc.push_sql("RANK(");
emit_expr(acc, arg, ctx)?;
acc.push_sql(") WITHIN GROUP (ORDER BY ");
emit_within_group_target(acc, within_group_order_by);
acc.push_sql(")");
}
AggOp::HypotheticalDenseRank => {
acc.push_sql("DENSE_RANK(");
emit_expr(acc, arg, ctx)?;
acc.push_sql(") WITHIN GROUP (ORDER BY ");
emit_within_group_target(acc, within_group_order_by);
acc.push_sql(")");
}
AggOp::HypotheticalPercentRank => {
acc.push_sql("PERCENT_RANK(");
emit_expr(acc, arg, ctx)?;
acc.push_sql(") WITHIN GROUP (ORDER BY ");
emit_within_group_target(acc, within_group_order_by);
acc.push_sql(")");
}
AggOp::HypotheticalCumeDist => {
acc.push_sql("CUME_DIST(");
emit_expr(acc, arg, ctx)?;
acc.push_sql(") WITHIN GROUP (ORDER BY ");
emit_within_group_target(acc, within_group_order_by);
acc.push_sql(")");
}
#[cfg(feature = "spatial")]
AggOp::SpatialCentroid => emit_spatial_unary_agg(
acc,
"ST_Collect(",
"ST_Centroid(",
"",
*distinct,
arg,
order_by,
filter.as_deref(),
ctx,
)?,
#[cfg(feature = "spatial")]
AggOp::SpatialConvexHull => emit_spatial_unary_agg(
acc,
"ST_Collect(",
"ST_ConvexHull(",
"",
*distinct,
arg,
order_by,
filter.as_deref(),
ctx,
)?,
#[cfg(feature = "spatial")]
AggOp::SpatialCollect => emit_spatial_unary_agg(
acc,
"ST_Collect(",
"",
"",
*distinct,
arg,
order_by,
filter.as_deref(),
ctx,
)?,
#[cfg(feature = "spatial")]
AggOp::SpatialUnion => emit_spatial_unary_agg(
acc,
"ST_Union(",
"",
"",
*distinct,
arg,
order_by,
filter.as_deref(),
ctx,
)?,
#[cfg(feature = "spatial")]
AggOp::SpatialExtent => emit_spatial_unary_agg(
acc,
"ST_Extent(",
"",
"",
*distinct,
arg,
order_by,
filter.as_deref(),
ctx,
)?,
#[cfg(feature = "spatial")]
AggOp::SpatialExtent3D => emit_spatial_unary_agg(
acc,
"ST_3DExtent(",
"",
"",
*distinct,
arg,
order_by,
filter.as_deref(),
ctx,
)?,
#[cfg(feature = "spatial")]
AggOp::SpatialMakeLine => emit_spatial_unary_agg(
acc,
"ST_MakeLine(",
"",
"",
*distinct,
arg,
order_by,
filter.as_deref(),
ctx,
)?,
#[cfg(feature = "spatial")]
AggOp::SpatialLineAgg => emit_spatial_unary_agg(
acc,
"ST_LineAgg(",
"",
"",
*distinct,
arg,
order_by,
filter.as_deref(),
ctx,
)?,
#[cfg(feature = "spatial")]
AggOp::SpatialPolygonAgg => emit_spatial_unary_agg(
acc,
"ST_Collect(",
"",
"",
*distinct,
arg,
order_by,
filter.as_deref(),
ctx,
)?,
#[cfg(feature = "spatial")]
AggOp::SpatialClusterIntersecting => emit_spatial_unary_agg(
acc,
"ST_ClusterIntersecting(",
"",
"",
*distinct,
arg,
order_by,
filter.as_deref(),
ctx,
)?,
#[cfg(feature = "spatial")]
AggOp::SpatialClusterWithin(distance) => {
let needs_filter_paren = filter.is_some();
if needs_filter_paren {
acc.push_sql("(");
}
acc.push_sql("ST_ClusterWithin(");
if *distinct {
acc.push_sql("DISTINCT ");
}
emit_expr(acc, arg, ctx)?;
acc.push_sql("::geometry, ");
acc.push_bind(*distance);
push_aggregate_order_by(acc, order_by);
acc.push_sql(")");
if let Some(cond) = filter.as_deref() {
acc.push_sql(" FILTER (WHERE ");
emit_expr(acc, cond, ctx)?;
acc.push_sql(")");
}
if needs_filter_paren {
acc.push_sql(")");
}
}
#[cfg(feature = "spatial")]
AggOp::SpatialMemUnion => emit_spatial_unary_agg(
acc,
"ST_MemUnion(",
"",
"",
*distinct,
arg,
order_by,
filter.as_deref(),
ctx,
)?,
#[cfg(feature = "spatial")]
AggOp::SpatialPolygonize => emit_spatial_unary_agg(
acc,
"ST_Polygonize(",
"",
"",
*distinct,
arg,
order_by,
filter.as_deref(),
ctx,
)?,
}
if !op_emits_outer_cast(op)
&& let Some(cond) = filter
{
acc.push_sql(" FILTER (WHERE ");
emit_expr(acc, cond, ctx)?;
acc.push_sql(")");
}
if let Some(suffix) = outer_cast_suffix(op) {
acc.push_sql(suffix);
}
}
ExprNode::Case { arms, otherwise } => {
acc.push_sql("CASE ");
for (cond, val) in arms {
acc.push_sql("WHEN ");
emit_expr(acc, cond, ctx)?;
acc.push_sql(" THEN ");
emit_expr(acc, val, ctx)?;
acc.push_sql(" ");
}
acc.push_sql("ELSE ");
emit_expr(acc, otherwise, ctx)?;
acc.push_sql(" END");
}
ExprNode::Exists(sub) => {
acc.push_sql("EXISTS (");
emit_subquery(acc, sub, ctx)?;
acc.push_sql(")");
}
ExprNode::Subquery(sub) => {
acc.push_sql("(");
emit_subquery(acc, sub, ctx)?;
acc.push_sql(")");
}
ExprNode::ArrayLength { column } => {
acc.push_sql("array_length(");
acc.push_sql(column);
acc.push_sql(", 1)");
}
ExprNode::CurrentYear => {
acc.push_sql("EXTRACT(YEAR FROM CURRENT_DATE)::INTEGER");
}
ExprNode::OuterRef { column } => {
acc.push_sql(column);
}
ExprNode::OuterRefColumn { table, column } => {
acc.push_sql(table);
acc.push_sql(".");
acc.push_sql(column);
}
ExprNode::OuterRefAlias {
alias,
column,
model_type,
model_name,
} => {
let Some(scope) = ctx.lateral_outer_scope() else {
return Err(PortablePredicateError::LateralOuterRefOutOfScope {
column,
source_model: model_name,
});
};
if *model_type != scope.model_type {
return Err(PortablePredicateError::LateralOuterRefModelMismatch {
column,
source_model: model_name,
expected_model: scope.model_name,
});
}
debug_assert_eq!(
*alias, scope.alias,
"lateral outer-ref alias diverged from emit context"
);
acc.push_sql(scope.alias);
acc.push_sql(".");
acc.push_sql(column);
}
ExprNode::IntervalLiteral { microseconds } => {
let literal = format!("INTERVAL '{microseconds} microseconds'");
acc.push_sql(&literal);
}
ExprNode::TsMatch {
column,
dictionary,
query_text,
} => {
emit_ts(acc, "", column, dictionary, query_text, " @@ ", ")");
}
ExprNode::TsRank {
column,
dictionary,
query_text,
} => {
emit_ts(acc, "ts_rank(", column, dictionary, query_text, ", ", "))");
}
ExprNode::TsRankCd {
column,
dictionary,
query_text,
} => {
emit_ts(
acc,
"ts_rank_cd(",
column,
dictionary,
query_text,
", ",
"))",
);
}
#[cfg(feature = "trgm")]
ExprNode::TrgmSimilarTo { column, pattern } => {
ctx.push_column(acc, column);
acc.push_sql(" % ");
acc.push_bind(pattern.to_owned());
}
#[cfg(feature = "trgm")]
ExprNode::TrgmSimilarityScore { column, pattern } => {
acc.push_sql("similarity(");
ctx.push_column(acc, column);
acc.push_sql(", ");
acc.push_bind(pattern.to_owned());
acc.push_sql(")");
}
#[cfg(feature = "spatial")]
ExprNode::Spatial(s) => {
s.emit(acc);
}
ExprNode::GroupingVariadic { args } => {
acc.push_sql("GROUPING(");
for (i, a) in args.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
emit_expr(acc, a, ctx)?;
}
acc.push_sql(")");
}
#[cfg(feature = "spatial")]
ExprNode::RowAggregate { op, columns: _ } => {
emit_row_aggregate(acc, op);
}
}
Ok(())
}
#[cfg(feature = "spatial")]
fn emit_row_aggregate(acc: &mut SqlAccumulator, op: &crate::expr::node::RowAggOp) {
use crate::expr::node::RowAggOp;
match op {
RowAggOp::AsMvt {
layer_name,
extent,
geom_name,
feature_id_name,
} => {
acc.push_sql("ST_AsMVT(__djogi_row, ");
acc.push_bind(layer_name.clone());
acc.push_sql(", ");
acc.push_bind(*extent);
acc.push_sql(", ");
acc.push_bind(geom_name.clone());
if let Some(fid) = feature_id_name {
acc.push_sql(", ");
acc.push_bind(fid.clone());
}
acc.push_sql(")");
}
RowAggOp::AsGeobuf { geom_name } => {
acc.push_sql("ST_AsGeobuf(__djogi_row, ");
acc.push_bind(geom_name.clone());
acc.push_sql(")");
}
}
}
fn emit_ts(
acc: &mut SqlAccumulator,
prefix: &'static str,
column: &str,
dictionary: &str,
query_text: &str,
sep: &'static str,
suffix: &'static str,
) {
acc.push_sql(prefix);
acc.push_sql(column);
acc.push_sql(sep);
acc.push_sql("to_tsquery('");
acc.push_sql(dictionary);
acc.push_sql("', ");
acc.push_bind(query_text.to_owned());
acc.push_sql(suffix);
}
fn emit_unary_agg(
acc: &mut SqlAccumulator,
keyword_opener: &'static str,
distinct: bool,
arg: &ExprNode,
order_by: &[crate::query::order::OrderExpr],
ctx: SqlEmitContext,
) -> Result<(), PortablePredicateError> {
acc.push_sql(keyword_opener);
if distinct {
acc.push_sql("DISTINCT ");
}
emit_expr(acc, arg, ctx)?;
push_aggregate_order_by(acc, order_by);
acc.push_sql(")");
Ok(())
}
fn emit_binary_agg(
acc: &mut SqlAccumulator,
keyword_opener: &'static str,
distinct: bool,
arg: &ExprNode,
arg2: &ExprNode,
order_by: &[crate::query::order::OrderExpr],
ctx: SqlEmitContext,
) -> Result<(), PortablePredicateError> {
acc.push_sql(keyword_opener);
if distinct {
acc.push_sql("DISTINCT ");
}
emit_expr(acc, arg, ctx)?;
acc.push_sql(", ");
emit_expr(acc, arg2, ctx)?;
push_aggregate_order_by(acc, order_by);
acc.push_sql(")");
Ok(())
}
fn push_aggregate_order_by(acc: &mut SqlAccumulator, order_by: &[crate::query::order::OrderExpr]) {
if order_by.is_empty() {
return;
}
acc.push_sql(" ORDER BY ");
for (i, o) in order_by.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
o.emit(acc, None);
}
}
fn emit_within_group_target(acc: &mut SqlAccumulator, targets: &[crate::query::order::OrderExpr]) {
for (i, t) in targets.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
t.emit(acc, None);
}
}
#[cfg(feature = "spatial")]
#[allow(clippy::too_many_arguments)]
fn emit_spatial_unary_agg(
acc: &mut SqlAccumulator,
inner_keyword: &'static str,
outer_wrap_open: &'static str,
outer_close_and_cast: &'static str,
distinct: bool,
arg: &ExprNode,
order_by: &[crate::query::order::OrderExpr],
filter: Option<&ExprNode>,
ctx: SqlEmitContext,
) -> Result<(), PortablePredicateError> {
let has_outer_wrap = !outer_wrap_open.is_empty();
let needs_filter_paren = filter.is_some() && !has_outer_wrap;
if needs_filter_paren {
acc.push_sql("(");
}
acc.push_sql(outer_wrap_open);
acc.push_sql(inner_keyword);
if distinct {
acc.push_sql("DISTINCT ");
}
emit_expr(acc, arg, ctx)?;
acc.push_sql("::geometry");
push_aggregate_order_by(acc, order_by);
acc.push_sql(")");
if let Some(cond) = filter {
acc.push_sql(" FILTER (WHERE ");
emit_expr(acc, cond, ctx)?;
acc.push_sql(")");
}
if has_outer_wrap {
acc.push_sql(")"); }
if needs_filter_paren {
acc.push_sql(")"); }
acc.push_sql(outer_close_and_cast);
Ok(())
}
#[cfg(feature = "spatial")]
pub(crate) fn outer_cast_suffix(op: &AggOp) -> Option<&'static str> {
match op {
AggOp::SpatialCentroid
| AggOp::SpatialConvexHull
| AggOp::SpatialCollect
| AggOp::SpatialUnion
| AggOp::SpatialMakeLine
| AggOp::SpatialLineAgg
| AggOp::SpatialPolygonAgg
| AggOp::SpatialMemUnion
| AggOp::SpatialPolygonize => Some("::geography"),
AggOp::SpatialExtent | AggOp::SpatialExtent3D => Some("::geometry::geography"),
AggOp::SpatialClusterIntersecting | AggOp::SpatialClusterWithin(_) => Some("::geography[]"),
_ => None,
}
}
#[cfg(not(feature = "spatial"))]
pub(crate) fn outer_cast_suffix(_op: &AggOp) -> Option<&'static str> {
None
}
fn op_emits_outer_cast(op: &AggOp) -> bool {
outer_cast_suffix(op).is_some()
}
fn emit_subquery(
acc: &mut SqlAccumulator,
node: &SubqueryNode,
ctx: SqlEmitContext,
) -> Result<(), PortablePredicateError> {
acc.push_sql("SELECT ");
match &node.select_column {
Some(col) => {
acc.push_sql(col);
}
None => {
acc.push_sql("1");
}
}
acc.push_sql(" FROM ");
acc.push_sql(node.table);
if let Some(predicate) = &node.where_clause {
acc.push_sql(" WHERE ");
predicate.emit(acc, ctx.subquery_body())?;
}
Ok(())
}
fn emit_arith(
acc: &mut SqlAccumulator,
lhs: &ExprNode,
op: &'static str,
rhs: &ExprNode,
ctx: SqlEmitContext,
) -> Result<(), PortablePredicateError> {
emit_wrapped_if_compound(acc, lhs, ctx)?;
acc.push_sql(op);
emit_wrapped_if_compound(acc, rhs, ctx)?;
Ok(())
}
fn emit_wrapped_if_compound(
acc: &mut SqlAccumulator,
node: &ExprNode,
ctx: SqlEmitContext,
) -> Result<(), PortablePredicateError> {
match node {
ExprNode::Add(..)
| ExprNode::Sub(..)
| ExprNode::Mul(..)
| ExprNode::Div(..)
| ExprNode::And(..)
| ExprNode::Or(..) => {
acc.push_sql("(");
emit_expr(acc, node, ctx)?;
acc.push_sql(")");
}
_ => emit_expr(acc, node, ctx)?,
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Expr;
use crate::descriptor::ModelDescriptor;
use crate::pg::accumulator::SqlAccumulator;
use crate::query::field::FieldRef;
struct Post;
impl crate::model::__sealed::Sealed for Post {}
#[allow(clippy::manual_async_fn)]
impl crate::model::Model for Post {
type Pk = i64;
type Fields = ();
fn table_name() -> &'static str {
"posts"
}
fn pk_value(&self) -> &i64 {
unreachable!()
}
fn descriptor() -> &'static ModelDescriptor {
unreachable!()
}
fn get(
_ctx: &mut crate::context::DjogiContext,
_id: i64,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send {
async { unreachable!() }
}
fn create(
_ctx: &mut crate::context::DjogiContext,
_v: Self,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send {
async { unreachable!() }
}
fn save<'ctx>(
&'ctx mut self,
_ctx: &'ctx mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<(), crate::DjogiError>> + Send + 'ctx
{
async { unreachable!() }
}
fn delete(
self,
_ctx: &mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<(), crate::DjogiError>> + Send {
async { unreachable!() }
}
fn refresh_from_db<'ctx>(
&'ctx self,
_ctx: &'ctx mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send + 'ctx
{
async { unreachable!() }
}
}
#[test]
fn emit_field_vs_literal_eq() {
let f: FieldRef<Post, i32> = FieldRef::new("view_count");
let expr = f.as_expr().eq(Expr::literal(100i32));
let mut acc = SqlAccumulator::new("");
emit_expr(&mut acc, &expr.node, SqlEmitContext::root())
.expect("expression should lower to SQL");
let sql = acc.sql();
assert!(sql.contains("view_count = $1"), "got: {sql}");
}
#[test]
fn emit_field_vs_field_eq() {
let a: FieldRef<Post, i64> = FieldRef::new("author_id");
let b: FieldRef<Post, i64> = FieldRef::new("editor_id");
let expr = a.as_expr().eq(b.as_expr());
let mut acc = SqlAccumulator::new("");
emit_expr(&mut acc, &expr.node, SqlEmitContext::root())
.expect("expression should lower to SQL");
let sql = acc.sql();
assert_eq!(sql.trim(), "author_id = editor_id");
assert!(!sql.contains('$'), "no binds expected, got: {sql}");
}
#[test]
fn emit_arithmetic_add_literal() {
let f: FieldRef<Post, i32> = FieldRef::new("view_count");
let expr = f.as_expr() + Expr::literal(1i32);
let mut acc = SqlAccumulator::new("");
emit_expr(&mut acc, &expr.node, SqlEmitContext::root())
.expect("expression should lower to SQL");
let sql = acc.sql();
assert!(sql.contains("view_count + $1"), "got: {sql}");
}
#[test]
fn emit_arithmetic_mixed_precedence_wraps_inner_ops() {
let a: FieldRef<Post, i32> = FieldRef::new("a");
let b: FieldRef<Post, i32> = FieldRef::new("b");
let c: FieldRef<Post, i32> = FieldRef::new("c");
let expr = (a.as_expr() + b.as_expr()) * c.as_expr();
let mut acc = SqlAccumulator::new("");
emit_expr(&mut acc, &expr.node, SqlEmitContext::root())
.expect("expression should lower to SQL");
let sql = acc.sql();
assert_eq!(sql.trim(), "(a + b) * c", "got: {sql}");
}
#[test]
fn raw_sql_fragment_emits_verbatim_with_outer_parens() {
let expr: Expr<f64> = Expr::__raw_sql_fragment("base_price * (1.0 + tax_rate)");
let mut acc = SqlAccumulator::new("");
emit_expr(&mut acc, &expr.node, SqlEmitContext::root())
.expect("expression should lower to SQL");
let sql = acc.sql();
assert_eq!(sql.trim(), "(base_price * (1.0 + tax_rate))");
}
#[test]
fn raw_sql_fragment_composes_with_compare() {
let expr = Expr::<f64>::__raw_sql_fragment("base_price * (1.0 + tax_rate)")
.gte(Expr::literal(100.0_f64));
let mut acc = SqlAccumulator::new("");
emit_expr(&mut acc, &expr.node, SqlEmitContext::root())
.expect("expression should lower to SQL");
let sql = acc.sql();
assert!(
sql.contains("(base_price * (1.0 + tax_rate)) >= $1"),
"got: {sql}",
);
}
#[test]
fn emit_arithmetic_mixed_precedence_wraps_rhs_ops() {
let a: FieldRef<Post, i32> = FieldRef::new("a");
let b: FieldRef<Post, i32> = FieldRef::new("b");
let c: FieldRef<Post, i32> = FieldRef::new("c");
let expr = a.as_expr() + (b.as_expr() - c.as_expr());
let mut acc = SqlAccumulator::new("");
emit_expr(&mut acc, &expr.node, SqlEmitContext::root())
.expect("expression should lower to SQL");
let sql = acc.sql();
assert_eq!(sql.trim(), "a + (b - c)", "got: {sql}");
}
#[test]
fn emit_arithmetic_flat_left_associative_no_spurious_parens() {
let a: FieldRef<Post, i32> = FieldRef::new("a");
let b: FieldRef<Post, i32> = FieldRef::new("b");
let c: FieldRef<Post, i32> = FieldRef::new("c");
let expr = a.as_expr() + b.as_expr() + c.as_expr();
let mut acc = SqlAccumulator::new("");
emit_expr(&mut acc, &expr.node, SqlEmitContext::root())
.expect("expression should lower to SQL");
let sql = acc.sql();
assert_eq!(sql.trim(), "(a + b) + c", "got: {sql}");
}
#[test]
fn emit_current_year_no_binds() {
let expr = Expr::current_year();
let mut acc = SqlAccumulator::new("");
emit_expr(&mut acc, &expr.node, SqlEmitContext::root())
.expect("expression should lower to SQL");
let sql = acc.sql();
assert_eq!(
sql.trim(),
"EXTRACT(YEAR FROM CURRENT_DATE)::INTEGER",
"got: {sql}"
);
assert_eq!(
acc.bind_count(),
0,
"current_year takes no user input — must bind zero params; got {}",
acc.bind_count()
);
}
#[test]
fn emit_current_year_minus_field_age_expression() {
let f: FieldRef<Post, i32> = FieldRef::new("estimated_birth_year");
let age = Expr::current_year() - f.as_expr();
let mut acc = SqlAccumulator::new("");
emit_expr(&mut acc, &age.node, SqlEmitContext::root())
.expect("expression should lower to SQL");
let sql = acc.sql();
assert!(
sql.contains("EXTRACT(YEAR FROM CURRENT_DATE)::INTEGER"),
"expected current_year token, got: {sql}"
);
assert!(
sql.contains(" - estimated_birth_year"),
"expected subtraction with column on RHS, got: {sql}"
);
assert_eq!(
acc.bind_count(),
0,
"neither side binds a parameter; got {}",
acc.bind_count()
);
}
#[test]
fn emit_current_year_age_with_gte_threshold() {
let f: FieldRef<Post, i32> = FieldRef::new("estimated_birth_year");
let predicate = (Expr::current_year() - f.as_expr()).gte(Expr::literal(15i32));
let mut acc = SqlAccumulator::new("");
emit_expr(&mut acc, &predicate.node, SqlEmitContext::root())
.expect("expression should lower to SQL");
let sql = acc.sql();
assert!(
sql.contains("EXTRACT(YEAR FROM CURRENT_DATE)::INTEGER"),
"got: {sql}"
);
assert!(sql.contains("estimated_birth_year"), "got: {sql}");
assert!(sql.contains(" >= $1"), "got: {sql}");
assert_eq!(
acc.bind_count(),
1,
"exactly one bind for the threshold; got {}",
acc.bind_count()
);
}
#[cfg(feature = "trgm")]
#[test]
fn trgm_similar_to_joined_context_qualifies_column() {
use crate::expr::node::ExprNode;
let node = ExprNode::TrgmSimilarTo {
column: "bio",
pattern: "rust".to_owned(),
};
let mut acc = SqlAccumulator::new("");
emit_expr(&mut acc, &node, SqlEmitContext::joined("u"))
.expect("TrgmSimilarTo emission must succeed");
let sql = acc.sql();
assert!(
sql.contains("u.bio % $1"),
"joined context must qualify column as `u.bio`; got: {sql}"
);
assert_eq!(
acc.bind_count(),
1,
"exactly one bind (pattern); got {}",
acc.bind_count()
);
}
#[cfg(feature = "trgm")]
#[test]
fn trgm_similarity_score_joined_context_qualifies_column() {
use crate::expr::node::ExprNode;
let node = ExprNode::TrgmSimilarityScore {
column: "bio",
pattern: "rust".to_owned(),
};
let mut acc = SqlAccumulator::new("");
emit_expr(&mut acc, &node, SqlEmitContext::joined("u"))
.expect("TrgmSimilarityScore emission must succeed");
let sql = acc.sql();
assert!(
sql.contains("similarity(u.bio, $1)"),
"joined context must qualify column as `u.bio`; got: {sql}"
);
assert_eq!(
acc.bind_count(),
1,
"exactly one bind (pattern); got {}",
acc.bind_count()
);
}
}