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}