Skip to main content

pgevolve_core/parse/
ast_canon.rs

1//! AST canonicalization pass for view and materialized view bodies.
2//!
3//! Runs after the source parser produces a provisional Catalog with
4//! views/MVs whose `body_canonical` is the empty sentinel and
5//! `body_dependencies` is empty. For each view and MV:
6//!
7//!  1. Calls [`NormalizedBody::from_sql`] on `raw_body` to fill
8//!     `body_canonical`.
9//!  2. Walks the body AST to extract [`DepEdge`]s with
10//!     [`DepSource::AstExtracted`].
11//!  3. Resolves each referenced relation against the provisional Catalog.
12//!     Unresolved → [`AstCanonError::UnresolvedReference`].
13//!  4. Fills `columns` from the SELECT target list when the alias list was
14//!     absent (PG's column-naming algorithm: explicit alias → rightmost
15//!     `ColumnRef` name → `"?column?"` fallback).
16//!
17//! No Postgres, no network, no Docker.
18
19use std::collections::BTreeSet;
20
21use crate::identifier::{Identifier, QualifiedName};
22use crate::ir::catalog::Catalog;
23use crate::ir::column_type::ColumnType;
24use crate::ir::index::IndexParent;
25use crate::ir::view::ViewColumn;
26use crate::parse::normalize_body::NormalizedBody;
27use crate::plan::edges::{DepEdge, DepSource, NodeId};
28
29/// Errors raised by the AST canonicalization pass.
30#[derive(Debug, thiserror::Error)]
31pub enum AstCanonError {
32    /// `pg_query` or `NormalizedBody` failed to parse the body.
33    #[error("view {view}: failed to canonicalize body: {reason}")]
34    NormalizeFailed {
35        /// Qualified name of the view.
36        view: String,
37        /// Underlying error message.
38        reason: String,
39    },
40    /// The body AST references a relation that was not declared in source.
41    #[error("view {view}: references {object} which is not declared in source")]
42    UnresolvedReference {
43        /// Qualified name of the view.
44        view: String,
45        /// Qualified name of the missing object.
46        object: String,
47    },
48}
49
50/// Fills `body_canonical`, `body_dependencies`, and (when needed) `columns`
51/// for all views and materialized views in `catalog`. Mutates in place.
52///
53/// Errors on the first unresolvable reference (fail-fast, like the existing
54/// `ast_resolution` pass).
55pub fn canonicalize_view_bodies(catalog: &mut Catalog) -> Result<(), AstCanonError> {
56    // Build the set of known relations up front. We snapshot from the catalog
57    // once; the catalog is not mutated between view passes.
58    let known = KnownObjects::from_catalog(catalog);
59
60    // --- Regular views ---
61    for i in 0..catalog.views.len() {
62        let raw_body = catalog.views[i].raw_body.clone();
63        let qname = catalog.views[i].qname.clone();
64        let qname_str = qname.to_string();
65        let column_aliases: Vec<String> = catalog.views[i]
66            .columns
67            .iter()
68            .map(|c| c.name.as_str().to_string())
69            .collect();
70        let has_explicit_columns = !column_aliases.is_empty();
71
72        // When the CREATE VIEW had an explicit `(col1, col2, …)` list, PG
73        // applies those aliases to the SELECT target list internally —
74        // pg_get_viewdef returns `SELECT a AS col1, b AS col2 FROM …`. The
75        // source-side raw_body, however, is the body as authored
76        // (`SELECT a, b FROM …`). To make the two canonical forms byte-equal,
77        // splice the aliases into the source body before canonicalization.
78        let effective_body = if has_explicit_columns {
79            apply_view_column_aliases(&raw_body, &column_aliases)
80                .unwrap_or_else(|| raw_body.clone())
81        } else {
82            raw_body.clone()
83        };
84
85        let normalized = NormalizedBody::from_sql(&effective_body).map_err(|e| {
86            AstCanonError::NormalizeFailed {
87                view: qname_str.clone(),
88                reason: e.to_string(),
89            }
90        })?;
91
92        let (deps, derived_columns) = walk_body_ast(&raw_body, &qname, &known)?;
93
94        catalog.views[i].body_canonical = normalized;
95        catalog.views[i].body_dependencies = deps;
96        // Only fill columns from the SELECT target list when no explicit alias
97        // list was provided in the CREATE VIEW statement. When an explicit alias
98        // list IS present the parser already populated `columns` (names only,
99        // type unresolved), so resolve their placeholder types in place.
100        if has_explicit_columns {
101            resolve_explicit_column_types(&mut catalog.views[i].columns);
102        } else {
103            catalog.views[i].columns = derived_columns;
104        }
105    }
106
107    // --- Materialized views ---
108    for i in 0..catalog.materialized_views.len() {
109        let raw_body = catalog.materialized_views[i].raw_body.clone();
110        let qname = catalog.materialized_views[i].qname.clone();
111        let has_explicit_columns = !catalog.materialized_views[i].columns.is_empty();
112
113        let normalized =
114            NormalizedBody::from_sql(&raw_body).map_err(|e| AstCanonError::NormalizeFailed {
115                view: qname.to_string(),
116                reason: e.to_string(),
117            })?;
118
119        let (deps, derived_columns) = walk_body_ast(&raw_body, &qname, &known)?;
120
121        catalog.materialized_views[i].body_canonical = normalized;
122        catalog.materialized_views[i].body_dependencies = deps;
123        if has_explicit_columns {
124            resolve_explicit_column_types(&mut catalog.materialized_views[i].columns);
125        } else {
126            catalog.materialized_views[i].columns = derived_columns;
127        }
128    }
129
130    Ok(())
131}
132
133/// Promote `IndexParent::Table(q)` to `IndexParent::Mv(q)` for every source-
134/// side index whose parent qname actually belongs to a materialized view.
135///
136/// The source parser (`parse/builder/index_stmt.rs`) hardcodes
137/// `IndexParent::Table` for all `CREATE INDEX` statements because at parse
138/// time it does not yet know whether the relation name refers to a table or an
139/// MV. This pass runs after both the indexes *and* the MVs are in the catalog
140/// and corrects the parent variant.
141///
142/// Consequences of the promotion:
143/// - `mv_has_unique_index` (in `plan/rewrite/refresh_mv_concurrently`) matches
144///   `IndexParent::Mv` — REFRESH CONCURRENTLY now fires.
145/// - `build_create_graph` routes `IndexParent::Mv(q)` to `NodeId::Mv(q)`, so
146///   the dep-graph correctly orders `CREATE MATERIALIZED VIEW` before
147///   `CREATE INDEX` on that MV.
148/// - The `mv-no-unique-index` lint no longer false-positives for source-
149///   defined MVs with source-defined unique indexes.
150pub fn promote_mv_index_parents(catalog: &mut Catalog) {
151    let mv_qnames: BTreeSet<QualifiedName> = catalog
152        .materialized_views
153        .iter()
154        .map(|mv| mv.qname.clone())
155        .collect();
156    if mv_qnames.is_empty() {
157        return;
158    }
159    for idx in &mut catalog.indexes {
160        if let IndexParent::Table(qname) = &idx.on
161            && mv_qnames.contains(qname)
162        {
163            idx.on = IndexParent::Mv(qname.clone());
164        }
165    }
166}
167
168/// Snapshot of the qualified names known in the catalog, used for reference
169/// resolution during AST walking.
170struct KnownObjects {
171    /// All table and view qualified names (both are valid relation references).
172    relations: BTreeSet<QualifiedName>,
173}
174
175impl KnownObjects {
176    fn from_catalog(catalog: &Catalog) -> Self {
177        let mut relations = BTreeSet::new();
178        for t in &catalog.tables {
179            relations.insert(t.qname.clone());
180        }
181        for v in &catalog.views {
182            relations.insert(v.qname.clone());
183        }
184        for m in &catalog.materialized_views {
185            relations.insert(m.qname.clone());
186        }
187        Self { relations }
188    }
189
190    fn has_relation(&self, qname: &QualifiedName) -> bool {
191        self.relations.contains(qname)
192    }
193}
194
195/// Walk the parsed body AST. Returns `(dep_edges, derived_columns)`.
196///
197/// `derived_columns` are derived from the top-level SELECT target list via
198/// PG's column-naming algorithm; the caller decides whether to apply them.
199fn walk_body_ast(
200    raw_body: &str,
201    view_qname: &QualifiedName,
202    known: &KnownObjects,
203) -> Result<(Vec<DepEdge>, Vec<ViewColumn>), AstCanonError> {
204    let qname_str = view_qname.to_string();
205    let parsed = pg_query::parse(raw_body).map_err(|e| AstCanonError::NormalizeFailed {
206        view: qname_str.clone(),
207        reason: e.to_string(),
208    })?;
209
210    let mut deps: Vec<DepEdge> = Vec::new();
211    let mut columns: Vec<ViewColumn> = Vec::new();
212
213    let Some(root) = parsed.protobuf.stmts.first() else {
214        return Ok((deps, columns));
215    };
216
217    let Some(node) = &root.stmt else {
218        return Ok((deps, columns));
219    };
220
221    walk_node(
222        node,
223        view_qname,
224        known,
225        &mut deps,
226        &mut columns,
227        /* is_top_level_select */ true,
228        &qname_str,
229    )?;
230
231    // Stable dedup: sort then dedup (DepEdge derives Ord).
232    deps.sort();
233    deps.dedup();
234
235    Ok((deps, columns))
236}
237
238/// Recursive AST walker over a [`pg_query::protobuf::Node`].
239///
240/// `is_top_level_select` is `true` only for the outermost `SelectStmt` —
241/// this is the one whose target list produces the view's column names.
242fn walk_node(
243    node: &pg_query::protobuf::Node,
244    view_qname: &QualifiedName,
245    known: &KnownObjects,
246    deps: &mut Vec<DepEdge>,
247    columns: &mut Vec<ViewColumn>,
248    is_top_level_select: bool,
249    qname_str: &str,
250) -> Result<(), AstCanonError> {
251    use pg_query::NodeEnum as N;
252    let Some(inner) = &node.node else {
253        return Ok(());
254    };
255
256    match inner {
257        // ------------------------------------------------------------------ //
258        // SELECT: capture target list when top-level, then recurse.
259        // ------------------------------------------------------------------ //
260        N::SelectStmt(sel) => {
261            if is_top_level_select {
262                for target in &sel.target_list {
263                    if let Some(col) = derive_column_name(target) {
264                        columns.push(col);
265                    }
266                }
267            }
268            // Recurse into FROM clauses (tables, subqueries, joins).
269            for from in &sel.from_clause {
270                walk_node(from, view_qname, known, deps, columns, false, qname_str)?;
271            }
272            // WHERE clause may reference functions that in turn reference relations
273            // (not extracted in v0.2; just recurse for structural completeness).
274            if let Some(wc) = &sel.where_clause {
275                walk_node(wc, view_qname, known, deps, columns, false, qname_str)?;
276            }
277            // UNION / INTERSECT / EXCEPT: recurse into both branches.
278            if let Some(larg) = &sel.larg {
279                walk_select(larg, view_qname, known, deps, qname_str)?;
280            }
281            if let Some(rarg) = &sel.rarg {
282                walk_select(rarg, view_qname, known, deps, qname_str)?;
283            }
284            // WITH (CTE) clause.
285            if let Some(with) = &sel.with_clause {
286                for cte in &with.ctes {
287                    walk_node(cte, view_qname, known, deps, columns, false, qname_str)?;
288                }
289            }
290        }
291
292        // ------------------------------------------------------------------ //
293        // Table/view reference: resolve and emit a DepEdge.
294        // ------------------------------------------------------------------ //
295        N::RangeVar(rv) => {
296            // Only schema-qualified names are checked against the catalog.
297            // Unqualified references require schema-search-path knowledge
298            // which is out of scope for v0.2 (file directives don't propagate
299            // to query body resolution yet).
300            // If identifier construction fails (e.g., overlong name),
301            // skip silently — pg_query already validated it's parseable.
302            if !rv.schemaname.is_empty()
303                && !rv.relname.is_empty()
304                && let Ok(s) = Identifier::from_unquoted(&rv.schemaname)
305                    .or_else(|_| Identifier::from_quoted(&rv.schemaname))
306                && let Ok(n) = Identifier::from_unquoted(&rv.relname)
307                    .or_else(|_| Identifier::from_quoted(&rv.relname))
308            {
309                let ref_qname = QualifiedName::new(s, n);
310                if !known.has_relation(&ref_qname) {
311                    return Err(AstCanonError::UnresolvedReference {
312                        view: qname_str.to_string(),
313                        object: ref_qname.to_string(),
314                    });
315                }
316                deps.push(DepEdge {
317                    from: NodeId::Table(view_qname.clone()),
318                    to: NodeId::Table(ref_qname),
319                    source: DepSource::AstExtracted,
320                });
321            }
322        }
323
324        // ------------------------------------------------------------------ //
325        // JOIN: recurse into left and right args.
326        // ------------------------------------------------------------------ //
327        N::JoinExpr(j) => {
328            if let Some(larg) = &j.larg {
329                walk_node(larg, view_qname, known, deps, columns, false, qname_str)?;
330            }
331            if let Some(rarg) = &j.rarg {
332                walk_node(rarg, view_qname, known, deps, columns, false, qname_str)?;
333            }
334        }
335
336        // ------------------------------------------------------------------ //
337        // Subquery in FROM: `SELECT ... FROM (SELECT ...) sub`
338        // ------------------------------------------------------------------ //
339        N::RangeSubselect(sub) => {
340            if let Some(subquery) = &sub.subquery {
341                walk_node(subquery, view_qname, known, deps, columns, false, qname_str)?;
342            }
343        }
344
345        // ------------------------------------------------------------------ //
346        // CommonTableExpr (CTE): walk the query inside.
347        // ------------------------------------------------------------------ //
348        N::CommonTableExpr(cte) => {
349            if let Some(q) = &cte.ctequery {
350                walk_node(q, view_qname, known, deps, columns, false, qname_str)?;
351            }
352        }
353
354        // Other node types (expressions, literals, function calls, etc.) do
355        // not contain relation references that need resolving in v0.2.
356        _ => {}
357    }
358
359    Ok(())
360}
361
362/// Recurse into a nested `SelectStmt` (from UNION / INTERSECT / EXCEPT).
363fn walk_select(
364    sel: &pg_query::protobuf::SelectStmt,
365    view_qname: &QualifiedName,
366    known: &KnownObjects,
367    deps: &mut Vec<DepEdge>,
368    qname_str: &str,
369) -> Result<(), AstCanonError> {
370    // Wrap the SelectStmt in a Node for the generic walker.
371    let node = pg_query::protobuf::Node {
372        node: Some(pg_query::NodeEnum::SelectStmt(Box::new(sel.clone()))),
373    };
374    // Pass empty columns vec; set-operation branches don't contribute column names.
375    walk_node(
376        &node,
377        view_qname,
378        known,
379        deps,
380        &mut Vec::new(),
381        false,
382        qname_str,
383    )
384}
385
386/// Resolve placeholder types for columns that came from an explicit
387/// `CREATE VIEW v(a, b, …)` alias list.
388///
389/// The parser populates such columns with names only, leaving `column_type`
390/// as `None` (unresolved). Static type inference of an arbitrary SELECT body
391/// is out of scope — those types are later collapsed to the `view_column`
392/// sentinel by `ir::canon::sentinel_view_columns` regardless — so this fills
393/// the same `"expression"` placeholder used for derived columns. This makes
394/// the post-`ast_canon` invariant "no view column type is `None`" hold for
395/// both the explicit-alias and derived paths.
396fn resolve_explicit_column_types(columns: &mut [ViewColumn]) {
397    for col in columns {
398        if col.column_type.is_none() {
399            col.column_type = Some(expression_placeholder());
400        }
401    }
402}
403
404/// The placeholder column type written for view columns before the
405/// `sentinel_view_columns` canon pass collapses every view column type to the
406/// `view_column` sentinel. The concrete value is irrelevant (it is always
407/// overwritten); a shared helper makes "these are deliberately the same" explicit.
408fn expression_placeholder() -> ColumnType {
409    ColumnType::Other {
410        raw: "expression".to_string(),
411    }
412}
413
414/// Derive a view column name from a SELECT target (`ResTarget`).
415///
416/// Uses PG's column-naming algorithm:
417///  1. `ResTarget.name` (explicit `AS alias`) wins.
418///  2. Otherwise the rightmost field name of a `ColumnRef`
419///     (`schema.table.col` → `"col"`).
420///  3. Otherwise `"?column?"` (PG's fallback for expressions with no name).
421///
422/// The `column_type` is set to `ColumnType::Other { raw: "expression" }` as
423/// a sentinel for the v0.2 source-side path; the live-catalog path (T5)
424/// populates the real type from `format_type(a.atttypid, a.atttypmod)`.
425/// The OR-REPLACE compatibility predicate in `diff::views` uses catalog-side
426/// types (target) vs. catalog-side types (source), so expression-typed columns
427/// from the source side will compare as unequal to typed columns in the
428/// catalog, conservatively declaring a replace incompatible — which is correct.
429fn derive_column_name(target: &pg_query::protobuf::Node) -> Option<ViewColumn> {
430    use pg_query::NodeEnum as N;
431    let Some(N::ResTarget(rt)) = &target.node else {
432        return None;
433    };
434
435    // 1. Explicit alias.
436    if !rt.name.is_empty() {
437        let name = Identifier::from_unquoted(&rt.name)
438            .or_else(|_| Identifier::from_quoted(&rt.name))
439            .ok()?;
440        return Some(ViewColumn {
441            name,
442            column_type: Some(expression_placeholder()),
443            comment: None,
444        });
445    }
446
447    // 2. Try to extract the rightmost ColumnRef field name.
448    if let Some(val_box) = &rt.val
449        && let Some(col_name) = extract_column_ref_name(val_box)
450    {
451        return Some(ViewColumn {
452            name: col_name,
453            column_type: Some(expression_placeholder()),
454            comment: None,
455        });
456    }
457
458    // 3. PG fallback.
459    Identifier::from_quoted("?column?")
460        .ok()
461        .map(|name| ViewColumn {
462            name,
463            column_type: Some(expression_placeholder()),
464            comment: None,
465        })
466}
467
468/// Attempt to extract a column name from a node that may be a `ColumnRef`.
469///
470/// Returns `None` for expressions that are not simple column references.
471fn extract_column_ref_name(node: &pg_query::protobuf::Node) -> Option<Identifier> {
472    use pg_query::NodeEnum as N;
473    match &node.node {
474        Some(N::ColumnRef(cr)) => {
475            // Take the rightmost `String` field.
476            for field in cr.fields.iter().rev() {
477                if let Some(N::String(s)) = &field.node
478                    && !s.sval.is_empty()
479                {
480                    return Identifier::from_unquoted(&s.sval)
481                        .or_else(|_| Identifier::from_quoted(&s.sval))
482                        .ok();
483                }
484            }
485            None
486        }
487        // TypeCast: `expr::type` — the name comes from the inner expression.
488        Some(N::TypeCast(tc)) => tc.arg.as_deref().and_then(extract_column_ref_name),
489        _ => None,
490    }
491}
492
493/// Rewrite a SELECT body so that its top-level target list carries the given
494/// column aliases — emulating what PG does internally when a `CREATE VIEW (…)`
495/// includes a column alias list.
496///
497/// Returns `None` if:
498/// - The body fails to parse,
499/// - The parsed statement isn't a `SelectStmt` (or has fewer/more target list
500///   items than aliases — the count must match),
501/// - The deparse step fails.
502///
503/// Implementation walks the parsed protobuf, sets each `ResTarget.name` to
504/// the corresponding alias, and deparses.
505fn apply_view_column_aliases(body_sql: &str, aliases: &[String]) -> Option<String> {
506    use pg_query::NodeEnum;
507
508    let parsed = pg_query::parse(body_sql).ok()?;
509    let mut result = parsed.protobuf;
510    // Locate the first SELECT statement in the parse result.
511    for stmt in &mut result.stmts {
512        let Some(node) = stmt.stmt.as_mut().and_then(|n| n.node.as_mut()) else {
513            continue;
514        };
515        if let NodeEnum::SelectStmt(sel) = node {
516            apply_aliases_to_select(sel, aliases)?;
517            return pg_query::deparse(&result).ok();
518        }
519    }
520    None
521}
522
523/// Set each top-level target's `name` to the corresponding alias. Returns
524/// `Some(())` only when target count matches alias count.
525///
526/// PG's `pg_get_viewdef` only emits `AS <alias>` when the alias differs from
527/// the target's implicit name (a bare `ColumnRef` resolves to the column
528/// name). To match that behavior, we set `ResTarget.name` only when the
529/// alias is non-redundant; otherwise we clear `name` so the deparser omits
530/// the `AS` clause.
531fn apply_aliases_to_select(
532    sel: &mut pg_query::protobuf::SelectStmt,
533    aliases: &[String],
534) -> Option<()> {
535    use pg_query::NodeEnum;
536    if sel.target_list.len() != aliases.len() {
537        return None;
538    }
539    for (node, alias) in sel.target_list.iter_mut().zip(aliases.iter()) {
540        let Some(NodeEnum::ResTarget(rt)) = node.node.as_mut() else {
541            return None;
542        };
543        // Compute the implicit (no-alias) column name. For a bare column
544        // reference `t.col` or `col`, the implicit name is the last field
545        // segment. For anything else (function calls, expressions, literals),
546        // there's no implicit name and we always emit the alias.
547        let implicit = restarget_implicit_column_name(rt);
548        if implicit.as_deref() == Some(alias.as_str()) {
549            rt.name.clear();
550        } else {
551            rt.name.clone_from(alias);
552        }
553    }
554    Some(())
555}
556
557/// Extract the implicit column name of a `ResTarget` (i.e., the name PG would
558/// use if no `AS` clause were given). Only `ColumnRef` target values have one;
559/// everything else is treated as having no implicit name.
560fn restarget_implicit_column_name(rt: &pg_query::protobuf::ResTarget) -> Option<String> {
561    use pg_query::NodeEnum;
562    let val = rt.val.as_ref()?;
563    let node = val.node.as_ref()?;
564    let NodeEnum::ColumnRef(cref) = node else {
565        return None;
566    };
567    // The implicit name is the last String field in the fields list.
568    let last = cref.fields.last()?;
569    let last_node = last.node.as_ref()?;
570    if let NodeEnum::String(s) = last_node {
571        Some(s.sval.clone())
572    } else {
573        None
574    }
575}
576
577// ---------------------------------------------------------------------------
578// Tests
579// ---------------------------------------------------------------------------
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use crate::ir::catalog::Catalog;
585    use crate::ir::index::{
586        Index, IndexColumn, IndexColumnExpr, IndexMethod, NullsOrder, SortOrder,
587    };
588    use crate::ir::view::MaterializedView;
589    use crate::parse::normalize_body::NormalizedBody;
590
591    fn id(s: &str) -> Identifier {
592        Identifier::from_unquoted(s).unwrap()
593    }
594
595    fn qn(schema: &str, name: &str) -> QualifiedName {
596        QualifiedName::new(id(schema), id(name))
597    }
598
599    fn simple_index(name: &str, on: IndexParent, unique: bool) -> Index {
600        Index {
601            qname: qn("app", name),
602            on,
603            method: IndexMethod::BTree,
604            columns: vec![IndexColumn {
605                expr: IndexColumnExpr::Column(id("k")),
606                collation: None,
607                opclass: None,
608                sort_order: SortOrder::Asc,
609                nulls_order: NullsOrder::NullsLast,
610            }],
611            include: vec![],
612            unique,
613            nulls_not_distinct: false,
614            predicate: None,
615            tablespace: None,
616            comment: None,
617            storage: crate::ir::reloptions::IndexStorageOptions::default(),
618        }
619    }
620
621    fn simple_mv(schema: &str, name: &str) -> MaterializedView {
622        MaterializedView {
623            qname: qn(schema, name),
624            columns: vec![],
625            body_canonical: NormalizedBody::from_sql("SELECT 1").unwrap(),
626            body_dependencies: vec![],
627            comment: None,
628            raw_body: String::new(),
629            owner: None,
630            grants: vec![],
631            storage: crate::ir::reloptions::MaterializedViewStorageOptions::default(),
632        }
633    }
634
635    // ── promote_mv_index_parents ─────────────────────────────────────────────
636
637    /// A source-SQL unique index on an MV (`CREATE UNIQUE INDEX ... ON app.mv`)
638    /// starts life as `IndexParent::Table` and must be promoted to
639    /// `IndexParent::Mv` by the pass.
640    #[test]
641    fn unique_index_on_mv_is_promoted_to_mv_parent() {
642        let mv_qname = qn("app", "mv");
643        let mut catalog = Catalog::empty();
644        catalog.materialized_views.push(simple_mv("app", "mv"));
645        // Initially parsed as Table (as the source parser always does).
646        catalog.indexes.push(simple_index(
647            "mv_uk",
648            IndexParent::Table(mv_qname.clone()),
649            true,
650        ));
651
652        promote_mv_index_parents(&mut catalog);
653
654        assert!(
655            matches!(&catalog.indexes[0].on, IndexParent::Mv(q) if q == &mv_qname),
656            "expected IndexParent::Mv after promotion, got {:?}",
657            catalog.indexes[0].on,
658        );
659    }
660
661    /// An index on a *table* must not be promoted, even if a same-named MV
662    /// does not exist.
663    #[test]
664    fn index_on_table_is_not_promoted() {
665        let table_qname = qn("app", "users");
666        let mut catalog = Catalog::empty();
667        // No MVs in the catalog.
668        catalog.indexes.push(simple_index(
669            "users_idx",
670            IndexParent::Table(table_qname.clone()),
671            false,
672        ));
673
674        promote_mv_index_parents(&mut catalog);
675
676        assert!(
677            matches!(&catalog.indexes[0].on, IndexParent::Table(q) if q == &table_qname),
678            "Table index must not be promoted: {:?}",
679            catalog.indexes[0].on,
680        );
681    }
682
683    /// When the catalog contains no MVs the pass is a no-op (early return).
684    #[test]
685    fn no_mvs_no_promotion() {
686        let mut catalog = Catalog::empty();
687        catalog.indexes.push(simple_index(
688            "some_idx",
689            IndexParent::Table(qn("app", "t")),
690            false,
691        ));
692        promote_mv_index_parents(&mut catalog);
693        assert!(matches!(&catalog.indexes[0].on, IndexParent::Table(_)));
694    }
695
696    /// An index that already has `IndexParent::Mv` (e.g., from the live-catalog
697    /// reader) must be left unchanged.
698    #[test]
699    fn already_mv_parent_is_unchanged() {
700        let mv_qname = qn("app", "mv");
701        let mut catalog = Catalog::empty();
702        catalog.materialized_views.push(simple_mv("app", "mv"));
703        catalog.indexes.push(simple_index(
704            "mv_idx",
705            IndexParent::Mv(mv_qname.clone()),
706            false,
707        ));
708
709        promote_mv_index_parents(&mut catalog);
710
711        assert!(
712            matches!(&catalog.indexes[0].on, IndexParent::Mv(q) if q == &mv_qname),
713            "already-Mv parent must remain Mv: {:?}",
714            catalog.indexes[0].on,
715        );
716    }
717}