use std::sync::atomic::{AtomicU64, Ordering};
use vibesql_ast::{
BinaryOperator, ColumnIdentifier, Expression, FromClause, FunctionIdentifier, GroupByClause,
JoinType, SelectItem, SelectStmt, UnaryOperator,
};
use vibesql_types::SqlValue;
use super::helpers::collect_table_names;
static SCALAR_SUBQ_COUNTER: AtomicU64 = AtomicU64::new(0);
pub(super) struct ScalarComparisonResult {
pub from: FromClause,
pub replacement_expr: Expression,
}
pub(super) fn try_convert_scalar_comparison_to_join(
from: &FromClause,
op: &BinaryOperator,
left: &Expression,
right: &Expression,
) -> Option<ScalarComparisonResult> {
let verbose = std::env::var("SUBQUERY_TRANSFORM_VERBOSE").is_ok();
if verbose {
eprintln!("[SCALAR_COMPARISON] Checking: {:?} {:?} ...", left, op);
}
if !matches!(
op,
BinaryOperator::GreaterThan
| BinaryOperator::GreaterThanOrEqual
| BinaryOperator::LessThan
| BinaryOperator::LessThanOrEqual
| BinaryOperator::Equal
| BinaryOperator::NotEqual
) {
if verbose {
eprintln!("[SCALAR_COMPARISON] Not a comparison operator");
}
return None;
}
let subquery = match right {
Expression::ScalarSubquery(sq) => sq.as_ref(),
_ => {
if verbose {
eprintln!("[SCALAR_COMPARISON] Right side is not ScalarSubquery");
}
return None;
}
};
if verbose {
eprintln!("[SCALAR_COMPARISON] Found scalar subquery comparison");
}
if subquery.select_list.len() != 1 {
if verbose {
eprintln!(
"[SCALAR_COMPARISON] Subquery has {} items in SELECT list, expected 1",
subquery.select_list.len()
);
}
return None;
}
let agg_expr = match &subquery.select_list[0] {
SelectItem::Expression { expr, .. } => expr.clone(),
_ => return None,
};
if !contains_aggregate(&agg_expr) {
if verbose {
eprintln!("[SCALAR_COMPARISON] Expression does not contain aggregate function");
}
return None;
}
if verbose {
eprintln!("[SCALAR_COMPARISON] Expression contains aggregate function");
}
let empty_group_behavior = classify_empty_group_behavior(&agg_expr);
if matches!(empty_group_behavior, EmptyGroupBehavior::Unsupported) {
if verbose {
eprintln!(
"[SCALAR_COMPARISON] Aggregate expression is not strictly NULL-propagating \
(e.g. COALESCE/IFNULL/CASE around the aggregate, or nested COUNT/TOTAL) - \
cannot represent empty-group value after LEFT JOIN, skipping"
);
}
return None;
}
if subquery.limit.is_some()
|| subquery.offset.is_some()
|| subquery.group_by.is_some()
|| subquery.having.is_some()
|| subquery.set_operation.is_some()
{
if verbose {
eprintln!(
"[SCALAR_COMPARISON] Skipping - has LIMIT/OFFSET/GROUP BY/HAVING/set operations"
);
}
return None;
}
let (inner_table_name, _inner_table_alias) = match &subquery.from {
Some(FromClause::Table { name, alias, .. }) => {
if verbose {
eprintln!("[SCALAR_COMPARISON] Inner table: {}", name);
}
(name.clone(), alias.clone())
}
_ => {
if verbose {
eprintln!("[SCALAR_COMPARISON] Complex FROM clause, skipping");
}
return None;
}
};
let mut outer_tables = Vec::new();
collect_table_names(from, &mut outer_tables);
if verbose {
eprintln!("[SCALAR_COMPARISON] Outer tables: {:?}", outer_tables);
}
let where_clause = subquery.where_clause.as_ref()?;
let (correlation_predicates, non_correlated_predicates) =
extract_correlation_predicates(where_clause, &outer_tables, &inner_table_name)?;
if verbose {
eprintln!(
"[SCALAR_COMPARISON] Found {} correlation predicates, {} non-correlated",
correlation_predicates.len(),
non_correlated_predicates.len()
);
}
if correlation_predicates.is_empty() {
if verbose {
eprintln!("[SCALAR_COMPARISON] No correlation predicates found");
}
return None;
}
let correlation_columns =
match extract_correlation_columns(&correlation_predicates, &inner_table_name) {
Some(cols) => cols,
None => {
if verbose {
eprintln!("[SCALAR_COMPARISON] Failed to extract correlation columns");
}
return None;
}
};
if std::env::var("SUBQUERY_TRANSFORM_VERBOSE").is_ok() {
eprintln!(
"[SUBQUERY_TRANSFORM] Decorrelating scalar comparison subquery with {} correlation columns",
correlation_columns.len()
);
for (outer, inner) in &correlation_columns {
eprintln!("[SUBQUERY_TRANSFORM] {} = {}", outer, inner);
}
}
let alias = format!("__scalar_subq_{}", SCALAR_SUBQ_COUNTER.fetch_add(1, Ordering::Relaxed));
let agg_column_name = "__scalar_agg".to_string();
let mut select_list = Vec::new();
let mut correlation_aliases: Vec<String> = Vec::new();
for (i, (_, inner_col)) in correlation_columns.iter().enumerate() {
let col_alias = format!("__corr_{}", i);
correlation_aliases.push(col_alias.clone());
select_list.push(SelectItem::Expression {
expr: Expression::ColumnRef(ColumnIdentifier::simple(inner_col, false)),
alias: Some(col_alias),
source_text: None,
});
}
select_list.push(SelectItem::Expression {
expr: agg_expr.clone(),
alias: Some(agg_column_name.clone()),
source_text: None,
});
let group_by: Vec<Expression> = correlation_columns
.iter()
.map(|(_, inner_col)| Expression::ColumnRef(ColumnIdentifier::simple(inner_col, false)))
.collect();
let new_where = if non_correlated_predicates.is_empty() {
None
} else {
Some(combine_predicates(&non_correlated_predicates))
};
let decorrelated_subquery = SelectStmt {
with_clause: None,
distinct: false,
select_list,
into_table: None,
into_variables: None,
from: subquery.from.clone(),
where_clause: new_where,
group_by: Some(GroupByClause::Simple(group_by)),
having: None,
window_definitions: None,
order_by: None,
limit: None,
offset: None,
set_operation: None,
values: None,
};
let derived_table = FromClause::Subquery {
query: Box::new(decorrelated_subquery),
alias: alias.clone(),
column_aliases: None,
};
let join_condition = build_join_condition(&correlation_columns, &correlation_aliases, &alias);
let new_from = FromClause::Join {
left: Box::new(from.clone()),
right: Box::new(derived_table),
join_type: JoinType::LeftOuter,
condition: Some(join_condition),
using_columns: None,
natural: false,
alias: None,
};
let agg_ref =
Expression::ColumnRef(ColumnIdentifier::qualified(&alias, false, &agg_column_name, false));
let comparison_rhs = match empty_group_behavior {
EmptyGroupBehavior::PropagateNull => agg_ref,
EmptyGroupBehavior::DefaultZero => Expression::Function {
name: FunctionIdentifier::new("COALESCE"),
args: vec![agg_ref, Expression::Literal(SqlValue::Integer(0))],
character_unit: None,
},
EmptyGroupBehavior::Unsupported => unreachable!(),
};
let replacement_expr = Expression::BinaryOp {
op: op.clone(),
left: Box::new(left.clone()),
right: Box::new(comparison_rhs),
};
if std::env::var("SUBQUERY_TRANSFORM_VERBOSE").is_ok() {
eprintln!("[SUBQUERY_TRANSFORM] Created LEFT JOIN with derived table alias: {}", alias);
eprintln!("[SUBQUERY_TRANSFORM] Replacement expression: {:?}", replacement_expr);
}
Some(ScalarComparisonResult { from: new_from, replacement_expr })
}
fn contains_aggregate(expr: &Expression) -> bool {
match expr {
Expression::AggregateFunction { .. } => true,
Expression::Function { name, args, .. } => {
let upper = name.canonical().to_uppercase();
if matches!(
upper.as_str(),
"SUM" | "AVG" | "COUNT" | "MIN" | "MAX" | "GROUP_CONCAT" | "TOTAL"
) {
return true;
}
args.iter().any(contains_aggregate)
}
Expression::BinaryOp { left, right, .. } => {
contains_aggregate(left) || contains_aggregate(right)
}
Expression::UnaryOp { expr, .. } => contains_aggregate(expr),
Expression::Cast { expr, .. } => contains_aggregate(expr),
Expression::Case { operand, when_clauses, else_result } => {
operand.as_ref().map(|o| contains_aggregate(o)).unwrap_or(false)
|| when_clauses.iter().any(|w| {
w.conditions.iter().any(contains_aggregate) || contains_aggregate(&w.result)
})
|| else_result.as_ref().map(|e| contains_aggregate(e)).unwrap_or(false)
}
_ => false,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EmptyGroupBehavior {
PropagateNull,
DefaultZero,
Unsupported,
}
fn classify_empty_group_behavior(expr: &Expression) -> EmptyGroupBehavior {
if is_bare_zero_on_empty_aggregate(expr) {
EmptyGroupBehavior::DefaultZero
} else {
match strict_null_propagation(expr) {
Some(true) => EmptyGroupBehavior::PropagateNull,
_ => EmptyGroupBehavior::Unsupported,
}
}
}
fn is_zero_on_empty_name(name: &FunctionIdentifier) -> bool {
matches!(name.canonical().to_uppercase().as_str(), "COUNT" | "TOTAL")
}
fn is_null_on_empty_name(name: &FunctionIdentifier) -> bool {
matches!(
name.canonical().to_uppercase().as_str(),
"SUM" | "AVG" | "MIN" | "MAX" | "GROUP_CONCAT"
)
}
fn is_bare_zero_on_empty_aggregate(expr: &Expression) -> bool {
match expr {
Expression::AggregateFunction { name, .. } => is_zero_on_empty_name(name),
Expression::Function { name, .. } => is_zero_on_empty_name(name),
_ => false,
}
}
fn strict_null_propagation(expr: &Expression) -> Option<bool> {
match expr {
Expression::AggregateFunction { name, .. } if is_null_on_empty_name(name) => Some(true),
Expression::Function { name, .. } if is_null_on_empty_name(name) => Some(true),
Expression::BinaryOp {
op:
BinaryOperator::Plus
| BinaryOperator::Minus
| BinaryOperator::Multiply
| BinaryOperator::Divide
| BinaryOperator::IntegerDivide
| BinaryOperator::Modulo,
left,
right,
} => {
let l = strict_null_propagation(left)?;
let r = strict_null_propagation(right)?;
Some(l || r)
}
Expression::UnaryOp { op: UnaryOperator::Minus | UnaryOperator::Plus, expr } => {
strict_null_propagation(expr)
}
Expression::Cast { expr, .. } => strict_null_propagation(expr),
Expression::Literal(_) => Some(false),
_ => None,
}
}
fn extract_correlation_predicates(
expr: &Expression,
outer_tables: &[String],
inner_table: &str,
) -> Option<(Vec<Expression>, Vec<Expression>)> {
let mut correlation = Vec::new();
let mut non_correlated = Vec::new();
extract_predicates_recursive(
expr,
outer_tables,
inner_table,
&mut correlation,
&mut non_correlated,
);
Some((correlation, non_correlated))
}
fn extract_predicates_recursive(
expr: &Expression,
_outer_tables: &[String],
inner_table: &str,
correlation: &mut Vec<Expression>,
non_correlated: &mut Vec<Expression>,
) {
match expr {
Expression::BinaryOp { op: BinaryOperator::And, left, right } => {
extract_predicates_recursive(
left,
_outer_tables,
inner_table,
correlation,
non_correlated,
);
extract_predicates_recursive(
right,
_outer_tables,
inner_table,
correlation,
non_correlated,
);
}
Expression::Conjunction(children) => {
for child in children {
extract_predicates_recursive(
child,
_outer_tables,
inner_table,
correlation,
non_correlated,
);
}
}
_ => {
if is_correlation_predicate(expr, inner_table) {
correlation.push(expr.clone());
} else {
non_correlated.push(expr.clone());
}
}
}
}
fn is_correlation_predicate(expr: &Expression, inner_table: &str) -> bool {
match expr {
Expression::BinaryOp { op: BinaryOperator::Equal, left, right } => {
let left_inner = is_column_from_table(left, inner_table);
let right_inner = is_column_from_table(right, inner_table);
let left_outer = is_column_from_outer(left, inner_table);
let right_outer = is_column_from_outer(right, inner_table);
(left_inner && right_outer) || (left_outer && right_inner)
}
_ => false,
}
}
fn is_column_from_table(expr: &Expression, table: &str) -> bool {
match expr {
Expression::ColumnRef(col_id) => {
if let Some(t) = col_id.table_canonical() {
t.eq_ignore_ascii_case(table)
} else {
let col = col_id.column_canonical();
if table.eq_ignore_ascii_case("lineitem") {
col.starts_with("l_") || col.starts_with("L_")
} else if table.eq_ignore_ascii_case("partsupp") {
col.starts_with("ps_") || col.starts_with("PS_")
} else if table.eq_ignore_ascii_case("supplier") {
col.starts_with("s_") || col.starts_with("S_")
} else if table.eq_ignore_ascii_case("part") {
col.starts_with("p_") || col.starts_with("P_")
} else if table.eq_ignore_ascii_case("nation") {
col.starts_with("n_") || col.starts_with("N_")
} else if table.eq_ignore_ascii_case("orders") {
col.starts_with("o_") || col.starts_with("O_")
} else if table.eq_ignore_ascii_case("customer") {
col.starts_with("c_") || col.starts_with("C_")
} else if table.eq_ignore_ascii_case("region") {
col.starts_with("r_") || col.starts_with("R_")
} else {
false
}
}
}
_ => false,
}
}
fn is_column_from_outer(expr: &Expression, inner_table: &str) -> bool {
match expr {
Expression::ColumnRef(col_id) => {
if let Some(t) = col_id.table_canonical() {
!t.eq_ignore_ascii_case(inner_table)
} else {
!is_column_from_table(expr, inner_table)
}
}
_ => false,
}
}
fn extract_correlation_columns(
predicates: &[Expression],
inner_table: &str,
) -> Option<Vec<(String, String)>> {
let mut result = Vec::new();
for pred in predicates {
match pred {
Expression::BinaryOp { op: BinaryOperator::Equal, left, right } => {
if let (Expression::ColumnRef(left_col), Expression::ColumnRef(right_col)) =
(left.as_ref(), right.as_ref())
{
let left_name = left_col.column_canonical().to_string();
let right_name = right_col.column_canonical().to_string();
let left_is_inner = is_column_from_table(left, inner_table);
let right_is_inner = is_column_from_table(right, inner_table);
if left_is_inner && !right_is_inner {
result.push((right_name, left_name));
} else if !left_is_inner && right_is_inner {
result.push((left_name, right_name));
} else {
return None;
}
} else {
return None;
}
}
_ => {
return None;
}
}
}
if result.is_empty() {
None
} else {
Some(result)
}
}
fn combine_predicates(predicates: &[Expression]) -> Expression {
if predicates.len() == 1 {
return predicates[0].clone();
}
let mut result = predicates[0].clone();
for pred in &predicates[1..] {
result = Expression::BinaryOp {
op: BinaryOperator::And,
left: Box::new(result),
right: Box::new(pred.clone()),
};
}
result
}
fn build_join_condition(
correlation_columns: &[(String, String)],
column_aliases: &[String],
subquery_alias: &str,
) -> Expression {
let conditions: Vec<Expression> = correlation_columns
.iter()
.zip(column_aliases.iter())
.map(|((outer_col, _inner_col), col_alias)| {
Expression::BinaryOp {
op: BinaryOperator::Equal,
left: Box::new(Expression::ColumnRef(ColumnIdentifier::simple(outer_col, false))),
right: Box::new(Expression::ColumnRef(ColumnIdentifier::qualified(
subquery_alias,
false,
col_alias, false,
))),
}
})
.collect();
combine_predicates(&conditions)
}
#[cfg(test)]
mod tests {
use super::*;
use vibesql_ast::SelectItem;
fn correlated_lineitem_subquery(select_expr: Expression) -> SelectStmt {
SelectStmt {
with_clause: None,
distinct: false,
select_list: vec![SelectItem::Expression {
expr: select_expr,
alias: None,
source_text: None,
}],
into_table: None,
into_variables: None,
from: Some(FromClause::Table {
index_hint: None,
name: "lineitem".to_string(),
alias: None,
column_aliases: None,
quoted: false,
}),
where_clause: Some(Expression::BinaryOp {
op: BinaryOperator::Equal,
left: Box::new(Expression::ColumnRef(ColumnIdentifier::simple("l_partkey", false))),
right: Box::new(Expression::ColumnRef(ColumnIdentifier::simple(
"ps_partkey",
false,
))),
}),
group_by: None,
having: None,
window_definitions: None,
order_by: None,
limit: None,
offset: None,
set_operation: None,
values: None,
}
}
fn transform_with_select_expr(select_expr: Expression) -> Option<ScalarComparisonResult> {
let from = FromClause::Table {
index_hint: None,
name: "partsupp".to_string(),
alias: None,
column_aliases: None,
quoted: false,
};
let left = Expression::ColumnRef(ColumnIdentifier::simple("ps_availqty", false));
let right = Expression::ScalarSubquery(Box::new(correlated_lineitem_subquery(select_expr)));
try_convert_scalar_comparison_to_join(&from, &BinaryOperator::GreaterThan, &left, &right)
}
fn sum_quantity() -> Expression {
Expression::AggregateFunction {
name: FunctionIdentifier::new("SUM"),
distinct: false,
args: vec![Expression::ColumnRef(ColumnIdentifier::simple("l_quantity", false))],
order_by: None,
filter: None,
}
}
fn count_star() -> Expression {
Expression::AggregateFunction {
name: FunctionIdentifier::new("COUNT"),
distinct: false,
args: vec![Expression::Wildcard],
order_by: None,
filter: None,
}
}
fn replacement_rhs(result: &ScalarComparisonResult) -> &Expression {
match &result.replacement_expr {
Expression::BinaryOp { right, .. } => right.as_ref(),
other => panic!("Expected BinaryOp replacement, got {:?}", other),
}
}
#[test]
fn test_sum_comparison_has_no_coalesce() {
let result = transform_with_select_expr(sum_quantity()).expect("transform should apply");
match replacement_rhs(&result) {
Expression::ColumnRef(col) => {
assert_eq!(col.column_canonical(), "__scalar_agg");
}
other => panic!("Expected bare column ref (no COALESCE), got {:?}", other),
}
}
#[test]
fn test_sum_in_expression_has_no_coalesce() {
let half_sum = Expression::BinaryOp {
op: BinaryOperator::Multiply,
left: Box::new(Expression::Literal(SqlValue::Float(0.5))),
right: Box::new(sum_quantity()),
};
let result = transform_with_select_expr(half_sum).expect("transform should apply");
match replacement_rhs(&result) {
Expression::ColumnRef(col) => {
assert_eq!(col.column_canonical(), "__scalar_agg");
}
other => panic!("Expected bare column ref (no COALESCE), got {:?}", other),
}
}
#[test]
fn test_bare_count_comparison_keeps_coalesce_zero() {
let result = transform_with_select_expr(count_star()).expect("transform should apply");
match replacement_rhs(&result) {
Expression::Function { name, args, .. } => {
assert_eq!(name.canonical().to_uppercase(), "COALESCE");
assert_eq!(args.len(), 2);
assert!(
matches!(&args[1], Expression::Literal(SqlValue::Integer(0))),
"COALESCE default must be 0, got {:?}",
args[1]
);
}
other => panic!("Expected COALESCE(.., 0), got {:?}", other),
}
}
#[test]
fn test_count_in_expression_skips_transformation() {
let count_plus_five = Expression::BinaryOp {
op: BinaryOperator::Plus,
left: Box::new(count_star()),
right: Box::new(Expression::Literal(SqlValue::Integer(5))),
};
assert!(
transform_with_select_expr(count_plus_five).is_none(),
"COUNT nested in expression must skip the transformation"
);
}
fn coalesce_sum_zero() -> Expression {
Expression::Function {
name: FunctionIdentifier::new("COALESCE"),
args: vec![sum_quantity(), Expression::Literal(SqlValue::Integer(0))],
character_unit: None,
}
}
#[test]
fn test_coalesce_sum_skips_transformation() {
assert!(
transform_with_select_expr(coalesce_sum_zero()).is_none(),
"COALESCE(SUM(..), 0) must skip the scalar-comparison transformation"
);
}
#[test]
fn test_classify_null_absorbing_expressions_unsupported() {
assert_eq!(
classify_empty_group_behavior(&coalesce_sum_zero()),
EmptyGroupBehavior::Unsupported
);
let ifnull_sum = Expression::Function {
name: FunctionIdentifier::new("IFNULL"),
args: vec![sum_quantity(), Expression::Literal(SqlValue::Integer(0))],
character_unit: None,
};
assert_eq!(classify_empty_group_behavior(&ifnull_sum), EmptyGroupBehavior::Unsupported);
let case_sum = Expression::Case {
operand: None,
when_clauses: vec![vibesql_ast::CaseWhen {
conditions: vec![Expression::UnaryOp {
op: UnaryOperator::IsNull,
expr: Box::new(sum_quantity()),
}],
result: Expression::Literal(SqlValue::Integer(0)),
}],
else_result: Some(Box::new(sum_quantity())),
};
assert_eq!(classify_empty_group_behavior(&case_sum), EmptyGroupBehavior::Unsupported);
let half_coalesce = Expression::BinaryOp {
op: BinaryOperator::Multiply,
left: Box::new(Expression::Literal(SqlValue::Float(0.5))),
right: Box::new(coalesce_sum_zero()),
};
assert_eq!(classify_empty_group_behavior(&half_coalesce), EmptyGroupBehavior::Unsupported);
let abs_sum = Expression::Function {
name: FunctionIdentifier::new("ABS"),
args: vec![sum_quantity()],
character_unit: None,
};
assert_eq!(classify_empty_group_behavior(&abs_sum), EmptyGroupBehavior::Unsupported);
}
#[test]
fn test_classify_whitelisted_strict_compositions_propagate_null() {
let neg_sum =
Expression::UnaryOp { op: UnaryOperator::Minus, expr: Box::new(sum_quantity()) };
assert_eq!(classify_empty_group_behavior(&neg_sum), EmptyGroupBehavior::PropagateNull);
let cast_sum = Expression::Cast {
expr: Box::new(sum_quantity()),
data_type: vibesql_types::DataType::Real,
};
assert_eq!(classify_empty_group_behavior(&cast_sum), EmptyGroupBehavior::PropagateNull);
let sum_plus_sum = Expression::BinaryOp {
op: BinaryOperator::Plus,
left: Box::new(sum_quantity()),
right: Box::new(sum_quantity()),
};
assert_eq!(classify_empty_group_behavior(&sum_plus_sum), EmptyGroupBehavior::PropagateNull);
}
#[test]
fn test_classify_empty_group_behavior() {
assert_eq!(classify_empty_group_behavior(&count_star()), EmptyGroupBehavior::DefaultZero);
let total = Expression::Function {
name: FunctionIdentifier::new("TOTAL"),
args: vec![Expression::ColumnRef(ColumnIdentifier::simple("qty", false))],
character_unit: None,
};
assert_eq!(classify_empty_group_behavior(&total), EmptyGroupBehavior::DefaultZero);
assert_eq!(
classify_empty_group_behavior(&sum_quantity()),
EmptyGroupBehavior::PropagateNull
);
let half_sum = Expression::BinaryOp {
op: BinaryOperator::Multiply,
left: Box::new(Expression::Literal(SqlValue::Float(0.5))),
right: Box::new(sum_quantity()),
};
assert_eq!(classify_empty_group_behavior(&half_sum), EmptyGroupBehavior::PropagateNull);
let count_plus_five = Expression::BinaryOp {
op: BinaryOperator::Plus,
left: Box::new(count_star()),
right: Box::new(Expression::Literal(SqlValue::Integer(5))),
};
assert_eq!(
classify_empty_group_behavior(&count_plus_five),
EmptyGroupBehavior::Unsupported
);
}
#[test]
fn test_contains_aggregate() {
let sum_expr = Expression::Function {
name: FunctionIdentifier::new("SUM"),
args: vec![Expression::ColumnRef(ColumnIdentifier::simple("qty", false))],
character_unit: None,
};
assert!(contains_aggregate(&sum_expr));
let mul_sum = Expression::BinaryOp {
op: BinaryOperator::Multiply,
left: Box::new(Expression::Literal(SqlValue::Float(0.5))),
right: Box::new(sum_expr),
};
assert!(contains_aggregate(&mul_sum));
let col = Expression::ColumnRef(ColumnIdentifier::simple("qty", false));
assert!(!contains_aggregate(&col));
}
}