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            for g in to_add {
180                let is_column_level = g.columns.is_some();
181                if is_column_level {
182                    out.push(
183                        Change::GrantColumnPrivilege {
184                            qname: (*qname).clone(),
185                            grant: g,
186                        },
187                        Destructiveness::Safe,
188                    );
189                } else {
190                    out.push(
191                        Change::GrantObjectPrivilege {
192                            qname: (*qname).clone(),
193                            kind: OwnerObjectKind::View,
194                            signature: String::new(),
195                            grant: g,
196                        },
197                        Destructiveness::Safe,
198                    );
199                }
200            }
201            for g in to_revoke {
202                if let Some(source_owner) = &src.owner {
203                    out.revokes_with_owner.push(RevokeWithOwnerObservation {
204                        object_label: object_label.clone(),
205                        privilege_label: g.privilege.sql_keyword().into(),
206                        grantee: g.grantee.clone(),
207                        owner: source_owner.clone(),
208                    });
209                }
210                let is_column_level = g.columns.is_some();
211                if is_column_level {
212                    out.push(
213                        Change::RevokeColumnPrivilege {
214                            qname: (*qname).clone(),
215                            grant: g,
216                        },
217                        Destructiveness::Safe,
218                    );
219                } else {
220                    out.push(
221                        Change::RevokeObjectPrivilege {
222                            qname: (*qname).clone(),
223                            kind: OwnerObjectKind::View,
224                            signature: String::new(),
225                            grant: g,
226                        },
227                        Destructiveness::Safe,
228                    );
229                }
230            }
231            for g in unmanaged {
232                if let GrantTarget::Role(role_name) = &g.grantee {
233                    out.unmanaged_grants.push(UnmanagedGrantObservation {
234                        object_label: object_label.clone(),
235                        privilege_label: g.privilege.sql_keyword().into(),
236                        role_name: role_name.clone(),
237                    });
238                }
239            }
240        }
241    }
242}
243
244/// Diff `target.materialized_views` (live DB) against
245/// `source.materialized_views` (desired).
246///
247/// Emits:
248/// - [`MvChange::Create`] for MVs present only in source.
249/// - [`MvChange::Drop`] for MVs present only in target (Safe: MVs are derived).
250/// - [`MvChange::ReplaceBody`] when the canonical body hashes differ.
251/// - [`MvChange::SetComment`] / [`MvChange::SetColumnComment`] for metadata.
252/// - [`Change::AlterObjectOwner`] / grant changes when applicable.
253#[allow(clippy::too_many_lines)] // exhaustive per-MV-property diff with create / replace / drop branches.
254pub fn diff_materialized_views(
255    target_mvs: &[MaterializedView],
256    source_mvs: &[MaterializedView],
257    out: &mut ChangeSet,
258    managed_roles: &BTreeSet<Identifier>,
259) {
260    use std::collections::BTreeMap;
261
262    let target_map: BTreeMap<&QualifiedName, &MaterializedView> =
263        target_mvs.iter().map(|m| (&m.qname, m)).collect();
264    let source_map: BTreeMap<&QualifiedName, &MaterializedView> =
265        source_mvs.iter().map(|m| (&m.qname, m)).collect();
266
267    // MVs in source but not target → Create.
268    for (qname, src) in &source_map {
269        if !target_map.contains_key(qname) {
270            out.push(
271                Change::Mv(MvChange::Create((*src).clone())),
272                Destructiveness::Safe,
273            );
274        }
275    }
276
277    // MVs in target but not source → Drop (Safe: MVs are derived data).
278    for qname in target_map.keys() {
279        if !source_map.contains_key(qname) {
280            out.push(
281                Change::Mv(MvChange::Drop((*qname).clone())),
282                Destructiveness::Safe,
283            );
284        }
285    }
286
287    // MVs present in both — check for changes.
288    for (qname, src) in &source_map {
289        let Some(tgt) = target_map.get(qname) else {
290            continue;
291        };
292
293        // Body change: compare canonical hashes.
294        if src.body_canonical.canonical_hash() != tgt.body_canonical.canonical_hash() {
295            out.push(
296                Change::Mv(MvChange::ReplaceBody {
297                    source: (*src).clone(),
298                    catalog: (*tgt).clone(),
299                }),
300                Destructiveness::Safe,
301            );
302        }
303
304        // MV-level comment change.
305        if src.comment != tgt.comment {
306            out.push(
307                Change::Mv(MvChange::SetComment {
308                    qname: (*qname).clone(),
309                    comment: src.comment.clone(),
310                }),
311                Destructiveness::Safe,
312            );
313        }
314
315        // Column comment changes.
316        diff_mv_column_comments(qname, &tgt.columns, &src.columns, out);
317
318        // ---- storage reloptions diff ----
319        let delta = crate::diff::reloptions::table_delta(&tgt.storage, &src.storage);
320        if !delta.is_empty() {
321            out.push(
322                Change::SetMaterializedViewStorage {
323                    qname: (*qname).clone(),
324                    options: delta,
325                },
326                Destructiveness::Safe,
327            );
328        }
329
330        // ---- owner diff ----
331        if let Some(source_owner) = &src.owner
332            && tgt.owner.as_ref() != Some(source_owner)
333        {
334            out.push(
335                Change::AlterObjectOwner(AlterObjectOwner {
336                    kind: OwnerObjectKind::MaterializedView,
337                    id: crate::diff::owner_op::OwnedObjectId::Qualified((*qname).clone()),
338                    signature: String::new(),
339                    from: tgt.owner.clone(),
340                    to: source_owner.clone(),
341                }),
342                Destructiveness::Safe,
343            );
344        }
345
346        // ---- grant diff ----
347        {
348            let object_label = format!("materialized view {qname}");
349            let (to_add, to_revoke, unmanaged) =
350                diff_grants(&tgt.grants, &src.grants, managed_roles);
351            for g in to_add {
352                let is_column_level = g.columns.is_some();
353                if is_column_level {
354                    out.push(
355                        Change::GrantColumnPrivilege {
356                            qname: (*qname).clone(),
357                            grant: g,
358                        },
359                        Destructiveness::Safe,
360                    );
361                } else {
362                    out.push(
363                        Change::GrantObjectPrivilege {
364                            qname: (*qname).clone(),
365                            kind: OwnerObjectKind::MaterializedView,
366                            signature: String::new(),
367                            grant: g,
368                        },
369                        Destructiveness::Safe,
370                    );
371                }
372            }
373            for g in to_revoke {
374                if let Some(source_owner) = &src.owner {
375                    out.revokes_with_owner.push(RevokeWithOwnerObservation {
376                        object_label: object_label.clone(),
377                        privilege_label: g.privilege.sql_keyword().into(),
378                        grantee: g.grantee.clone(),
379                        owner: source_owner.clone(),
380                    });
381                }
382                let is_column_level = g.columns.is_some();
383                if is_column_level {
384                    out.push(
385                        Change::RevokeColumnPrivilege {
386                            qname: (*qname).clone(),
387                            grant: g,
388                        },
389                        Destructiveness::Safe,
390                    );
391                } else {
392                    out.push(
393                        Change::RevokeObjectPrivilege {
394                            qname: (*qname).clone(),
395                            kind: OwnerObjectKind::MaterializedView,
396                            signature: String::new(),
397                            grant: g,
398                        },
399                        Destructiveness::Safe,
400                    );
401                }
402            }
403            for g in unmanaged {
404                if let GrantTarget::Role(role_name) = &g.grantee {
405                    out.unmanaged_grants.push(UnmanagedGrantObservation {
406                        object_label: object_label.clone(),
407                        privilege_label: g.privilege.sql_keyword().into(),
408                        role_name: role_name.clone(),
409                    });
410                }
411            }
412        }
413    }
414}
415
416/// Emit [`ViewChange::SetColumnComment`] for each column where the comment
417/// differs between target and source.
418fn diff_view_column_comments(
419    qname: &QualifiedName,
420    target_cols: &[ViewColumn],
421    source_cols: &[ViewColumn],
422    out: &mut ChangeSet,
423) {
424    use std::collections::BTreeMap;
425
426    let tgt_map: BTreeMap<_, _> = target_cols.iter().map(|c| (&c.name, c)).collect();
427    let src_map: BTreeMap<_, _> = source_cols.iter().map(|c| (&c.name, c)).collect();
428
429    for (name, src_col) in &src_map {
430        let tgt_comment = tgt_map.get(name).and_then(|c| c.comment.as_deref());
431        let src_comment = src_col.comment.as_deref();
432        if src_comment != tgt_comment {
433            out.push(
434                Change::View(ViewChange::SetColumnComment {
435                    qname: qname.clone(),
436                    column: (*name).clone(),
437                    comment: src_col.comment.clone(),
438                }),
439                Destructiveness::Safe,
440            );
441        }
442    }
443}
444
445/// Emit [`MvChange::SetColumnComment`] for each column where the comment
446/// differs between target and source.
447fn diff_mv_column_comments(
448    qname: &QualifiedName,
449    target_cols: &[ViewColumn],
450    source_cols: &[ViewColumn],
451    out: &mut ChangeSet,
452) {
453    use std::collections::BTreeMap;
454
455    let tgt_map: BTreeMap<_, _> = target_cols.iter().map(|c| (&c.name, c)).collect();
456    let src_map: BTreeMap<_, _> = source_cols.iter().map(|c| (&c.name, c)).collect();
457
458    for (name, src_col) in &src_map {
459        let tgt_comment = tgt_map.get(name).and_then(|c| c.comment.as_deref());
460        let src_comment = src_col.comment.as_deref();
461        if src_comment != tgt_comment {
462            out.push(
463                Change::Mv(MvChange::SetColumnComment {
464                    qname: qname.clone(),
465                    column: (*name).clone(),
466                    comment: src_col.comment.clone(),
467                }),
468                Destructiveness::Safe,
469            );
470        }
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use std::collections::BTreeSet;
478
479    use crate::identifier::Identifier;
480    use crate::ir::column_type::ColumnType;
481    use crate::ir::view::{MaterializedView, View, ViewColumn};
482    use crate::parse::normalize_body::NormalizedBody;
483
484    fn id(s: &str) -> Identifier {
485        Identifier::from_unquoted(s).unwrap()
486    }
487
488    fn qn(schema: &str, name: &str) -> QualifiedName {
489        QualifiedName::new(id(schema), id(name))
490    }
491
492    fn body(sql: &str) -> NormalizedBody {
493        NormalizedBody::from_sql(sql).unwrap()
494    }
495
496    fn col(name: &str, ty: ColumnType) -> ViewColumn {
497        ViewColumn {
498            name: id(name),
499            column_type: ty,
500            comment: None,
501        }
502    }
503
504    fn simple_view(qname: QualifiedName, body_sql: &str, cols: Vec<ViewColumn>) -> View {
505        View {
506            qname,
507            columns: cols,
508            body_canonical: body(body_sql),
509            body_dependencies: vec![],
510            security_barrier: None,
511            security_invoker: None,
512            check_option: None,
513            comment: None,
514            raw_body: String::new(),
515            owner: None,
516            grants: vec![],
517        }
518    }
519
520    fn simple_mv(qname: QualifiedName, body_sql: &str, cols: Vec<ViewColumn>) -> MaterializedView {
521        MaterializedView {
522            qname,
523            columns: cols,
524            body_canonical: body(body_sql),
525            body_dependencies: vec![],
526            comment: None,
527            raw_body: String::new(),
528            owner: None,
529            grants: vec![],
530            storage: crate::ir::reloptions::MaterializedViewStorageOptions::default(),
531        }
532    }
533
534    // ------------------------------------------------------------------ //
535    // OR-REPLACE compatibility predicate tests
536    // ------------------------------------------------------------------ //
537
538    #[test]
539    fn identical_column_lists_are_compatible() {
540        let cols = vec![col("id", ColumnType::BigInt), col("name", ColumnType::Text)];
541        assert!(or_replace_compatible(&cols, &cols));
542    }
543
544    #[test]
545    fn appending_columns_is_compatible() {
546        let catalog = vec![col("id", ColumnType::BigInt)];
547        let source = vec![col("id", ColumnType::BigInt), col("name", ColumnType::Text)];
548        assert!(or_replace_compatible(&catalog, &source));
549    }
550
551    #[test]
552    fn renaming_a_column_is_incompatible() {
553        let catalog = vec![col("id", ColumnType::BigInt)];
554        let source = vec![col("user_id", ColumnType::BigInt)];
555        assert!(!or_replace_compatible(&catalog, &source));
556    }
557
558    #[test]
559    fn dropping_a_column_is_incompatible() {
560        let catalog = vec![col("id", ColumnType::BigInt), col("name", ColumnType::Text)];
561        let source = vec![col("id", ColumnType::BigInt)];
562        assert!(!or_replace_compatible(&catalog, &source));
563    }
564
565    #[test]
566    fn reordering_is_incompatible() {
567        let catalog = vec![col("id", ColumnType::BigInt), col("name", ColumnType::Text)];
568        let source = vec![col("name", ColumnType::Text), col("id", ColumnType::BigInt)];
569        assert!(!or_replace_compatible(&catalog, &source));
570    }
571
572    #[test]
573    fn type_change_is_incompatible() {
574        let catalog = vec![col("id", ColumnType::Integer)];
575        let source = vec![col("id", ColumnType::BigInt)];
576        assert!(!or_replace_compatible(&catalog, &source));
577    }
578
579    // ------------------------------------------------------------------ //
580    // diff_views tests
581    // ------------------------------------------------------------------ //
582
583    #[test]
584    fn view_only_in_source_is_create() {
585        let src = vec![simple_view(qn("app", "v"), "SELECT 1", vec![])];
586        let mut out = ChangeSet::new();
587        diff_views(&[], &src, &mut out, &BTreeSet::new());
588        let changes = &out.entries;
589        assert_eq!(changes.len(), 1);
590        assert!(matches!(
591            &changes[0].change,
592            Change::View(ViewChange::Create(_))
593        ));
594    }
595
596    #[test]
597    fn view_only_in_target_is_drop() {
598        let tgt = vec![simple_view(qn("app", "v"), "SELECT 1", vec![])];
599        let mut out = ChangeSet::new();
600        diff_views(&tgt, &[], &mut out, &BTreeSet::new());
601        let changes = &out.entries;
602        assert_eq!(changes.len(), 1);
603        assert!(matches!(
604            &changes[0].change,
605            Change::View(ViewChange::Drop(_))
606        ));
607        assert!(changes[0].destructiveness.requires_approval());
608    }
609
610    #[test]
611    fn view_body_change_with_compatible_columns_is_replace_body_compatible_true() {
612        let cols = vec![col("id", ColumnType::BigInt)];
613        let tgt = vec![simple_view(qn("app", "v"), "SELECT 1 AS id", cols.clone())];
614        let src = vec![simple_view(qn("app", "v"), "SELECT 2 AS id", cols)];
615        let mut out = ChangeSet::new();
616        diff_views(&tgt, &src, &mut out, &BTreeSet::new());
617        let changes = &out.entries;
618        assert_eq!(changes.len(), 1);
619        match &changes[0].change {
620            Change::View(ViewChange::ReplaceBody { compatible, .. }) => {
621                assert!(*compatible, "expected compatible=true");
622            }
623            other => panic!("unexpected change: {other:?}"),
624        }
625    }
626
627    #[test]
628    fn view_body_change_with_incompatible_columns_is_replace_body_compatible_false() {
629        let tgt_cols = vec![col("id", ColumnType::Integer)];
630        let src_cols = vec![col("id", ColumnType::BigInt)];
631        let tgt = vec![simple_view(qn("app", "v"), "SELECT 1::int AS id", tgt_cols)];
632        let src = vec![simple_view(
633            qn("app", "v"),
634            "SELECT 2::bigint AS id",
635            src_cols,
636        )];
637        let mut out = ChangeSet::new();
638        diff_views(&tgt, &src, &mut out, &BTreeSet::new());
639        let changes = &out.entries;
640        assert_eq!(changes.len(), 1);
641        match &changes[0].change {
642            Change::View(ViewChange::ReplaceBody { compatible, .. }) => {
643                assert!(!*compatible, "expected compatible=false");
644            }
645            other => panic!("unexpected change: {other:?}"),
646        }
647    }
648
649    #[test]
650    fn view_security_barrier_change_emits_set_reloption() {
651        let body_sql = "SELECT 1";
652        let tgt = vec![{
653            let mut v = simple_view(qn("app", "v"), body_sql, vec![]);
654            v.security_barrier = Some(false);
655            v
656        }];
657        let src = vec![{
658            let mut v = simple_view(qn("app", "v"), body_sql, vec![]);
659            v.security_barrier = Some(true);
660            v
661        }];
662        let mut out = ChangeSet::new();
663        diff_views(&tgt, &src, &mut out, &BTreeSet::new());
664        let changes = &out.entries;
665        assert_eq!(changes.len(), 1);
666        assert!(matches!(
667            &changes[0].change,
668            Change::View(ViewChange::SetReloption { .. })
669        ));
670    }
671
672    #[test]
673    fn identical_views_emit_no_changes() {
674        let v = simple_view(qn("app", "v"), "SELECT 1", vec![]);
675        let mut out = ChangeSet::new();
676        diff_views(
677            std::slice::from_ref(&v),
678            std::slice::from_ref(&v),
679            &mut out,
680            &BTreeSet::new(),
681        );
682        assert!(out.is_empty());
683    }
684
685    // ------------------------------------------------------------------ //
686    // diff_materialized_views tests
687    // ------------------------------------------------------------------ //
688
689    #[test]
690    fn mv_only_in_source_is_create() {
691        let src = vec![simple_mv(qn("app", "mv"), "SELECT 1", vec![])];
692        let mut out = ChangeSet::new();
693        diff_materialized_views(&[], &src, &mut out, &BTreeSet::new());
694        let changes = &out.entries;
695        assert_eq!(changes.len(), 1);
696        assert!(matches!(
697            &changes[0].change,
698            Change::Mv(MvChange::Create(_))
699        ));
700    }
701
702    #[test]
703    fn mv_drop_does_not_set_destructive() {
704        let tgt = vec![simple_mv(qn("app", "mv"), "SELECT 1", vec![])];
705        let mut out = ChangeSet::new();
706        diff_materialized_views(&tgt, &[], &mut out, &BTreeSet::new());
707        let changes = &out.entries;
708        assert_eq!(changes.len(), 1);
709        assert!(matches!(&changes[0].change, Change::Mv(MvChange::Drop(_))));
710        // MVs are derived data — drop is Safe.
711        assert!(!changes[0].destructiveness.requires_approval());
712    }
713
714    #[test]
715    fn identical_mvs_emit_no_changes() {
716        let mv = simple_mv(qn("app", "mv"), "SELECT 1", vec![]);
717        let mut out = ChangeSet::new();
718        diff_materialized_views(
719            std::slice::from_ref(&mv),
720            std::slice::from_ref(&mv),
721            &mut out,
722            &BTreeSet::new(),
723        );
724        assert!(out.is_empty());
725    }
726}