use std::{fs::File, path::Path};
use arrow::{
array::{ArrayRef, Int64Array},
record_batch::RecordBatch,
};
use datafusion::{parquet::arrow::ArrowWriter, physical_plan::displayable};
use query_planning::plan::{WindowFrame, WindowFunction, WindowNode};
use super::*;
use crate::native::{ParquetReadStrategyConfig, reestablish_declared_output_ordering_if_needed};
const FACT_ROWS: i64 = 5_000;
const DIMENSION_ROWS: i64 = 997;
fn write_window_join_tables(dir: &Path) {
let write = |name: &str, columns: Vec<(&str, Vec<i64>)>| {
let schema = Arc::new(Schema::new(
columns.iter().map(|(column, _)| Field::new(*column, DataType::Int64, false)).collect::<Vec<_>>(),
));
let arrays = columns.into_iter().map(|(_, values)| Arc::new(Int64Array::from(values)) as ArrayRef).collect();
let batch = RecordBatch::try_new(Arc::clone(&schema), arrays).unwrap();
let mut writer = ArrowWriter::try_new(File::create(dir.join(name)).unwrap(), schema, None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
};
write(
"t.parquet",
vec![
("id", (0..FACT_ROWS).collect()),
("k", (0..FACT_ROWS).map(|row| (row * 31) % DIMENSION_ROWS).collect()),
("a", (0..FACT_ROWS).map(|row| (row * 7919) % 53).collect()),
("b", (0..FACT_ROWS).map(|row| (row * 131) % 211).collect()),
("v", (0..FACT_ROWS).map(|row| row % 1000).collect()),
],
);
write(
"u.parquet",
vec![("k", (0..DIMENSION_ROWS).collect()), ("w", (0..DIMENSION_ROWS).map(|row| row * 3).collect())],
);
}
async fn window_join_sql_plan(dir: &Path, sql: &str) -> Arc<dyn ExecutionPlan> {
let ctx = SessionContext::new_with_config(direct_friendly_sql_session_config());
for table in ["t", "u"] {
let path = dir.join(format!("{table}.parquet"));
ctx.register_parquet(table, path.to_str().unwrap(), ParquetReadOptions::default()).await.unwrap();
}
ctx.sql(sql).await.unwrap().create_physical_plan().await.unwrap()
}
fn sort_nodes(plan: &QueryPlan) -> Vec<NodeId> {
plan.nodes().iter().filter(|node| matches!(node.kind, PlanNode::Sort(_))).map(|node| node.id).collect()
}
fn window_node(plan: &QueryPlan) -> &WindowNode {
plan.nodes()
.iter()
.find_map(|node| match &node.kind {
PlanNode::Window(window) => Some(window),
_ => None,
})
.unwrap_or_else(|| panic!("the native plan has a window:\n{plan}"))
}
fn assert_hash_join_probe_is_sorted_below_the_join(plan: &Arc<dyn ExecutionPlan>) {
let tree = displayable(plan.as_ref()).indent(false).to_string();
let join = tree.find("HashJoinExec").unwrap_or_else(|| panic!("DataFusion plans a hash join:\n{tree}"));
let sort = tree.find("SortExec").unwrap_or_else(|| panic!("DataFusion plans a sort:\n{tree}"));
assert!(join < sort && tree.matches("SortExec").count() == 1, "DataFusion sorts only below the join:\n{tree}");
}
#[tokio::test]
async fn window_over_a_hash_join_reads_one_sort_of_its_keys_above_the_join() {
let dir = unique_temp_dir("algeon-datafusion-native-lowering-window-over-join");
write_window_join_tables(&dir);
for (sql, expected_keys) in [
(
"SELECT t.a, t.b, t.id, LEAD(t.v) OVER (PARTITION BY t.a ORDER BY t.b, t.id) AS next \
FROM t JOIN u ON t.k = u.k",
["a", "b", "id"].as_slice(),
),
(
"SELECT t.a, t.b, t.id, ROW_NUMBER() OVER (PARTITION BY t.a ORDER BY t.b, t.id) AS rn \
FROM t JOIN u ON t.k = u.k",
["a", "b", "id"].as_slice(),
),
("SELECT t.a, t.id, ROW_NUMBER() OVER (PARTITION BY t.a) AS rn FROM t JOIN u ON t.k = u.k", ["a"].as_slice()),
] {
let plan = window_join_sql_plan(&dir, sql).await;
assert_hash_join_probe_is_sorted_below_the_join(&plan);
let (report, native_plan) = optimize_with_native_rule(plan);
assert!(
matches!(report.outcomes(), [CandidateOutcome::Selected { .. }]),
"{sql}\noutcomes={:?}",
report.outcomes()
);
let native_plan = native_plan.expect("the rule keeps the native plan");
let window = window_node(&native_plan);
let Some(PlanNode::Sort(sort)) = native_plan.node(window.input).map(|node| &node.kind) else {
panic!("the window reads a sort of its keys:\n{native_plan}");
};
assert_eq!(sort_nodes(&native_plan).len(), 1, "{sql}\n{native_plan}");
assert!(
matches!(native_plan.node(sort.input).map(|node| &node.kind), Some(PlanNode::Join(_))),
"{native_plan}"
);
let keys = sort
.spec
.expressions
.iter()
.map(|key| match &key.expr {
Expr::Column(column) => column.name().to_string(),
other => panic!("sort key is a column: {other}"),
})
.collect::<Vec<_>>();
assert_eq!(keys, expected_keys, "{sql}");
}
fs::remove_dir_all(dir).unwrap();
}
#[tokio::test]
async fn ordered_join_output_sorts_once_at_the_root() {
let dir = unique_temp_dir("algeon-datafusion-native-lowering-ordered-join");
write_window_join_tables(&dir);
let plan =
window_join_sql_plan(&dir, "SELECT t.a, t.b, t.id FROM t JOIN u ON t.k = u.k ORDER BY t.a, t.b, t.id").await;
assert_hash_join_probe_is_sorted_below_the_join(&plan);
let (report, native_plan) = optimize_with_native_rule(plan);
assert!(matches!(report.outcomes(), [CandidateOutcome::Selected { .. }]), "outcomes={:?}", report.outcomes());
let native_plan = native_plan.expect("the rule keeps the native plan");
assert_eq!(sort_nodes(&native_plan), vec![native_plan.root_id()], "{native_plan}");
fs::remove_dir_all(dir).unwrap();
}
#[tokio::test]
async fn window_over_a_sorted_scan_adds_no_second_sort() {
let dir = unique_temp_dir("algeon-datafusion-native-lowering-window-over-scan");
write_window_join_tables(&dir);
let plan =
window_join_sql_plan(&dir, "SELECT a, id, LEAD(v) OVER (PARTITION BY a ORDER BY b, id) AS next FROM t").await;
let (report, native_plan) = optimize_with_native_rule(plan);
assert!(matches!(report.outcomes(), [CandidateOutcome::Selected { .. }]), "outcomes={:?}", report.outcomes());
assert_eq!(sort_nodes(&native_plan.expect("the rule keeps the native plan")).len(), 1);
fs::remove_dir_all(dir).unwrap();
}
#[tokio::test]
async fn lowered_windows_satisfy_the_input_ordering_audit_over_order_breaking_inputs() {
let dir = unique_temp_dir("algeon-datafusion-native-lowering-window-audit");
write_window_join_tables(&dir);
for sql in [
"SELECT a, id, LAG(v) OVER (PARTITION BY a ORDER BY id) AS previous \
FROM (SELECT a, MIN(id) AS id, SUM(v) AS v FROM t GROUP BY a)",
"SELECT a, id, LEAD(v) OVER (PARTITION BY a ORDER BY id) AS next \
FROM (SELECT a, id, v FROM t UNION ALL SELECT a, id, v FROM t)",
"SELECT t.a, t.id, LEAD(t.v) OVER (PARTITION BY t.a ORDER BY t.id) AS next FROM t JOIN u ON t.k = u.k",
] {
let plan = window_join_sql_plan(&dir, sql).await;
let lowering = lower_physical_plan_to_native(plan, &ParquetReadStrategyConfig::default()).unwrap();
assert_eq!(lowering.selected_count(), 1, "{sql}\noutcomes={:?}", lowering.report.outcomes());
reestablish_declared_output_ordering_if_needed(lowering.selected_plan().clone(), None)
.unwrap_or_else(|_| panic!("the audit rejects the lowered window:\n{sql}\n{}", lowering.selected_plan()));
}
fs::remove_dir_all(dir).unwrap();
}
#[tokio::test]
async fn window_functions_over_computed_arguments_plan_natively() {
let dir = unique_temp_dir("algeon-datafusion-native-lowering-window-computed-argument");
write_window_join_tables(&dir);
for sql in [
"SELECT a, COUNT(*) OVER (PARTITION BY a) AS n FROM t",
"SELECT a, SUM(v + 1) OVER (PARTITION BY a) AS s FROM t",
"SELECT a, id, LAG(v + 1) OVER (PARTITION BY a ORDER BY id) AS previous FROM t",
] {
let plan = window_join_sql_plan(&dir, sql).await;
let (report, native_plan) = optimize_with_native_rule(plan);
assert!(
matches!(report.outcomes(), [CandidateOutcome::Selected { .. }]),
"{sql}\noutcomes={:?}",
report.outcomes()
);
let native_plan = native_plan.expect("the rule keeps the native plan");
let window = window_node(&native_plan);
let argument = window
.spec
.functions
.iter()
.find_map(|function| match function {
WindowFunction::Aggregate { expr, .. } | WindowFunction::LagLead { expr, .. } => Some(expr),
_ => None,
})
.expect("the window carries an aggregate or LAG argument");
assert!(matches!(argument, Expr::Column(_)), "{sql}\nargument={argument}");
}
fs::remove_dir_all(dir).unwrap();
}
#[tokio::test]
async fn whole_input_aggregate_windows_plan_natively() {
let dir = unique_temp_dir("algeon-datafusion-native-lowering-window-whole-input");
write_window_join_tables(&dir);
for sql in [
"SELECT id, COUNT(*) OVER () AS n FROM t",
"SELECT id, SUM(v) OVER () AS s FROM t",
"SELECT id, ROW_NUMBER() OVER (ORDER BY a, id) AS rk, COUNT(*) OVER () AS n FROM t",
"SELECT id FROM (SELECT id, ROW_NUMBER() OVER (ORDER BY a, id) AS rk, COUNT(*) OVER () AS n FROM t) \
WHERE rk <= n / 100",
] {
let plan = window_join_sql_plan(&dir, sql).await;
let (report, native_plan) = optimize_with_native_rule(plan);
assert!(
matches!(report.outcomes(), [CandidateOutcome::Selected { .. }]),
"{sql}\noutcomes={:?}",
report.outcomes()
);
let native_plan = native_plan.expect("the rule keeps the native plan");
let whole_input = native_plan.nodes().iter().any(|node| match &node.kind {
PlanNode::Window(window) => {
window.spec.partition_by.is_empty()
&& window.spec.functions.iter().any(|function| {
matches!(function, WindowFunction::Aggregate { frame: WindowFrame::WholePartition, .. })
})
},
_ => false,
});
assert!(whole_input, "{sql}\n{native_plan}");
}
fs::remove_dir_all(dir).unwrap();
}
#[tokio::test]
async fn unsupported_cumulative_window_names_its_function_inside_a_union_branch() {
let dir = unique_temp_dir("algeon-datafusion-native-lowering-window-union-branch");
write_window_join_tables(&dir);
let cumulative = "COUNT(v) OVER (ORDER BY id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)";
for sql in [
format!("SELECT id, {cumulative} AS n FROM t"),
format!(
"SELECT 'a' AS k, COUNT(*) AS c FROM (SELECT id, ROW_NUMBER() OVER (ORDER BY a DESC, id) AS rk FROM t) x \
WHERE rk <= 10 \
UNION ALL SELECT 'b', COUNT(*) FROM (SELECT id, {cumulative} AS n FROM t) y WHERE n > 5"
),
] {
let plan = window_join_sql_plan(&dir, &sql).await;
let (report, native_plan) = optimize_with_native_rule(plan);
assert!(native_plan.is_none(), "{sql}");
let blocking = report
.outcomes()
.iter()
.find_map(|outcome| match outcome {
CandidateOutcome::NotSupported { detail, .. } => detail.blocking(),
_ => None,
})
.unwrap_or_else(|| panic!("{sql}\noutcomes={:?}", report.outcomes()));
assert_eq!(
(blocking.kind(), blocking.text()),
("window_function", "window function count over frame ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW"),
"{sql}"
);
}
fs::remove_dir_all(dir).unwrap();
}