use std::collections::{BTreeSet, HashMap, HashSet};
use rudb_common::Result;
use rudb_plan::{Arm, ColumnBinding, Expr, ExprRef, Node, NodeRef, Plan, Slice};
use crate::pass::{Context, Pass, top_down};
#[derive(Debug, Clone, Copy)]
pub struct UnusedColumns;
impl Pass for UnusedColumns {
fn name(&self) -> &'static str {
"unused_columns"
}
fn run(&self, plan: &mut Plan, _context: &Context) -> Result<()> {
prune(plan);
Ok(())
}
}
pub fn prune(plan: &mut Plan) {
let order = top_down(plan);
let untouched = untouched(plan, &order);
let mut moved: HashMap<u32, Vec<u32>> = HashMap::new();
let mut read: HashMap<u32, BTreeSet<u32>> = HashMap::new();
let mut found = Found::default();
for node in order {
if !untouched.contains(&node) {
narrow(plan, node, &read, &mut moved);
}
let mark = found.order.len();
expressions(plan, node, &mut found);
for &expr in &found.order[mark..] {
if let Expr::Column(binding) = *plan.expr(expr) {
read.entry(binding.table).or_default().insert(binding.column);
}
}
}
if moved.is_empty() {
return;
}
for &expr in &found.order {
let Expr::Column(binding) = *plan.expr(expr) else { continue };
let Some(positions) = moved.get(&binding.table) else { continue };
let to = positions[binding.column as usize];
plan.rebind(expr, ColumnBinding::new(binding.table, to));
}
}
fn narrow(
plan: &mut Plan,
node: NodeRef,
read: &HashMap<u32, BTreeSet<u32>>,
moved: &mut HashMap<u32, Vec<u32>>,
) {
let empty = BTreeSet::new();
match *plan.node(node) {
Node::Get { index, columns, .. } | Node::TableFunction { index, columns, .. } => {
let wanted = read.get(&index).unwrap_or(&empty);
let held = plan.field_list(columns).len();
if wanted.len() == held {
return;
}
let kept: Vec<_> = wanted
.iter()
.filter_map(|&at| plan.field_list(columns).get(at as usize).cloned())
.collect();
if kept.len() != wanted.len() {
return;
}
let narrowed = plan.add_fields(&kept);
match plan.node_mut(node) {
Node::Get { columns, .. } | Node::TableFunction { columns, .. } => {
*columns = narrowed;
}
_ => unreachable!("the node was one of these two a moment ago"),
}
moved.insert(index, positions(wanted, held));
}
Node::Project { index, exprs, names, .. } => {
let wanted = read.get(&index).unwrap_or(&empty);
let held = plan.expr_list(exprs).len();
if wanted.len() == held {
return;
}
let kept: Vec<_> = wanted
.iter()
.filter_map(|&at| plan.expr_list(exprs).get(at as usize).copied())
.collect();
let labels: Vec<_> = wanted
.iter()
.filter_map(|&at| plan.name_list(names).get(at as usize).copied())
.collect();
if kept.len() != wanted.len() || labels.len() != wanted.len() {
return;
}
let narrowed = plan.add_expr_list(&kept);
let renamed = plan.add_name_list(&labels);
match plan.node_mut(node) {
Node::Project { exprs, names, .. } => {
*exprs = narrowed;
*names = renamed;
}
_ => unreachable!("the node was a projection a moment ago"),
}
moved.insert(index, positions(wanted, held));
}
_ => {}
}
}
fn positions(wanted: &BTreeSet<u32>, held: usize) -> Vec<u32> {
let mut positions = vec![0; held];
for (new, &old) in wanted.iter().enumerate() {
positions[old as usize] = new as u32;
}
positions
}
fn untouched(plan: &Plan, order: &[NodeRef]) -> HashSet<NodeRef> {
let mut found = HashSet::new();
let mut pending = vec![plan.root()];
while let Some(node) = pending.pop() {
if !found.insert(node) {
continue;
}
if plan.node(node).table_index().is_some() {
continue;
}
pending.extend(plan.node(node).children().into_iter().flatten());
}
for &node in order {
if let Node::SetOp { left, right, .. } = *plan.node(node) {
found.insert(left);
found.insert(right);
}
}
found
}
fn expressions(plan: &Plan, node: NodeRef, found: &mut Found) {
match *plan.node(node) {
Node::Get { .. } | Node::Dummy | Node::SetOp { .. } | Node::CrossProduct { .. } => {}
Node::Values { rows, .. } => {
for &row in plan.row_list(rows) {
list(plan, row, found);
}
}
Node::TableFunction { args, .. } => list(plan, args, found),
Node::Filter { predicate, .. } => walk(plan, predicate, found),
Node::Project { exprs, .. } => list(plan, exprs, found),
Node::Aggregate { groups, aggregates, .. } => {
list(plan, groups, found);
list(plan, aggregates, found);
}
Node::Sort { keys, .. } => {
for key in plan.sort_key_list(keys) {
walk(plan, key.expr, found);
}
}
Node::Limit { .. } => {}
Node::Distinct { on, .. } => list(plan, on, found),
Node::Join { conditions, .. } => list(plan, conditions, found),
}
}
#[derive(Debug, Default)]
struct Found {
order: Vec<ExprRef>,
seen: HashSet<ExprRef>,
}
fn list(plan: &Plan, slice: Slice, found: &mut Found) {
for &expr in plan.expr_list(slice) {
walk(plan, expr, found);
}
}
fn walk(plan: &Plan, expr: ExprRef, found: &mut Found) {
if !found.seen.insert(expr) {
return;
}
found.order.push(expr);
match *plan.expr(expr) {
Expr::Column(_) | Expr::Constant(_) => {}
Expr::Cast { input, .. } => walk(plan, input, found),
Expr::Compare { left, right, .. } => {
walk(plan, left, found);
walk(plan, right, found);
}
Expr::Conjunction { children, .. } => list(plan, children, found),
Expr::Function { args, .. } => list(plan, args, found),
Expr::Aggregate { args, filter, .. } => {
list(plan, args, found);
if let Some(filter) = filter {
walk(plan, filter, found);
}
}
Expr::Case { arms, otherwise } => {
for &Arm { when, then } in plan.arm_list(arms) {
walk(plan, when, found);
walk(plan, then, found);
}
if let Some(otherwise) = otherwise {
walk(plan, otherwise, found);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pruned(text: &str) -> String {
let mut plan =
Plan::parse(text).unwrap_or_else(|error| panic!("{text} did not parse: {error}"));
prune(&mut plan);
plan.validate().unwrap_or_else(|error| panic!("{text} pruned to a bad plan: {error}"));
plan.to_string()
}
#[test]
fn a_scan_of_a_column_nobody_reads_loses_it() {
let before = "Project #1 [#0.0::INTEGER AS a]\n Get memory.main.t AS t #0 [a::INTEGER, b::VARCHAR]\n";
let after = "Project #1 [#0.0::INTEGER AS a]\n Get memory.main.t AS t #0 [a::INTEGER]\n";
assert_eq!(pruned(before), after);
}
#[test]
fn the_columns_that_stay_are_read_from_where_they_moved_to() {
let before = "Project #1 [#0.2::VARCHAR AS c]\n Get memory.main.t AS t #0 [a::INTEGER, b::VARCHAR, c::VARCHAR]\n";
let after = "Project #1 [#0.0::VARCHAR AS c]\n Get memory.main.t AS t #0 [c::VARCHAR]\n";
assert_eq!(pruned(before), after);
}
#[test]
fn a_column_read_only_by_a_filter_is_kept_and_one_read_by_nothing_is_not() {
let before = "Project #1 [#0.0::INTEGER AS a]\n Filter (#0.1::INTEGER > 1::INTEGER)::BOOLEAN\n Get memory.main.t AS t #0 [a::INTEGER, b::INTEGER, c::INTEGER]\n";
let after = "Project #1 [#0.0::INTEGER AS a]\n Filter (#0.1::INTEGER > 1::INTEGER)::BOOLEAN\n Get memory.main.t AS t #0 [a::INTEGER, b::INTEGER]\n";
assert_eq!(pruned(before), after);
}
#[test]
fn counting_the_rows_reads_no_columns_at_all() {
let before = "Aggregate #1 groups=[] aggregates=[count_star()::BIGINT]\n Get memory.main.t AS t #0 [a::INTEGER, b::VARCHAR]\n";
let after = "Aggregate #1 groups=[] aggregates=[count_star()::BIGINT]\n Get memory.main.t AS t #0 []\n";
assert_eq!(pruned(before), after);
}
#[test]
fn a_table_function_is_narrowed_the_same_way_a_table_is() {
let before = "Aggregate #1 groups=[] aggregates=[count_star()::BIGINT]\n TableFunction read_parquet args=['f.parquet'::VARCHAR] #0 [a::INTEGER, b::VARCHAR]\n";
let after = "Aggregate #1 groups=[] aggregates=[count_star()::BIGINT]\n TableFunction read_parquet args=['f.parquet'::VARCHAR] #0 []\n";
assert_eq!(pruned(before), after);
}
#[test]
fn each_side_of_a_join_is_narrowed_to_what_that_side_is_read_for() {
let before = "Project #2 [#0.0::INTEGER AS a]\n Join INNER on=[(#0.0::INTEGER = #1.1::INTEGER)::BOOLEAN]\n Get memory.main.t AS t #0 [a::INTEGER, b::INTEGER]\n Get memory.main.u AS u #1 [x::INTEGER, y::INTEGER]\n";
let after = "Project #2 [#0.0::INTEGER AS a]\n Join INNER on=[(#0.0::INTEGER = #1.0::INTEGER)::BOOLEAN]\n Get memory.main.t AS t #0 [a::INTEGER]\n Get memory.main.u AS u #1 [y::INTEGER]\n";
assert_eq!(pruned(before), after);
}
#[test]
fn a_scan_whose_columns_are_the_answer_is_left_alone() {
let text = "Limit 1 offset 0\n Get memory.main.t AS t #0 [a::INTEGER, b::VARCHAR]\n";
assert_eq!(pruned(text), text);
}
#[test]
fn a_scan_that_is_already_narrow_is_not_touched() {
let text = "Project #1 [#0.0::INTEGER AS a]\n Get memory.main.t AS t #0 [a::INTEGER]\n";
assert_eq!(pruned(text), text);
}
#[test]
fn pruning_twice_is_pruning_once() {
let before = "Project #1 [#0.1::VARCHAR AS b]\n Get memory.main.t AS t #0 [a::INTEGER, b::VARCHAR]\n";
let once = pruned(before);
assert_eq!(pruned(&once), once);
}
#[test]
fn the_columns_that_stay_keep_the_order_the_scan_had_them_in() {
let before = "Project #1 [#0.3::VARCHAR AS d, #0.1::INTEGER AS b]\n Get memory.main.t AS t #0 [a::INTEGER, b::INTEGER, c::INTEGER, d::VARCHAR]\n";
let after = "Project #1 [#0.1::VARCHAR AS d, #0.0::INTEGER AS b]\n Get memory.main.t AS t #0 [b::INTEGER, d::VARCHAR]\n";
assert_eq!(pruned(before), after);
}
#[test]
fn a_column_only_a_sort_key_reads_is_kept() {
let before = "Project #1 [#0.0::INTEGER AS a]\n Sort [#0.2::INTEGER DESC NULLS LAST]\n Get memory.main.t AS t #0 [a::INTEGER, b::INTEGER, c::INTEGER]\n";
let after = "Project #1 [#0.0::INTEGER AS a]\n Sort [#0.1::INTEGER DESC NULLS LAST]\n Get memory.main.t AS t #0 [a::INTEGER, c::INTEGER]\n";
assert_eq!(pruned(before), after);
}
#[test]
fn a_column_buried_inside_an_expression_is_found_the_same_as_a_bare_one() {
let before = "Project #1 [upper(CASE WHEN (#0.2::INTEGER > 3::INTEGER)::BOOLEAN THEN #0.0::VARCHAR ELSE ''::VARCHAR END::VARCHAR)::VARCHAR AS a]\n Get memory.main.t AS t #0 [a::VARCHAR, b::VARCHAR, c::INTEGER]\n";
let after = "Project #1 [upper(CASE WHEN (#0.1::INTEGER > 3::INTEGER)::BOOLEAN THEN #0.0::VARCHAR ELSE ''::VARCHAR END::VARCHAR)::VARCHAR AS a]\n Get memory.main.t AS t #0 [a::VARCHAR, c::INTEGER]\n";
assert_eq!(pruned(before), after);
}
#[test]
fn a_projection_in_the_middle_loses_the_expressions_nothing_above_it_reads() {
let before = "Project #2 [#1.1::VARCHAR AS b]\n Project #1 [#0.0::INTEGER AS a, #0.1::VARCHAR AS b, #0.2::INTEGER AS c]\n Get memory.main.t AS t #0 [a::INTEGER, b::VARCHAR, c::INTEGER]\n";
let after = "Project #2 [#1.0::VARCHAR AS b]\n Project #1 [#0.0::VARCHAR AS b]\n Get memory.main.t AS t #0 [b::VARCHAR]\n";
assert_eq!(pruned(before), after);
}
#[test]
fn counting_the_rows_through_a_projection_reads_no_columns_either() {
let before = "Aggregate #2 groups=[] aggregates=[count_star()::BIGINT]\n Project #1 [#0.0::INTEGER AS a, #0.1::VARCHAR AS b]\n Get memory.main.t AS t #0 [a::INTEGER, b::VARCHAR]\n";
let after = "Aggregate #2 groups=[] aggregates=[count_star()::BIGINT]\n Project #1 []\n Get memory.main.t AS t #0 []\n";
assert_eq!(pruned(before), after);
}
#[test]
fn pruning_a_projection_twice_is_pruning_it_once() {
let before = "Project #2 [#1.1::VARCHAR AS b]\n Project #1 [#0.0::INTEGER AS a, #0.1::VARCHAR AS b]\n Get memory.main.t AS t #0 [a::INTEGER, b::VARCHAR]\n";
let once = pruned(before);
assert_eq!(pruned(&once), once);
}
#[test]
fn neither_side_of_a_set_operation_is_narrowed() {
let text = "Aggregate #3 groups=[] aggregates=[count_star()::BIGINT]\n SetOp UNION ALL #2\n Get memory.main.t AS t #0 [a::INTEGER]\n Get memory.main.u AS u #1 [x::INTEGER]\n";
assert_eq!(pruned(text), text);
}
#[test]
fn a_values_list_keeps_its_columns_even_when_nothing_reads_them() {
let text = "Project #1 [#0.0::BIGINT AS a]\n Values #0 [a::BIGINT, b::BIGINT] rows=[[1::BIGINT, 2::BIGINT], [3::BIGINT, 4::BIGINT]]\n";
assert_eq!(pruned(text), text);
}
}