Skip to main content

rudb_opt/
columns.rs

1//! Column pruning, which is the scan half of projection pushdown.
2//!
3//! A bound plan reads every column of every table it names, because the binder puts a scan's whole
4//! schema in the scan and lets the projection above it throw away what nobody asked for. That is the
5//! right thing for a binder to do and the wrong thing to run. `spec/09-optimizer.md` section 9.2
6//! calls this pass the difference between 20 GB and 200 MB on ClickBench, and it means it literally:
7//! the file is 105 columns wide and the average query in that set names three of them.
8//!
9//! The pass walks the plan from the root down, carrying the set of columns each table index is read
10//! for. At a scan it narrows the field list to the columns something above it named, and at a
11//! projection nothing above it reads the whole of, it drops the expressions nobody asked for.
12//! Dropping a column moves every column after it up, so the rewrite of the bindings is not optional
13//! and is the only part of this that can produce a wrong answer rather than a slow one.
14//!
15//! The projection half is what makes a view cost what the file costs. A view expands inline at its
16//! reference, so `SELECT count(*) FROM hits` over `CREATE VIEW hits AS SELECT * FROM
17//! read_parquet(...)` arrives here as a count over a projection of all one hundred and five columns
18//! over a scan of all one hundred and five columns. Narrowing only the scan does nothing there,
19//! because the projection above it reads every one. Measured on the real ClickBench partition on
20//! server2, that count took 3.39 seconds through the projection and 0.009 seconds without it.
21//!
22//! A scan that nothing reads a column of prunes to no columns at all, which is `SELECT count(*)`.
23//! Both scan operators produce chunks that carry a row count and no vectors for that case, and the
24//! Parquet reader in particular then reads no column data whatsoever, which is what makes counting
25//! the rows of a file a footer read. A projection prunes to no expressions the same way and for the
26//! same reason, and passes the row count of its input through.
27//!
28//! What it does not narrow is an aggregate, a `VALUES` list, either side of a set operation, and the
29//! input of a `DISTINCT` that names no columns. The first two are noted where they are skipped. A set
30//! operation lines its two sides up by position rather than binding to them, so narrowing one side
31//! without the other would change what the columns line up with, and narrowing both would take a rule
32//! that maps the set operation's own read set onto each side. That rule is worth writing and is not
33//! written here. A plain `DISTINCT` is distinct on everything its input produces and says so by
34//! naming nothing, which is the same problem in a different shape.
35
36use std::collections::{BTreeSet, HashMap, HashSet};
37
38use rudb_common::Result;
39use rudb_plan::{Arm, ColumnBinding, Expr, ExprRef, Node, NodeRef, Plan, Slice};
40
41use crate::pass::{Context, Pass, top_down};
42
43/// Narrows every scan and every interior projection to the columns something above reads.
44#[derive(Debug, Clone, Copy)]
45pub struct UnusedColumns;
46
47impl Pass for UnusedColumns {
48    fn name(&self) -> &'static str {
49        "unused_columns"
50    }
51
52    fn run(&self, plan: &mut Plan, _context: &Context) -> Result<()> {
53        prune(plan);
54        Ok(())
55    }
56}
57
58/// Narrows every scan and every interior projection in `plan` to the columns something above reads.
59///
60/// Rewrites in place. A plan this has already run over is left alone the second time, because a node
61/// whose columns are already what is read of it is not changed.
62pub fn prune(plan: &mut Plan) {
63    let order = top_down(plan);
64    let untouched = untouched(plan, &order);
65    // Old position to new, per table index, for the nodes that lost a column. A node that kept all
66    // of them is not in here, so the rebinding walk below skips it without having to compare.
67    let mut moved: HashMap<u32, Vec<u32>> = HashMap::new();
68    let mut read: HashMap<u32, BTreeSet<u32>> = HashMap::new();
69    let mut found = Found::default();
70
71    // Parents before children, which is what makes one walk enough. A node is narrowed to the
72    // columns everything above it reads, so everything above it has to have been read first.
73    for node in order {
74        if !untouched.contains(&node) {
75            narrow(plan, node, &read, &mut moved);
76        }
77        let mark = found.order.len();
78        expressions(plan, node, &mut found);
79        for &expr in &found.order[mark..] {
80            if let Expr::Column(binding) = *plan.expr(expr) {
81                read.entry(binding.table).or_default().insert(binding.column);
82            }
83        }
84    }
85
86    if moved.is_empty() {
87        return;
88    }
89    for &expr in &found.order {
90        let Expr::Column(binding) = *plan.expr(expr) else { continue };
91        let Some(positions) = moved.get(&binding.table) else { continue };
92        let to = positions[binding.column as usize];
93        plan.rebind(expr, ColumnBinding::new(binding.table, to));
94    }
95}
96
97/// Narrow one node to what `read` says is read of it, recording where its columns moved to.
98///
99/// A node whose bindings point past the end of what it holds is a malformed plan, and pruning is not
100/// where that gets reported. Leaving it alone keeps this pass out of the way of [`Plan::validate`],
101/// which says so with the node number.
102fn narrow(
103    plan: &mut Plan,
104    node: NodeRef,
105    read: &HashMap<u32, BTreeSet<u32>>,
106    moved: &mut HashMap<u32, Vec<u32>>,
107) {
108    let empty = BTreeSet::new();
109    match *plan.node(node) {
110        // A `VALUES` list keeps its columns on purpose rather than by omission. The rows are already
111        // in the plan, so narrowing one saves reading nothing and would cost a rewrite of every row.
112        // An aggregate keeps its own on purpose too: an aggregate nobody reads the result of is a
113        // shape the binder does not build, and dropping one would drop whatever it counted.
114        Node::Get { index, columns, .. } | Node::TableFunction { index, columns, .. } => {
115            let wanted = read.get(&index).unwrap_or(&empty);
116            let held = plan.field_list(columns).len();
117            if wanted.len() == held {
118                return;
119            }
120            let kept: Vec<_> = wanted
121                .iter()
122                .filter_map(|&at| plan.field_list(columns).get(at as usize).cloned())
123                .collect();
124            if kept.len() != wanted.len() {
125                return;
126            }
127            let narrowed = plan.add_fields(&kept);
128            match plan.node_mut(node) {
129                Node::Get { columns, .. } | Node::TableFunction { columns, .. } => {
130                    *columns = narrowed;
131                }
132                _ => unreachable!("the node was one of these two a moment ago"),
133            }
134            moved.insert(index, positions(wanted, held));
135        }
136        Node::Project { index, exprs, names, .. } => {
137            let wanted = read.get(&index).unwrap_or(&empty);
138            let held = plan.expr_list(exprs).len();
139            if wanted.len() == held {
140                return;
141            }
142            let kept: Vec<_> = wanted
143                .iter()
144                .filter_map(|&at| plan.expr_list(exprs).get(at as usize).copied())
145                .collect();
146            let labels: Vec<_> = wanted
147                .iter()
148                .filter_map(|&at| plan.name_list(names).get(at as usize).copied())
149                .collect();
150            if kept.len() != wanted.len() || labels.len() != wanted.len() {
151                return;
152            }
153            let narrowed = plan.add_expr_list(&kept);
154            let renamed = plan.add_name_list(&labels);
155            match plan.node_mut(node) {
156                Node::Project { exprs, names, .. } => {
157                    *exprs = narrowed;
158                    *names = renamed;
159                }
160                _ => unreachable!("the node was a projection a moment ago"),
161            }
162            moved.insert(index, positions(wanted, held));
163        }
164        _ => {}
165    }
166}
167
168/// Where each of `held` columns ends up once everything outside `wanted` is dropped.
169///
170/// The columns that stay keep the order the node had them in rather than the order the query named
171/// them in, which for a scan is the difference between reading a Parquet file forwards and seeking
172/// back and forth through it. The entries for the dropped columns are never read, since nothing
173/// binds to a column that was dropped for not being bound to.
174fn positions(wanted: &BTreeSet<u32>, held: usize) -> Vec<u32> {
175    let mut positions = vec![0; held];
176    for (new, &old) in wanted.iter().enumerate() {
177        positions[old as usize] = new as u32;
178    }
179    positions
180}
181
182/// The nodes this pass leaves alone, for either of the two reasons there are.
183///
184/// The first is that the node's columns are the query's own output, where narrowing would change
185/// the answer rather than the work. That is the root, and then down through every operator that
186/// passes its input's columns through. Both sides of a join are in it, since a join's output is both
187/// of them. The walk stops at the first operator that introduces columns of its own, because from
188/// there up those columns are that operator's business and not the answer's. The binder always puts
189/// a projection on top, so the scans this reaches are the ones that come out of [`Plan::parse`] in
190/// the plan tests.
191///
192/// The second is that the node feeds an operator that reads all of it without binding to any of it.
193/// A set operation is one: it lines its sides up by position and produces an index of its own, so a
194/// pass that went by what is bound would narrow both sides to nothing and answer a `UNION ALL` with
195/// no columns at all. A `DISTINCT` that names no columns is the other, and a plain `SELECT DISTINCT`
196/// is exactly that, because the binder writes the column list only for `DISTINCT ON`. Narrowing its
197/// input to what is bound above it leaves an operator deduplicating rows that have nothing left to
198/// tell them apart, so `SELECT count(*) FROM (SELECT DISTINCT region FROM sales)` comes back as 1 on
199/// any table with at least one row. Both sit here for what they are and not for where they sit.
200fn untouched(plan: &Plan, order: &[NodeRef]) -> HashSet<NodeRef> {
201    let mut found = HashSet::new();
202    let mut pending = vec![plan.root()];
203    while let Some(node) = pending.pop() {
204        if !found.insert(node) {
205            continue;
206        }
207        if plan.node(node).table_index().is_some() {
208            continue;
209        }
210        pending.extend(plan.node(node).children().into_iter().flatten());
211    }
212    for &node in order {
213        match *plan.node(node) {
214            Node::SetOp { left, right, .. } => {
215                found.insert(left);
216                found.insert(right);
217            }
218            Node::Distinct { input, on } if on.is_empty() => {
219                found.insert(input);
220            }
221            _ => {}
222        }
223    }
224    found
225}
226
227/// Every expression one node holds, operands included, each one once.
228fn expressions(plan: &Plan, node: NodeRef, found: &mut Found) {
229    match *plan.node(node) {
230        Node::Get { .. } | Node::Dummy | Node::SetOp { .. } | Node::CrossProduct { .. } => {}
231        Node::Values { rows, .. } => {
232            for &row in plan.row_list(rows) {
233                list(plan, row, found);
234            }
235        }
236        Node::TableFunction { args, .. } => list(plan, args, found),
237        Node::Filter { predicate, .. } => walk(plan, predicate, found),
238        Node::Project { exprs, .. } => list(plan, exprs, found),
239        Node::Aggregate { groups, aggregates, .. } => {
240            list(plan, groups, found);
241            list(plan, aggregates, found);
242        }
243        Node::Sort { keys, .. } | Node::TopN { keys, .. } => {
244            for key in plan.sort_key_list(keys) {
245                walk(plan, key.expr, found);
246            }
247        }
248        Node::Limit { .. } => {}
249        Node::Distinct { on, .. } => list(plan, on, found),
250        Node::Join { conditions, .. } => list(plan, conditions, found),
251    }
252}
253
254/// The expressions found so far, and which they are.
255///
256/// The arena shares operands, so the same expression is reached from as many places as refer to it.
257/// The set is what stops the walk going over a shared subtree once per reference, which on a `CASE`
258/// with a common condition is the difference between a walk and a blowup.
259#[derive(Debug, Default)]
260struct Found {
261    order: Vec<ExprRef>,
262    seen: HashSet<ExprRef>,
263}
264
265fn list(plan: &Plan, slice: Slice, found: &mut Found) {
266    for &expr in plan.expr_list(slice) {
267        walk(plan, expr, found);
268    }
269}
270
271/// One expression and everything under it.
272fn walk(plan: &Plan, expr: ExprRef, found: &mut Found) {
273    if !found.seen.insert(expr) {
274        return;
275    }
276    found.order.push(expr);
277    match *plan.expr(expr) {
278        Expr::Column(_) | Expr::Constant(_) => {}
279        Expr::Cast { input, .. } => walk(plan, input, found),
280        Expr::Compare { left, right, .. } => {
281            walk(plan, left, found);
282            walk(plan, right, found);
283        }
284        Expr::Conjunction { children, .. } => list(plan, children, found),
285        Expr::Function { args, .. } => list(plan, args, found),
286        Expr::Aggregate { args, filter, .. } => {
287            list(plan, args, found);
288            if let Some(filter) = filter {
289                walk(plan, filter, found);
290            }
291        }
292        Expr::Case { arms, otherwise } => {
293            for &Arm { when, then } in plan.arm_list(arms) {
294                walk(plan, when, found);
295                walk(plan, then, found);
296            }
297            if let Some(otherwise) = otherwise {
298                walk(plan, otherwise, found);
299            }
300        }
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    /// The plan a text prints as after pruning, which is what every assertion here reads.
309    fn pruned(text: &str) -> String {
310        let mut plan =
311            Plan::parse(text).unwrap_or_else(|error| panic!("{text} did not parse: {error}"));
312        prune(&mut plan);
313        plan.validate().unwrap_or_else(|error| panic!("{text} pruned to a bad plan: {error}"));
314        plan.to_string()
315    }
316
317    #[test]
318    fn a_scan_of_a_column_nobody_reads_loses_it() {
319        let before = "Project #1 [#0.0::INTEGER AS a]\n  Get memory.main.t AS t #0 [a::INTEGER, b::VARCHAR]\n";
320        let after = "Project #1 [#0.0::INTEGER AS a]\n  Get memory.main.t AS t #0 [a::INTEGER]\n";
321        assert_eq!(pruned(before), after);
322    }
323
324    #[test]
325    fn the_columns_that_stay_are_read_from_where_they_moved_to() {
326        // The one that can produce a wrong answer rather than a slow one. `c` was column two and is
327        // column zero afterwards, and a reader still pointing at two would read off the end.
328        let before = "Project #1 [#0.2::VARCHAR AS c]\n  Get memory.main.t AS t #0 [a::INTEGER, b::VARCHAR, c::VARCHAR]\n";
329        let after = "Project #1 [#0.0::VARCHAR AS c]\n  Get memory.main.t AS t #0 [c::VARCHAR]\n";
330        assert_eq!(pruned(before), after);
331    }
332
333    #[test]
334    fn a_column_read_only_by_a_filter_is_kept_and_one_read_by_nothing_is_not() {
335        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";
336        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";
337        assert_eq!(pruned(before), after);
338    }
339
340    #[test]
341    fn counting_the_rows_reads_no_columns_at_all() {
342        // What makes `SELECT count(*)` over a Parquet file a read of the footer and nothing else.
343        let before = "Aggregate #1 groups=[] aggregates=[count_star()::BIGINT]\n  Get memory.main.t AS t #0 [a::INTEGER, b::VARCHAR]\n";
344        let after = "Aggregate #1 groups=[] aggregates=[count_star()::BIGINT]\n  Get memory.main.t AS t #0 []\n";
345        assert_eq!(pruned(before), after);
346    }
347
348    #[test]
349    fn a_table_function_is_narrowed_the_same_way_a_table_is() {
350        let before = "Aggregate #1 groups=[] aggregates=[count_star()::BIGINT]\n  TableFunction read_parquet args=['f.parquet'::VARCHAR] #0 [a::INTEGER, b::VARCHAR]\n";
351        let after = "Aggregate #1 groups=[] aggregates=[count_star()::BIGINT]\n  TableFunction read_parquet args=['f.parquet'::VARCHAR] #0 []\n";
352        assert_eq!(pruned(before), after);
353    }
354
355    #[test]
356    fn each_side_of_a_join_is_narrowed_to_what_that_side_is_read_for() {
357        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";
358        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";
359        assert_eq!(pruned(before), after);
360    }
361
362    #[test]
363    fn a_scan_whose_columns_are_the_answer_is_left_alone() {
364        // Nothing above it names a column, and narrowing it would change the result rather than the
365        // work. The binder never builds this, and `Plan::parse` does.
366        let text = "Limit 1 offset 0\n  Get memory.main.t AS t #0 [a::INTEGER, b::VARCHAR]\n";
367        assert_eq!(pruned(text), text);
368    }
369
370    #[test]
371    fn a_scan_that_is_already_narrow_is_not_touched() {
372        let text = "Project #1 [#0.0::INTEGER AS a]\n  Get memory.main.t AS t #0 [a::INTEGER]\n";
373        assert_eq!(pruned(text), text);
374    }
375
376    #[test]
377    fn pruning_twice_is_pruning_once() {
378        let before = "Project #1 [#0.1::VARCHAR AS b]\n  Get memory.main.t AS t #0 [a::INTEGER, b::VARCHAR]\n";
379        let once = pruned(before);
380        assert_eq!(pruned(&once), once);
381    }
382
383    #[test]
384    fn the_columns_that_stay_keep_the_order_the_scan_had_them_in() {
385        // Not the order the query named them in. A reader that had to seek backwards through a
386        // Parquet file because the plan asked for column 40 before column 3 would read the same
387        // bytes in a worse order.
388        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";
389        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";
390        assert_eq!(pruned(before), after);
391    }
392
393    #[test]
394    fn a_column_only_a_sort_key_reads_is_kept() {
395        // `ORDER BY` on a column the query does not select. It never reaches the output and it is
396        // still read, so the walk has to go through the sort keys and not only the projection.
397        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";
398        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";
399        assert_eq!(pruned(before), after);
400    }
401
402    #[test]
403    fn a_column_buried_inside_an_expression_is_found_the_same_as_a_bare_one() {
404        // The leaves are what count, however many layers of function call and CASE are on top of
405        // them, which is what makes the expression walk recursive rather than a look at the roots.
406        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";
407        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";
408        assert_eq!(pruned(before), after);
409    }
410
411    #[test]
412    fn a_projection_in_the_middle_loses_the_expressions_nothing_above_it_reads() {
413        // The shape a view arrives in, and the one narrowing scans alone does nothing for. The
414        // inner projection is the view's `SELECT *`, and until it loses `a` and `c` the scan under
415        // it has to keep them, because the projection reads them.
416        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";
417        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";
418        assert_eq!(pruned(before), after);
419    }
420
421    #[test]
422    fn counting_the_rows_through_a_projection_reads_no_columns_either() {
423        // `SELECT count(*) FROM hits` where `hits` is a view over the file. Measured on the real
424        // ClickBench partition on server2, this is 3.39 seconds before and 0.009 seconds after.
425        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";
426        let after = "Aggregate #2 groups=[] aggregates=[count_star()::BIGINT]\n  Project #1 []\n    Get memory.main.t AS t #0 []\n";
427        assert_eq!(pruned(before), after);
428    }
429
430    #[test]
431    fn pruning_a_projection_twice_is_pruning_it_once() {
432        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";
433        let once = pruned(before);
434        assert_eq!(pruned(&once), once);
435    }
436
437    #[test]
438    fn neither_side_of_a_set_operation_is_narrowed() {
439        // A set operation lines its sides up by position and nothing binds to either side's index,
440        // so a pass that went by what is bound would narrow both of them to nothing and answer a
441        // `UNION ALL` with no columns at all.
442        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";
443        assert_eq!(pruned(text), text);
444    }
445
446    #[test]
447    fn a_distinct_that_names_no_columns_keeps_the_ones_it_is_distinct_on() {
448        // `SELECT count(*) FROM (SELECT DISTINCT b FROM t)`. The binder writes a column list only
449        // for `DISTINCT ON`, so a plain one names nothing and is distinct on everything under it.
450        // Narrowed to what is bound above, the projection loses `b`, and an operator deduplicating
451        // rows with no columns in them returns one row for any input that had any, which turns the
452        // count into 1 on every table in the world.
453        let text = "Aggregate #2 groups=[] aggregates=[count_star()::BIGINT]\n  Distinct on=[]\n    Project #1 [#0.1::VARCHAR AS b]\n      Get memory.main.t AS t #0 [a::INTEGER, b::VARCHAR]\n";
454        let after = "Aggregate #2 groups=[] aggregates=[count_star()::BIGINT]\n  Distinct on=[]\n    Project #1 [#0.0::VARCHAR AS b]\n      Get memory.main.t AS t #0 [b::VARCHAR]\n";
455        assert_eq!(pruned(text), after);
456    }
457
458    #[test]
459    fn a_distinct_on_named_columns_narrows_underneath_like_anything_else() {
460        // `DISTINCT ON` binds to what it reads, so the ordinary rule applies and `c` goes. The two
461        // cases are the same node and they have to be told apart by whether the list is empty.
462        let before = "Project #2 [#1.0::VARCHAR AS b]\n  Distinct on=[#1.0::VARCHAR]\n    Project #1 [#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";
463        let after = "Project #2 [#1.0::VARCHAR AS b]\n  Distinct on=[#1.0::VARCHAR]\n    Project #1 [#0.0::VARCHAR AS b]\n      Get memory.main.t AS t #0 [b::VARCHAR]\n";
464        assert_eq!(pruned(before), after);
465    }
466
467    #[test]
468    fn a_values_list_keeps_its_columns_even_when_nothing_reads_them() {
469        // On purpose rather than by omission. The rows are already in the plan, so narrowing one
470        // saves reading nothing and would cost a rewrite of every row.
471        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";
472        assert_eq!(pruned(text), text);
473    }
474}