1use std::collections::BTreeSet;
10use uqa_sql::{
11 catalog::{analysis::AnalysisCatalog, resolution::RelationNameResolution},
12 plan::{
13 source_projection::{ColumnPrune, SourceProjection},
14 QueryBlockPlan, SourcePlan,
15 },
16 semantics::volatility::{expr_contains_volatile_function, VolatilityCatalog},
17 semantics::{
18 expr_contains_subquery, DOC_ID_COLUMN, META_DOC_ID_COLUMN, META_QUALIFIER,
19 META_SCORE_COLUMN, SCORE_COLUMN,
20 },
21 SQLError, ScalarExpr,
22};
23
24#[cfg(test)]
25mod tests;
26
27#[derive(Clone, Copy)]
28pub struct ColumnPruneContext<'a> {
29 pub catalog: &'a dyn AnalysisCatalog,
30 pub resolution: &'a RelationNameResolution,
31 pub volatility: &'a dyn VolatilityCatalog,
32 pub is_visible_cte: &'a dyn Fn(&str) -> bool,
33}
34
35fn has_window(projections: &[uqa_sql::plan::ProjectionPlan]) -> bool {
36 projections
37 .iter()
38 .any(|projection| uqa_sql::semantics::windows::expr_has_window(&projection.expr))
39}
40
41pub fn column_prune_for_stmt(
42 context: ColumnPruneContext<'_>,
43 stmt: &QueryBlockPlan,
44 from: &SourcePlan,
45) -> Result<Option<ColumnPrune>, SQLError> {
46 column_prune_for_stmt_with_filter(context, stmt, from, stmt.r#where.as_ref())
47}
48
49pub fn column_prune_for_stmt_with_filter(
58 context: ColumnPruneContext<'_>,
59 stmt: &QueryBlockPlan,
60 from: &SourcePlan,
61 filter: Option<&ScalarExpr>,
62) -> Result<Option<ColumnPrune>, SQLError> {
63 let catalog = context.catalog;
64 let resolution = context.resolution;
65 let requires_full_projection = source_contains_join_alias(from)
66 || has_window(&stmt.projections)
67 || stmt.projections.iter().any(|projection| {
68 matches!(projection.expr, ScalarExpr::Star)
69 || expr_contains_subquery(&projection.expr)
70 || expr_contains_volatile_function(context.volatility, &projection.expr)
71 });
72
73 let mut qualifiers = Vec::new();
74 collect_from_qualifiers(from, &mut qualifiers);
75 if qualifiers.is_empty() {
76 return Ok(None);
77 }
78
79 let metadata_binding =
80 single_local_table_metadata_binding(catalog, resolution, from, context.is_visible_cte)?;
81 let scope = PruneScope {
82 qualifiers: &qualifiers,
83 metadata_qualifier: metadata_binding
84 .as_ref()
85 .map(|binding| binding.qualifier.as_str()),
86 legacy_doc_id: metadata_binding
87 .as_ref()
88 .is_some_and(|binding| binding.legacy_doc_id),
89 legacy_score: metadata_binding
90 .as_ref()
91 .is_some_and(|binding| binding.legacy_score),
92 };
93 let mut prune: ColumnPrune = qualifiers
94 .iter()
95 .map(|qualifier| {
96 (
97 qualifier.clone(),
98 if requires_full_projection {
99 SourceProjection::retaining_all()
100 } else {
101 SourceProjection::default()
102 },
103 )
104 })
105 .collect();
106 let mut valid = true;
107 collect_from_prune_columns(from, scope, &mut prune, &mut valid);
108 collect_join_binding_prune_columns(catalog, resolution, from, &mut prune)?;
109 collect_query_block_prune_columns(stmt, filter, scope, &mut prune, &mut valid);
110 let metadata_requested = prune
111 .values()
112 .any(|projection| !projection.metadata().is_empty());
113 if requires_full_projection {
114 return Ok(metadata_requested.then_some(prune));
115 }
116 if !valid {
117 if metadata_requested {
118 for projection in prune.values_mut() {
119 projection.retain_all();
120 }
121 return Ok(Some(prune));
122 }
123 return Ok(None);
124 }
125 Ok(Some(prune))
126}
127
128#[derive(Clone, Copy)]
129struct PruneScope<'a> {
130 qualifiers: &'a [String],
131 metadata_qualifier: Option<&'a str>,
132 legacy_doc_id: bool,
133 legacy_score: bool,
134}
135
136fn collect_query_block_prune_columns(
137 stmt: &QueryBlockPlan,
138 filter: Option<&ScalarExpr>,
139 scope: PruneScope<'_>,
140 prune: &mut ColumnPrune,
141 valid: &mut bool,
142) {
143 let expressions = stmt
144 .projections
145 .iter()
146 .map(|projection| &projection.expr)
147 .chain(filter)
148 .chain(stmt.group_by.iter())
149 .chain(stmt.grouping_sets.iter().flatten())
150 .chain(stmt.having.iter())
151 .chain(stmt.order_by.iter().map(|order| &order.expr))
152 .chain(stmt.distinct_on.iter())
153 .chain(
154 stmt.windows
155 .iter()
156 .flat_map(|window| window.spec.expressions()),
157 );
158 for expression in expressions {
159 collect_expr_prune_columns(expression, scope, prune, valid);
160 }
161}
162
163struct LocalTableMetadataBinding {
164 qualifier: String,
165 legacy_doc_id: bool,
166 legacy_score: bool,
167}
168
169fn single_local_table_metadata_binding(
170 catalog: &dyn AnalysisCatalog,
171 resolution: &RelationNameResolution,
172 source: &SourcePlan,
173 is_visible_cte: &dyn Fn(&str) -> bool,
174) -> Result<Option<LocalTableMetadataBinding>, SQLError> {
175 fn collect(
176 catalog: &dyn AnalysisCatalog,
177 resolution: &RelationNameResolution,
178 source: &SourcePlan,
179 is_visible_cte: &dyn Fn(&str) -> bool,
180 relations: &mut BTreeSet<(String, String)>,
181 ) -> Result<(), SQLError> {
182 match source {
183 SourcePlan::Table {
184 name,
185 qualifier,
186 alias,
187 ..
188 } => {
189 if !is_visible_cte(name) {
190 if let Some(name) = catalog.table_name_resolved(resolution, name)? {
191 relations.insert((alias.as_deref().unwrap_or(qualifier).to_string(), name));
192 }
193 }
194 }
195 SourcePlan::Join { left, right, .. } => {
196 collect(catalog, resolution, left, is_visible_cte, relations)?;
197 collect(catalog, resolution, right, is_visible_cte, relations)?;
198 }
199 SourcePlan::Values { .. }
200 | SourcePlan::Function { .. }
201 | SourcePlan::FunctionGroup { .. }
202 | SourcePlan::Subquery { .. } => {}
203 }
204 Ok(())
205 }
206 if source_contains_join_alias(source) {
207 return Ok(None);
208 }
209 let mut relations = BTreeSet::new();
210 collect(catalog, resolution, source, is_visible_cte, &mut relations)?;
211 let Some((qualifier, name)) = relations.pop_first() else {
212 return Ok(None);
213 };
214 if !relations.is_empty() {
215 return Ok(None);
216 }
217 let columns = &catalog
218 .table_resolved(resolution, &name)?
219 .ok_or_else(|| SQLError::UnknownTable(name.clone()))?
220 .columns;
221 Ok(Some(LocalTableMetadataBinding {
222 qualifier,
223 legacy_doc_id: !columns.iter().any(|column| column.name == DOC_ID_COLUMN),
224 legacy_score: !columns.iter().any(|column| column.name == SCORE_COLUMN),
225 }))
226}
227
228fn source_contains_join_alias(source: &SourcePlan) -> bool {
229 match source {
230 SourcePlan::Join {
231 left, right, alias, ..
232 } => {
233 alias.is_some() || source_contains_join_alias(left) || source_contains_join_alias(right)
234 }
235 SourcePlan::Table { .. }
236 | SourcePlan::Values { .. }
237 | SourcePlan::Function { .. }
238 | SourcePlan::FunctionGroup { .. }
239 | SourcePlan::Subquery { .. } => false,
240 }
241}
242
243fn collect_join_binding_prune_columns(
244 catalog: &dyn AnalysisCatalog,
245 resolution: &RelationNameResolution,
246 from: &SourcePlan,
247 prune: &mut ColumnPrune,
248) -> Result<(), SQLError> {
249 match from {
250 SourcePlan::Join {
251 left,
252 right,
253 using,
254 natural,
255 ..
256 } => {
257 collect_join_binding_prune_columns(catalog, resolution, left, prune)?;
258 collect_join_binding_prune_columns(catalog, resolution, right, prune)?;
259 if let Some(using) = using {
260 for column in &using.columns {
261 add_column_to_source_prune(left, column, prune);
262 add_column_to_source_prune(right, column, prune);
263 }
264 }
265 if *natural {
266 add_all_source_columns_to_prune(catalog, resolution, left, prune)?;
267 add_all_source_columns_to_prune(catalog, resolution, right, prune)?;
268 }
269 }
270 SourcePlan::Table { .. }
271 | SourcePlan::Values { .. }
272 | SourcePlan::Function { .. }
273 | SourcePlan::FunctionGroup { .. }
274 | SourcePlan::Subquery { .. } => {}
275 }
276 Ok(())
277}
278
279fn add_column_to_source_prune(source: &SourcePlan, column: &str, prune: &mut ColumnPrune) {
280 let mut qualifiers = Vec::new();
281 collect_from_qualifiers(source, &mut qualifiers);
282 for qualifier in qualifiers {
283 if let Some(columns) = prune.get_mut(&qualifier) {
284 columns.insert(column.to_string());
285 }
286 }
287}
288
289#[expect(
290 clippy::too_many_lines,
291 reason = "preserves SELECT schema and row identity"
292)]
293fn add_all_source_columns_to_prune(
294 catalog: &dyn AnalysisCatalog,
295 resolution: &RelationNameResolution,
296 source: &SourcePlan,
297 prune: &mut ColumnPrune,
298) -> Result<(), SQLError> {
299 match source {
300 SourcePlan::Table {
301 name,
302 qualifier,
303 alias,
304 column_aliases,
305 ..
306 } => {
307 let qualifier = alias.as_deref().unwrap_or(qualifier);
308 match catalog.table_resolved(resolution, name)? {
309 Some(table) => {
310 if let Some(columns) = prune.get_mut(qualifier) {
311 columns.extend(table.columns.iter().enumerate().map(
312 |(position, column)| {
313 column_aliases
314 .get(position)
315 .cloned()
316 .unwrap_or_else(|| column.name.clone())
317 },
318 ));
319 }
320 }
321 None => {
322 prune.remove(qualifier);
326 }
327 }
328 }
329 SourcePlan::Join { left, right, .. } => {
330 add_all_source_columns_to_prune(catalog, resolution, left, prune)?;
331 add_all_source_columns_to_prune(catalog, resolution, right, prune)?;
332 }
333 SourcePlan::Values {
334 rows,
335 alias,
336 column_aliases,
337 ..
338 } => {
339 let Some(columns) = alias.as_ref().and_then(|alias| prune.get_mut(alias)) else {
340 return Ok(());
341 };
342 if column_aliases.is_empty() {
343 columns.extend(
344 (0..rows.first().map_or(0, Vec::len))
345 .map(|index| format!("column{}", index + 1)),
346 );
347 } else {
348 columns.extend(column_aliases.iter().cloned());
349 }
350 }
351 SourcePlan::Function {
352 name,
353 output_name,
354 args,
355 alias,
356 column_aliases,
357 ordinality,
358 ..
359 } => {
360 let qualifier = alias.as_ref().unwrap_or(output_name);
361 let Some(columns) = prune.get_mut(qualifier) else {
362 return Ok(());
363 };
364 let routine_columns = uqa_sql::binding::catalog_sources::user_function_output_columns(
365 catalog, resolution, name,
366 )?;
367 columns.extend(routine_columns.map_or_else(
368 || {
369 uqa_sql::semantics::table_function_empty_schema(
370 name,
371 output_name,
372 alias.as_deref(),
373 column_aliases,
374 args.len(),
375 *ordinality,
376 )
377 },
378 |base| {
379 uqa_sql::semantics::apply_table_function_aliases(
380 base,
381 column_aliases,
382 *ordinality,
383 )
384 },
385 ));
386 }
387 SourcePlan::FunctionGroup {
388 functions,
389 alias,
390 column_aliases,
391 ordinality,
392 } => {
393 let Some(qualifier) = alias
394 .as_ref()
395 .or_else(|| functions.first().map(|function| &function.output_name))
396 else {
397 return Ok(());
398 };
399 let Some(columns) = prune.get_mut(qualifier) else {
400 return Ok(());
401 };
402 let mut group_columns = Vec::new();
403 for function in functions {
404 let routine_columns =
405 uqa_sql::binding::catalog_sources::user_function_output_columns(
406 catalog,
407 resolution,
408 &function.name,
409 )?;
410 group_columns.extend(routine_columns.map_or_else(
411 || {
412 uqa_sql::semantics::table_function_empty_schema(
413 &function.name,
414 &function.output_name,
415 None,
416 &function.column_aliases,
417 function.args.len(),
418 false,
419 )
420 },
421 |base| {
422 uqa_sql::semantics::apply_table_function_aliases(
423 base,
424 &function.column_aliases,
425 false,
426 )
427 },
428 ));
429 }
430 if *ordinality {
431 group_columns.push("ordinality".into());
432 }
433 for (column, alias) in group_columns.iter_mut().zip(column_aliases) {
434 column.clone_from(alias);
435 }
436 columns.extend(group_columns);
437 }
438 SourcePlan::Subquery {
439 body,
440 alias,
441 column_aliases,
442 } => {
443 let Some(columns) = alias.as_ref().and_then(|alias| prune.get_mut(alias)) else {
444 return Ok(());
445 };
446 if column_aliases.is_empty() {
447 columns.extend(
448 uqa_sql::semantics::query_plan_output_columns(body).unwrap_or_default(),
449 );
450 } else {
451 columns.extend(column_aliases.iter().cloned());
452 }
453 }
454 }
455 Ok(())
456}
457
458pub use uqa_sql::semantics::collect_from_qualifiers;
459
460fn collect_from_prune_columns(
461 from: &SourcePlan,
462 scope: PruneScope<'_>,
463 prune: &mut ColumnPrune,
464 valid: &mut bool,
465) {
466 match from {
467 SourcePlan::Join {
468 left, right, on, ..
469 } => {
470 collect_from_prune_columns(left, scope, prune, valid);
471 collect_from_prune_columns(right, scope, prune, valid);
472 if let Some(on) = on.as_ref() {
473 collect_expr_prune_columns(on, scope, prune, valid);
474 }
475 }
476 SourcePlan::Values { rows, .. } => {
477 for row in rows {
478 for expr in row {
479 collect_expr_prune_columns(expr, scope, prune, valid);
480 }
481 }
482 }
483 SourcePlan::Function { args, .. } => {
484 for expr in args {
485 collect_expr_prune_columns(expr, scope, prune, valid);
486 }
487 }
488 SourcePlan::FunctionGroup { functions, .. } => {
489 for function in functions {
490 for expr in &function.args {
491 collect_expr_prune_columns(expr, scope, prune, valid);
492 }
493 }
494 }
495 SourcePlan::Subquery { .. } => {
496 *valid = false;
497 }
498 SourcePlan::Table { .. } => {}
499 }
500}
501
502#[expect(
503 clippy::too_many_lines,
504 reason = "preserves SELECT schema and row identity"
505)]
506fn collect_expr_prune_columns(
507 expr: &ScalarExpr,
508 scope: PruneScope<'_>,
509 prune: &mut ColumnPrune,
510 valid: &mut bool,
511) {
512 match expr {
513 ScalarExpr::Column(column) => {
514 for qualifier in scope.qualifiers {
515 if qualifier.eq_ignore_ascii_case(column) {
516 let Some(source) = prune.get_mut(qualifier) else {
517 *valid = false;
518 return;
519 };
520 source.retain_all();
521 }
522 }
523 if let Some(qualifier) = scope.metadata_qualifier {
524 let metadata = match column.as_str() {
525 DOC_ID_COLUMN if scope.legacy_doc_id => Some(true),
526 SCORE_COLUMN if scope.legacy_score => Some(false),
527 _ => None,
528 };
529 if let Some(doc_id) = metadata {
530 let Some(source) = prune.get_mut(qualifier) else {
531 *valid = false;
532 return;
533 };
534 source.insert(column.clone());
535 if doc_id {
536 source.metadata_mut().request_doc_id();
537 } else {
538 source.metadata_mut().request_score();
539 }
540 return;
541 }
542 }
543 for qualifier in scope.qualifiers {
544 if let Some(columns) = prune.get_mut(qualifier) {
545 columns.insert(column.clone());
546 }
547 }
548 }
549 ScalarExpr::QualifiedColumn {
550 qualifier, column, ..
551 } => {
552 if scope.metadata_qualifier == Some(qualifier.as_str()) {
553 let metadata = match column.as_str() {
554 DOC_ID_COLUMN if scope.legacy_doc_id => Some(true),
555 SCORE_COLUMN if scope.legacy_score => Some(false),
556 _ => None,
557 };
558 if let Some(doc_id) = metadata {
559 let Some(source) = prune.get_mut(qualifier) else {
560 *valid = false;
561 return;
562 };
563 source.insert(column.clone());
564 if doc_id {
565 source.metadata_mut().request_doc_id();
566 } else {
567 source.metadata_mut().request_score();
568 }
569 return;
570 }
571 }
572 if qualifier == META_QUALIFIER && !prune.contains_key(META_QUALIFIER) {
573 let Some(source) = scope
574 .metadata_qualifier
575 .and_then(|source| prune.get_mut(source))
576 else {
577 *valid = false;
578 return;
579 };
580 match column.as_str() {
581 META_DOC_ID_COLUMN => source.metadata_mut().request_doc_id(),
582 META_SCORE_COLUMN => source.metadata_mut().request_score(),
583 _ => *valid = false,
584 }
585 return;
586 }
587 if let Some(columns) = prune.get_mut(qualifier) {
588 columns.insert(column.clone());
589 } else {
590 *valid = false;
591 }
592 }
593 ScalarExpr::Literal(_) | ScalarExpr::TypedLiteral { .. } | ScalarExpr::Param(_) => {}
594 ScalarExpr::Default
595 | ScalarExpr::Star
596 | ScalarExpr::Position(_)
597 | ScalarExpr::InternalColumn(_)
598 | ScalarExpr::QualifiedStar(_)
599 | ScalarExpr::ScalarSubquery(_)
600 | ScalarExpr::Exists { .. } => {
601 *valid = false;
602 }
603 ScalarExpr::Array(items)
604 | ScalarExpr::Row(items)
605 | ScalarExpr::CompositeRow { items, .. }
606 | ScalarExpr::And(items)
607 | ScalarExpr::Or(items) => {
608 for item in items {
609 collect_expr_prune_columns(item, scope, prune, valid);
610 }
611 }
612 ScalarExpr::Func {
613 name,
614 args,
615 order_by,
616 filter,
617 ..
618 } => {
619 if !(name.eq_ignore_ascii_case("count")
621 && matches!(args.as_slice(), [ScalarExpr::Star]))
622 {
623 for arg in args {
624 collect_expr_prune_columns(arg, scope, prune, valid);
625 }
626 }
627 for order in order_by {
628 collect_expr_prune_columns(&order.expr, scope, prune, valid);
629 }
630 if let Some(filter) = filter.as_ref() {
631 collect_expr_prune_columns(filter, scope, prune, valid);
632 }
633 }
634 ScalarExpr::Binary { lhs, rhs, .. } => {
635 collect_expr_prune_columns(lhs, scope, prune, valid);
636 collect_expr_prune_columns(rhs, scope, prune, valid);
637 }
638 ScalarExpr::Not(inner)
639 | ScalarExpr::UnaryMinus(inner)
640 | ScalarExpr::IsNull { expr: inner, .. }
641 | ScalarExpr::Cast { expr: inner, .. } => {
642 collect_expr_prune_columns(inner, scope, prune, valid);
643 }
644 ScalarExpr::Between { expr, low, high } => {
645 collect_expr_prune_columns(expr, scope, prune, valid);
646 collect_expr_prune_columns(low, scope, prune, valid);
647 collect_expr_prune_columns(high, scope, prune, valid);
648 }
649 ScalarExpr::InList { expr, list, .. } => {
650 collect_expr_prune_columns(expr, scope, prune, valid);
651 for item in list {
652 collect_expr_prune_columns(item, scope, prune, valid);
653 }
654 }
655 ScalarExpr::WindowCall {
656 args, spec, filter, ..
657 } => {
658 for argument in args {
659 collect_expr_prune_columns(argument, scope, prune, valid);
660 }
661 if let Some(filter) = filter {
662 collect_expr_prune_columns(filter, scope, prune, valid);
663 }
664 for expression in &spec.partition_by {
665 collect_expr_prune_columns(expression, scope, prune, valid);
666 }
667 for order in &spec.order_by {
668 collect_expr_prune_columns(&order.expr, scope, prune, valid);
669 }
670 if let Some(frame) = &spec.frame {
671 for bound in [&frame.start, &frame.end] {
672 if let uqa_sql::ScalarFrameBound::Preceding(expression)
673 | uqa_sql::ScalarFrameBound::Following(expression) = bound
674 {
675 collect_expr_prune_columns(expression, scope, prune, valid);
676 }
677 }
678 }
679 *valid = false;
680 }
681 ScalarExpr::InSubquery { expr, .. } => {
682 collect_expr_prune_columns(expr, scope, prune, valid);
683 *valid = false;
684 }
685 ScalarExpr::Case {
686 base,
687 when,
688 else_branch,
689 } => {
690 if let Some(base) = base.as_ref() {
691 collect_expr_prune_columns(base, scope, prune, valid);
692 }
693 for (cond, result) in when {
694 collect_expr_prune_columns(cond, scope, prune, valid);
695 collect_expr_prune_columns(result, scope, prune, valid);
696 }
697 if let Some(else_branch) = else_branch.as_ref() {
698 collect_expr_prune_columns(else_branch, scope, prune, valid);
699 }
700 }
701 }
702}