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