Skip to main content

uqa_planner/
column_pruning.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Required source columns and row metadata for physical query projection.
8
9use 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
46/// Compute the document projection for `stmt` while treating `filter` as the
47/// only predicate that remains to be evaluated by the relational pipeline.
48/// Accelerated retrieval consumes its search predicate before constructing a
49/// scored document source, so its field
50/// arguments are index dependencies rather than row-materialization
51/// dependencies. Callers that have executed retrieval pass only the residual
52/// predicate here; ordinary scans retain the statement's original `WHERE` via
53/// [`column_prune_for_stmt`].
54pub 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                    // A CTE, view, or external relation owns its row type
315                    // outside the local table catalog. Omitting its prune
316                    // entry retains that source's complete schema.
317                    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}