Skip to main content

pgevolve_core/diff/
views.rs

1//! View / MV diff and OR-REPLACE compatibility.
2//!
3//! ## Body change detection
4//!
5//! Two view bodies are considered identical when their `canonical_hash` bytes
6//! are equal. This avoids fragile text comparison and is already the canonical
7//! form produced by T4's AST canonicalization pass and by the catalog reader.
8//!
9//! ## OR-REPLACE compatibility
10//!
11//! Per Postgres's `CREATE OR REPLACE VIEW` rules the new definition must:
12//! 1. Have at least as many columns as the existing view.
13//! 2. Keep the same column names at the same positions.
14//! 3. Keep the same types at the same positions.
15//!
16//! New columns may only be appended at the end.
17//!
18//! `or_replace_compatible` encodes exactly these rules.
19
20use std::collections::BTreeSet;
21
22use crate::identifier::{Identifier, QualifiedName};
23use crate::ir::grant::GrantTarget;
24use crate::ir::view::{MaterializedView, View, ViewColumn};
25
26use super::change::{Change, MvChange, ViewChange};
27use super::changeset::{ChangeSet, RevokeWithOwnerObservation, UnmanagedGrantObservation};
28use super::destructiveness::Destructiveness;
29use super::grants::diff_grants;
30use super::owner_op::{AlterObjectOwner, OwnerObjectKind};
31
32/// Per Postgres's `CREATE OR REPLACE VIEW` rules: the new column list must be
33/// a non-shrinking superset of the existing list, with the same names **and
34/// types** at the same indexes. New columns may be appended at the end.
35///
36/// `catalog` is the existing column list (live DB / target).
37/// `source` is the desired column list (source SQL).
38pub(crate) fn or_replace_compatible(catalog: &[ViewColumn], source: &[ViewColumn]) -> bool {
39    if source.len() < catalog.len() {
40        return false;
41    }
42    for (i, cat_col) in catalog.iter().enumerate() {
43        let src_col = &source[i];
44        if cat_col.name != src_col.name || cat_col.column_type != src_col.column_type {
45            return false;
46        }
47    }
48    true
49}
50
51/// Diff `target.views` (live DB) against `source.views` (desired).
52///
53/// Pairs views by [`QualifiedName`] and emits:
54/// - [`ViewChange::Create`] for views present only in source.
55/// - [`ViewChange::Drop`] for views present only in target.
56/// - [`ViewChange::ReplaceBody`] when the canonical body hashes differ.
57/// - [`ViewChange::SetReloption`] when `security_barrier` / `security_invoker`
58///   differ (independent of body changes).
59/// - [`ViewChange::SetComment`] / [`ViewChange::SetColumnComment`] for metadata
60///   changes.
61/// - [`Change::AlterObjectOwner`] / grant changes when applicable.
62///
63/// No change is emitted when target and source are byte-for-byte identical.
64#[allow(clippy::too_many_lines)] // exhaustive per-view-property diff with create / replace / drop branches.
65pub fn diff_views(
66    target_views: &[View],
67    source_views: &[View],
68    out: &mut ChangeSet,
69    managed_roles: &BTreeSet<Identifier>,
70) {
71    use std::collections::BTreeMap;
72
73    let target_map: BTreeMap<&QualifiedName, &View> =
74        target_views.iter().map(|v| (&v.qname, v)).collect();
75    let source_map: BTreeMap<&QualifiedName, &View> =
76        source_views.iter().map(|v| (&v.qname, v)).collect();
77
78    // Views in source but not target → Create.
79    for (qname, src) in &source_map {
80        if !target_map.contains_key(qname) {
81            out.push(
82                Change::View(ViewChange::Create((*src).clone())),
83                Destructiveness::Safe,
84            );
85        }
86    }
87
88    // Views in target but not source → Drop.
89    for qname in target_map.keys() {
90        if !source_map.contains_key(qname) {
91            out.push(
92                Change::View(ViewChange::Drop((*qname).clone())),
93                Destructiveness::RequiresApproval {
94                    reason: format!("drops view {qname}"),
95                },
96            );
97        }
98    }
99
100    // Views present in both — check for changes.
101    for (qname, src) in &source_map {
102        let Some(tgt) = target_map.get(qname) else {
103            continue;
104        };
105
106        // Body change: compare canonical hashes.
107        if src.body_canonical.canonical_hash() != tgt.body_canonical.canonical_hash() {
108            let compatible = or_replace_compatible(&tgt.columns, &src.columns);
109            out.push(
110                Change::View(ViewChange::ReplaceBody {
111                    source: (*src).clone(),
112                    catalog: (*tgt).clone(),
113                    compatible,
114                }),
115                Destructiveness::Safe,
116            );
117        }
118
119        // Reloption changes (independent of body).
120        if src.security_barrier != tgt.security_barrier
121            || src.security_invoker != tgt.security_invoker
122        {
123            out.push(
124                Change::View(ViewChange::SetReloption {
125                    qname: (*qname).clone(),
126                    security_barrier: src.security_barrier,
127                    security_invoker: src.security_invoker,
128                }),
129                Destructiveness::Safe,
130            );
131        }
132
133        // Check option change (lenient — only managed when source declares it).
134        if src.check_option != tgt.check_option {
135            out.push(
136                Change::AlterViewSetCheckOption {
137                    qname: (*qname).clone(),
138                    new_value: src.check_option,
139                },
140                Destructiveness::Safe,
141            );
142        }
143
144        // View-level comment change.
145        if src.comment != tgt.comment {
146            out.push(
147                Change::View(ViewChange::SetComment {
148                    qname: (*qname).clone(),
149                    comment: src.comment.clone(),
150                }),
151                Destructiveness::Safe,
152            );
153        }
154
155        // Column comment changes: compare by name at matching positions.
156        diff_view_column_comments(qname, &tgt.columns, &src.columns, out);
157
158        // ---- owner diff ----
159        if let Some(source_owner) = &src.owner
160            && tgt.owner.as_ref() != Some(source_owner)
161        {
162            out.push(
163                Change::AlterObjectOwner(AlterObjectOwner {
164                    kind: OwnerObjectKind::View,
165                    id: crate::diff::owner_op::OwnedObjectId::Qualified((*qname).clone()),
166                    signature: String::new(),
167                    from: tgt.owner.clone(),
168                    to: source_owner.clone(),
169                }),
170                Destructiveness::Safe,
171            );
172        }
173
174        // ---- grant diff ----
175        {
176            let object_label = format!("view {qname}");
177            let (to_add, to_revoke, unmanaged) =
178                diff_grants(&tgt.grants, &src.grants, managed_roles);
179            // Emit REVOKEs before GRANTs (issue #33): revokes must precede
180            // grants so that WGO-change pairs (same grantee+privilege, different
181            // wgo) don't self-cancel.
182            for g in to_revoke {
183                if let Some(source_owner) = &src.owner {
184                    out.revokes_with_owner.push(RevokeWithOwnerObservation {
185                        object_label: object_label.clone(),
186                        privilege_label: g.privilege.sql_keyword().into(),
187                        grantee: g.grantee.clone(),
188                        owner: source_owner.clone(),
189                    });
190                }
191                let is_column_level = g.columns.is_some();
192                if is_column_level {
193                    out.push(
194                        Change::RevokeColumnPrivilege {
195                            qname: (*qname).clone(),
196                            grant: g,
197                        },
198                        Destructiveness::Safe,
199                    );
200                } else {
201                    out.push(
202                        Change::RevokeObjectPrivilege {
203                            qname: (*qname).clone(),
204                            kind: OwnerObjectKind::View,
205                            signature: String::new(),
206                            grant: g,
207                        },
208                        Destructiveness::Safe,
209                    );
210                }
211            }
212            for g in to_add {
213                let is_column_level = g.columns.is_some();
214                if is_column_level {
215                    out.push(
216                        Change::GrantColumnPrivilege {
217                            qname: (*qname).clone(),
218                            grant: g,
219                        },
220                        Destructiveness::Safe,
221                    );
222                } else {
223                    out.push(
224                        Change::GrantObjectPrivilege {
225                            qname: (*qname).clone(),
226                            kind: OwnerObjectKind::View,
227                            signature: String::new(),
228                            grant: g,
229                        },
230                        Destructiveness::Safe,
231                    );
232                }
233            }
234            for g in unmanaged {
235                if let GrantTarget::Role(role_name) = &g.grantee {
236                    out.unmanaged_grants.push(UnmanagedGrantObservation {
237                        object_label: object_label.clone(),
238                        privilege_label: g.privilege.sql_keyword().into(),
239                        role_name: role_name.clone(),
240                    });
241                }
242            }
243        }
244    }
245}
246
247/// Diff `target.materialized_views` (live DB) against
248/// `source.materialized_views` (desired).
249///
250/// Emits:
251/// - [`MvChange::Create`] for MVs present only in source.
252/// - [`MvChange::Drop`] for MVs present only in target (Safe: MVs are derived).
253/// - [`MvChange::ReplaceBody`] when the canonical body hashes differ.
254/// - [`MvChange::SetComment`] / [`MvChange::SetColumnComment`] for metadata.
255/// - [`Change::AlterObjectOwner`] / grant changes when applicable.
256#[allow(clippy::too_many_lines)] // exhaustive per-MV-property diff with create / replace / drop branches.
257pub fn diff_materialized_views(
258    target_mvs: &[MaterializedView],
259    source_mvs: &[MaterializedView],
260    out: &mut ChangeSet,
261    managed_roles: &BTreeSet<Identifier>,
262) {
263    use std::collections::BTreeMap;
264
265    let target_map: BTreeMap<&QualifiedName, &MaterializedView> =
266        target_mvs.iter().map(|m| (&m.qname, m)).collect();
267    let source_map: BTreeMap<&QualifiedName, &MaterializedView> =
268        source_mvs.iter().map(|m| (&m.qname, m)).collect();
269
270    // MVs in source but not target → Create.
271    for (qname, src) in &source_map {
272        if !target_map.contains_key(qname) {
273            out.push(
274                Change::Mv(MvChange::Create((*src).clone())),
275                Destructiveness::Safe,
276            );
277        }
278    }
279
280    // MVs in target but not source → Drop (Safe: MVs are derived data).
281    for qname in target_map.keys() {
282        if !source_map.contains_key(qname) {
283            out.push(
284                Change::Mv(MvChange::Drop((*qname).clone())),
285                Destructiveness::Safe,
286            );
287        }
288    }
289
290    // MVs present in both — check for changes.
291    for (qname, src) in &source_map {
292        let Some(tgt) = target_map.get(qname) else {
293            continue;
294        };
295
296        // Body change: compare canonical hashes.
297        if src.body_canonical.canonical_hash() != tgt.body_canonical.canonical_hash() {
298            out.push(
299                Change::Mv(MvChange::ReplaceBody {
300                    source: (*src).clone(),
301                    catalog: (*tgt).clone(),
302                }),
303                Destructiveness::Safe,
304            );
305        }
306
307        // MV-level comment change.
308        if src.comment != tgt.comment {
309            out.push(
310                Change::Mv(MvChange::SetComment {
311                    qname: (*qname).clone(),
312                    comment: src.comment.clone(),
313                }),
314                Destructiveness::Safe,
315            );
316        }
317
318        // Column comment changes.
319        diff_mv_column_comments(qname, &tgt.columns, &src.columns, out);
320
321        // ---- storage reloptions diff ----
322        let delta = crate::diff::reloptions::table_delta(&tgt.storage, &src.storage);
323        if !delta.is_empty() {
324            out.push(
325                Change::SetMaterializedViewStorage {
326                    qname: (*qname).clone(),
327                    options: delta,
328                },
329                Destructiveness::Safe,
330            );
331        }
332
333        // ---- owner diff ----
334        if let Some(source_owner) = &src.owner
335            && tgt.owner.as_ref() != Some(source_owner)
336        {
337            out.push(
338                Change::AlterObjectOwner(AlterObjectOwner {
339                    kind: OwnerObjectKind::MaterializedView,
340                    id: crate::diff::owner_op::OwnedObjectId::Qualified((*qname).clone()),
341                    signature: String::new(),
342                    from: tgt.owner.clone(),
343                    to: source_owner.clone(),
344                }),
345                Destructiveness::Safe,
346            );
347        }
348
349        // ---- grant diff ----
350        {
351            let object_label = format!("materialized view {qname}");
352            let (to_add, to_revoke, unmanaged) =
353                diff_grants(&tgt.grants, &src.grants, managed_roles);
354            // Emit REVOKEs before GRANTs (issue #33): revokes must precede
355            // grants so that WGO-change pairs (same grantee+privilege, different
356            // wgo) don't self-cancel.
357            for g in to_revoke {
358                if let Some(source_owner) = &src.owner {
359                    out.revokes_with_owner.push(RevokeWithOwnerObservation {
360                        object_label: object_label.clone(),
361                        privilege_label: g.privilege.sql_keyword().into(),
362                        grantee: g.grantee.clone(),
363                        owner: source_owner.clone(),
364                    });
365                }
366                let is_column_level = g.columns.is_some();
367                if is_column_level {
368                    out.push(
369                        Change::RevokeColumnPrivilege {
370                            qname: (*qname).clone(),
371                            grant: g,
372                        },
373                        Destructiveness::Safe,
374                    );
375                } else {
376                    out.push(
377                        Change::RevokeObjectPrivilege {
378                            qname: (*qname).clone(),
379                            kind: OwnerObjectKind::MaterializedView,
380                            signature: String::new(),
381                            grant: g,
382                        },
383                        Destructiveness::Safe,
384                    );
385                }
386            }
387            for g in to_add {
388                let is_column_level = g.columns.is_some();
389                if is_column_level {
390                    out.push(
391                        Change::GrantColumnPrivilege {
392                            qname: (*qname).clone(),
393                            grant: g,
394                        },
395                        Destructiveness::Safe,
396                    );
397                } else {
398                    out.push(
399                        Change::GrantObjectPrivilege {
400                            qname: (*qname).clone(),
401                            kind: OwnerObjectKind::MaterializedView,
402                            signature: String::new(),
403                            grant: g,
404                        },
405                        Destructiveness::Safe,
406                    );
407                }
408            }
409            for g in unmanaged {
410                if let GrantTarget::Role(role_name) = &g.grantee {
411                    out.unmanaged_grants.push(UnmanagedGrantObservation {
412                        object_label: object_label.clone(),
413                        privilege_label: g.privilege.sql_keyword().into(),
414                        role_name: role_name.clone(),
415                    });
416                }
417            }
418        }
419    }
420}
421
422/// Emit [`ViewChange::SetColumnComment`] for each column where the comment
423/// differs between target and source.
424fn diff_view_column_comments(
425    qname: &QualifiedName,
426    target_cols: &[ViewColumn],
427    source_cols: &[ViewColumn],
428    out: &mut ChangeSet,
429) {
430    use std::collections::BTreeMap;
431
432    let tgt_map: BTreeMap<_, _> = target_cols.iter().map(|c| (&c.name, c)).collect();
433    let src_map: BTreeMap<_, _> = source_cols.iter().map(|c| (&c.name, c)).collect();
434
435    for (name, src_col) in &src_map {
436        let tgt_comment = tgt_map.get(name).and_then(|c| c.comment.as_deref());
437        let src_comment = src_col.comment.as_deref();
438        if src_comment != tgt_comment {
439            out.push(
440                Change::View(ViewChange::SetColumnComment {
441                    qname: qname.clone(),
442                    column: (*name).clone(),
443                    comment: src_col.comment.clone(),
444                }),
445                Destructiveness::Safe,
446            );
447        }
448    }
449}
450
451/// Emit [`MvChange::SetColumnComment`] for each column where the comment
452/// differs between target and source.
453fn diff_mv_column_comments(
454    qname: &QualifiedName,
455    target_cols: &[ViewColumn],
456    source_cols: &[ViewColumn],
457    out: &mut ChangeSet,
458) {
459    use std::collections::BTreeMap;
460
461    let tgt_map: BTreeMap<_, _> = target_cols.iter().map(|c| (&c.name, c)).collect();
462    let src_map: BTreeMap<_, _> = source_cols.iter().map(|c| (&c.name, c)).collect();
463
464    for (name, src_col) in &src_map {
465        let tgt_comment = tgt_map.get(name).and_then(|c| c.comment.as_deref());
466        let src_comment = src_col.comment.as_deref();
467        if src_comment != tgt_comment {
468            out.push(
469                Change::Mv(MvChange::SetColumnComment {
470                    qname: qname.clone(),
471                    column: (*name).clone(),
472                    comment: src_col.comment.clone(),
473                }),
474                Destructiveness::Safe,
475            );
476        }
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use std::collections::BTreeSet;
484
485    use crate::identifier::Identifier;
486    use crate::ir::column_type::ColumnType;
487    use crate::ir::view::{MaterializedView, View, ViewColumn};
488    use crate::parse::normalize_body::NormalizedBody;
489
490    fn id(s: &str) -> Identifier {
491        Identifier::from_unquoted(s).unwrap()
492    }
493
494    fn qn(schema: &str, name: &str) -> QualifiedName {
495        QualifiedName::new(id(schema), id(name))
496    }
497
498    fn body(sql: &str) -> NormalizedBody {
499        NormalizedBody::from_sql(sql).unwrap()
500    }
501
502    fn col(name: &str, ty: ColumnType) -> ViewColumn {
503        ViewColumn {
504            name: id(name),
505            column_type: ty,
506            comment: None,
507        }
508    }
509
510    fn simple_view(qname: QualifiedName, body_sql: &str, cols: Vec<ViewColumn>) -> View {
511        View {
512            qname,
513            columns: cols,
514            body_canonical: body(body_sql),
515            body_dependencies: vec![],
516            security_barrier: None,
517            security_invoker: None,
518            check_option: None,
519            comment: None,
520            raw_body: String::new(),
521            owner: None,
522            grants: vec![],
523        }
524    }
525
526    fn simple_mv(qname: QualifiedName, body_sql: &str, cols: Vec<ViewColumn>) -> MaterializedView {
527        MaterializedView {
528            qname,
529            columns: cols,
530            body_canonical: body(body_sql),
531            body_dependencies: vec![],
532            comment: None,
533            raw_body: String::new(),
534            owner: None,
535            grants: vec![],
536            storage: crate::ir::reloptions::MaterializedViewStorageOptions::default(),
537        }
538    }
539
540    // ------------------------------------------------------------------ //
541    // OR-REPLACE compatibility predicate tests
542    // ------------------------------------------------------------------ //
543
544    #[test]
545    fn identical_column_lists_are_compatible() {
546        let cols = vec![col("id", ColumnType::BigInt), col("name", ColumnType::Text)];
547        assert!(or_replace_compatible(&cols, &cols));
548    }
549
550    #[test]
551    fn appending_columns_is_compatible() {
552        let catalog = vec![col("id", ColumnType::BigInt)];
553        let source = vec![col("id", ColumnType::BigInt), col("name", ColumnType::Text)];
554        assert!(or_replace_compatible(&catalog, &source));
555    }
556
557    #[test]
558    fn renaming_a_column_is_incompatible() {
559        let catalog = vec![col("id", ColumnType::BigInt)];
560        let source = vec![col("user_id", ColumnType::BigInt)];
561        assert!(!or_replace_compatible(&catalog, &source));
562    }
563
564    #[test]
565    fn dropping_a_column_is_incompatible() {
566        let catalog = vec![col("id", ColumnType::BigInt), col("name", ColumnType::Text)];
567        let source = vec![col("id", ColumnType::BigInt)];
568        assert!(!or_replace_compatible(&catalog, &source));
569    }
570
571    #[test]
572    fn reordering_is_incompatible() {
573        let catalog = vec![col("id", ColumnType::BigInt), col("name", ColumnType::Text)];
574        let source = vec![col("name", ColumnType::Text), col("id", ColumnType::BigInt)];
575        assert!(!or_replace_compatible(&catalog, &source));
576    }
577
578    #[test]
579    fn type_change_is_incompatible() {
580        let catalog = vec![col("id", ColumnType::Integer)];
581        let source = vec![col("id", ColumnType::BigInt)];
582        assert!(!or_replace_compatible(&catalog, &source));
583    }
584
585    // ------------------------------------------------------------------ //
586    // diff_views tests
587    // ------------------------------------------------------------------ //
588
589    #[test]
590    fn view_only_in_source_is_create() {
591        let src = vec![simple_view(qn("app", "v"), "SELECT 1", vec![])];
592        let mut out = ChangeSet::new();
593        diff_views(&[], &src, &mut out, &BTreeSet::new());
594        let changes = &out.entries;
595        assert_eq!(changes.len(), 1);
596        assert!(matches!(
597            &changes[0].change,
598            Change::View(ViewChange::Create(_))
599        ));
600    }
601
602    #[test]
603    fn view_only_in_target_is_drop() {
604        let tgt = vec![simple_view(qn("app", "v"), "SELECT 1", vec![])];
605        let mut out = ChangeSet::new();
606        diff_views(&tgt, &[], &mut out, &BTreeSet::new());
607        let changes = &out.entries;
608        assert_eq!(changes.len(), 1);
609        assert!(matches!(
610            &changes[0].change,
611            Change::View(ViewChange::Drop(_))
612        ));
613        assert!(changes[0].destructiveness.requires_approval());
614    }
615
616    #[test]
617    fn view_body_change_with_compatible_columns_is_replace_body_compatible_true() {
618        let cols = vec![col("id", ColumnType::BigInt)];
619        let tgt = vec![simple_view(qn("app", "v"), "SELECT 1 AS id", cols.clone())];
620        let src = vec![simple_view(qn("app", "v"), "SELECT 2 AS id", cols)];
621        let mut out = ChangeSet::new();
622        diff_views(&tgt, &src, &mut out, &BTreeSet::new());
623        let changes = &out.entries;
624        assert_eq!(changes.len(), 1);
625        match &changes[0].change {
626            Change::View(ViewChange::ReplaceBody { compatible, .. }) => {
627                assert!(*compatible, "expected compatible=true");
628            }
629            other => panic!("unexpected change: {other:?}"),
630        }
631    }
632
633    #[test]
634    fn view_body_change_with_incompatible_columns_is_replace_body_compatible_false() {
635        let tgt_cols = vec![col("id", ColumnType::Integer)];
636        let src_cols = vec![col("id", ColumnType::BigInt)];
637        let tgt = vec![simple_view(qn("app", "v"), "SELECT 1::int AS id", tgt_cols)];
638        let src = vec![simple_view(
639            qn("app", "v"),
640            "SELECT 2::bigint AS id",
641            src_cols,
642        )];
643        let mut out = ChangeSet::new();
644        diff_views(&tgt, &src, &mut out, &BTreeSet::new());
645        let changes = &out.entries;
646        assert_eq!(changes.len(), 1);
647        match &changes[0].change {
648            Change::View(ViewChange::ReplaceBody { compatible, .. }) => {
649                assert!(!*compatible, "expected compatible=false");
650            }
651            other => panic!("unexpected change: {other:?}"),
652        }
653    }
654
655    #[test]
656    fn view_security_barrier_change_emits_set_reloption() {
657        let body_sql = "SELECT 1";
658        let tgt = vec![{
659            let mut v = simple_view(qn("app", "v"), body_sql, vec![]);
660            v.security_barrier = Some(false);
661            v
662        }];
663        let src = vec![{
664            let mut v = simple_view(qn("app", "v"), body_sql, vec![]);
665            v.security_barrier = Some(true);
666            v
667        }];
668        let mut out = ChangeSet::new();
669        diff_views(&tgt, &src, &mut out, &BTreeSet::new());
670        let changes = &out.entries;
671        assert_eq!(changes.len(), 1);
672        assert!(matches!(
673            &changes[0].change,
674            Change::View(ViewChange::SetReloption { .. })
675        ));
676    }
677
678    #[test]
679    fn identical_views_emit_no_changes() {
680        let v = simple_view(qn("app", "v"), "SELECT 1", vec![]);
681        let mut out = ChangeSet::new();
682        diff_views(
683            std::slice::from_ref(&v),
684            std::slice::from_ref(&v),
685            &mut out,
686            &BTreeSet::new(),
687        );
688        assert!(out.is_empty());
689    }
690
691    // ------------------------------------------------------------------ //
692    // diff_materialized_views tests
693    // ------------------------------------------------------------------ //
694
695    #[test]
696    fn mv_only_in_source_is_create() {
697        let src = vec![simple_mv(qn("app", "mv"), "SELECT 1", vec![])];
698        let mut out = ChangeSet::new();
699        diff_materialized_views(&[], &src, &mut out, &BTreeSet::new());
700        let changes = &out.entries;
701        assert_eq!(changes.len(), 1);
702        assert!(matches!(
703            &changes[0].change,
704            Change::Mv(MvChange::Create(_))
705        ));
706    }
707
708    #[test]
709    fn mv_drop_does_not_set_destructive() {
710        let tgt = vec![simple_mv(qn("app", "mv"), "SELECT 1", vec![])];
711        let mut out = ChangeSet::new();
712        diff_materialized_views(&tgt, &[], &mut out, &BTreeSet::new());
713        let changes = &out.entries;
714        assert_eq!(changes.len(), 1);
715        assert!(matches!(&changes[0].change, Change::Mv(MvChange::Drop(_))));
716        // MVs are derived data — drop is Safe.
717        assert!(!changes[0].destructiveness.requires_approval());
718    }
719
720    #[test]
721    fn identical_mvs_emit_no_changes() {
722        let mv = simple_mv(qn("app", "mv"), "SELECT 1", vec![]);
723        let mut out = ChangeSet::new();
724        diff_materialized_views(
725            std::slice::from_ref(&mv),
726            std::slice::from_ref(&mv),
727            &mut out,
728            &BTreeSet::new(),
729        );
730        assert!(out.is_empty());
731    }
732}