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