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::view::{MaterializedView, View, ViewColumn};
24
25use super::change::{BodyReplaceStrategy, Change, MvChange, ViewChange};
26use super::changeset::ChangeSet;
27use super::destructiveness::Destructiveness;
28use super::owner_grants::{ColumnGrantMode, diff_owner_and_grants};
29use super::owner_op::CatalogObjectRef;
30
31/// Per Postgres's `CREATE OR REPLACE VIEW` rules: the new column list must be
32/// a non-shrinking superset of the existing list, with the same names **and
33/// types** at the same indexes. New columns may be appended at the end.
34///
35/// `catalog` is the existing column list (live DB / target).
36/// `source` is the desired column list (source SQL).
37pub(crate) fn or_replace_compatible(catalog: &[ViewColumn], source: &[ViewColumn]) -> bool {
38    if source.len() < catalog.len() {
39        return false;
40    }
41    for (i, cat_col) in catalog.iter().enumerate() {
42        let src_col = &source[i];
43        if cat_col.name != src_col.name || cat_col.column_type != src_col.column_type {
44            return false;
45        }
46    }
47    true
48}
49
50/// Diff `target.views` (live DB) against `source.views` (desired).
51///
52/// Pairs views by [`QualifiedName`] and emits:
53/// - [`ViewChange::Create`] for views present only in source.
54/// - [`ViewChange::Drop`] for views present only in target.
55/// - [`ViewChange::ReplaceBody`] when the canonical body hashes differ.
56/// - [`ViewChange::SetReloption`] when `security_barrier` / `security_invoker`
57///   differ (independent of body changes).
58/// - [`ViewChange::SetComment`] / [`ViewChange::SetColumnComment`] for metadata
59///   changes.
60/// - [`Change::AlterObjectOwner`] / grant changes when applicable.
61///
62/// No change is emitted when target and source are byte-for-byte identical.
63#[allow(clippy::too_many_lines)] // exhaustive per-view-property diff with create / replace / drop branches.
64pub fn diff_views(
65    target_views: &[View],
66    source_views: &[View],
67    out: &mut ChangeSet,
68    managed_roles: &BTreeSet<Identifier>,
69) {
70    use std::collections::BTreeMap;
71
72    let target_map: BTreeMap<&QualifiedName, &View> =
73        target_views.iter().map(|v| (&v.qname, v)).collect();
74    let source_map: BTreeMap<&QualifiedName, &View> =
75        source_views.iter().map(|v| (&v.qname, v)).collect();
76
77    // Views in source but not target → Create.
78    for (qname, src) in &source_map {
79        if !target_map.contains_key(qname) {
80            out.push(
81                Change::View(ViewChange::Create((*src).clone())),
82                Destructiveness::Safe,
83            );
84        }
85    }
86
87    // Views in target but not source → Drop.
88    for qname in target_map.keys() {
89        if !source_map.contains_key(qname) {
90            out.push(
91                Change::View(ViewChange::Drop((*qname).clone())),
92                Destructiveness::RequiresApproval {
93                    reason: format!("drops view {qname}"),
94                },
95            );
96        }
97    }
98
99    // Views present in both — check for changes.
100    for (qname, src) in &source_map {
101        let Some(tgt) = target_map.get(qname) else {
102            continue;
103        };
104
105        // Body change: compare canonical hashes.
106        if src.body_canonical.canonical_hash() != tgt.body_canonical.canonical_hash() {
107            let strategy = if or_replace_compatible(&tgt.columns, &src.columns) {
108                BodyReplaceStrategy::InPlace
109            } else {
110                BodyReplaceStrategy::Recreate
111            };
112            out.push(
113                Change::View(ViewChange::ReplaceBody {
114                    source: (*src).clone(),
115                    catalog: (*tgt).clone(),
116                    strategy,
117                }),
118                Destructiveness::Safe,
119            );
120        }
121
122        // Reloption changes (independent of body).
123        if src.security_barrier != tgt.security_barrier
124            || src.security_invoker != tgt.security_invoker
125        {
126            out.push(
127                Change::View(ViewChange::SetReloption {
128                    qname: (*qname).clone(),
129                    security_barrier: src.security_barrier,
130                    security_invoker: src.security_invoker,
131                }),
132                Destructiveness::Safe,
133            );
134        }
135
136        // Check option change (lenient — only managed when source declares it).
137        if src.check_option != tgt.check_option {
138            out.push(
139                Change::AlterViewSetCheckOption {
140                    qname: (*qname).clone(),
141                    new_value: src.check_option,
142                },
143                Destructiveness::Safe,
144            );
145        }
146
147        // View-level comment change.
148        if src.comment != tgt.comment {
149            out.push(
150                Change::View(ViewChange::SetComment {
151                    qname: (*qname).clone(),
152                    comment: src.comment.clone(),
153                }),
154                Destructiveness::Safe,
155            );
156        }
157
158        // Column comment changes: compare by name at matching positions.
159        diff_view_column_comments(qname, &tgt.columns, &src.columns, out);
160
161        diff_owner_and_grants(
162            &CatalogObjectRef::View((*qname).clone()),
163            tgt.owner.as_ref(),
164            src.owner.as_ref(),
165            &tgt.grants,
166            &src.grants,
167            managed_roles,
168            ColumnGrantMode::ColumnAware,
169            out,
170        );
171    }
172}
173
174/// Diff `target.materialized_views` (live DB) against
175/// `source.materialized_views` (desired).
176///
177/// Emits:
178/// - [`MvChange::Create`] for MVs present only in source.
179/// - [`MvChange::Drop`] for MVs present only in target (Safe: MVs are derived).
180/// - [`MvChange::ReplaceBody`] when the canonical body hashes differ.
181/// - [`MvChange::SetComment`] / [`MvChange::SetColumnComment`] for metadata.
182/// - [`Change::AlterObjectOwner`] / grant changes when applicable.
183#[allow(clippy::too_many_lines)] // exhaustive per-MV-property diff with create / replace / drop branches.
184pub fn diff_materialized_views(
185    target_mvs: &[MaterializedView],
186    source_mvs: &[MaterializedView],
187    out: &mut ChangeSet,
188    managed_roles: &BTreeSet<Identifier>,
189) {
190    use std::collections::BTreeMap;
191
192    let target_map: BTreeMap<&QualifiedName, &MaterializedView> =
193        target_mvs.iter().map(|m| (&m.qname, m)).collect();
194    let source_map: BTreeMap<&QualifiedName, &MaterializedView> =
195        source_mvs.iter().map(|m| (&m.qname, m)).collect();
196
197    // MVs in source but not target → Create.
198    for (qname, src) in &source_map {
199        if !target_map.contains_key(qname) {
200            out.push(
201                Change::Mv(MvChange::Create((*src).clone())),
202                Destructiveness::Safe,
203            );
204        }
205    }
206
207    // MVs in target but not source → Drop (Safe: MVs are derived data).
208    for qname in target_map.keys() {
209        if !source_map.contains_key(qname) {
210            out.push(
211                Change::Mv(MvChange::Drop((*qname).clone())),
212                Destructiveness::Safe,
213            );
214        }
215    }
216
217    // MVs present in both — check for changes.
218    for (qname, src) in &source_map {
219        let Some(tgt) = target_map.get(qname) else {
220            continue;
221        };
222
223        // Body change: compare canonical hashes.
224        if src.body_canonical.canonical_hash() != tgt.body_canonical.canonical_hash() {
225            out.push(
226                Change::Mv(MvChange::ReplaceBody {
227                    source: (*src).clone(),
228                    catalog: (*tgt).clone(),
229                }),
230                Destructiveness::Safe,
231            );
232        }
233
234        // MV-level comment change.
235        if src.comment != tgt.comment {
236            out.push(
237                Change::Mv(MvChange::SetComment {
238                    qname: (*qname).clone(),
239                    comment: src.comment.clone(),
240                }),
241                Destructiveness::Safe,
242            );
243        }
244
245        // Column comment changes.
246        diff_mv_column_comments(qname, &tgt.columns, &src.columns, out);
247
248        // ---- storage reloptions diff ----
249        let delta = crate::diff::reloptions::table_delta(&tgt.storage, &src.storage);
250        if !delta.is_empty() {
251            out.push(
252                Change::SetMaterializedViewStorage {
253                    qname: (*qname).clone(),
254                    options: delta,
255                },
256                Destructiveness::Safe,
257            );
258        }
259
260        diff_owner_and_grants(
261            &CatalogObjectRef::MaterializedView((*qname).clone()),
262            tgt.owner.as_ref(),
263            src.owner.as_ref(),
264            &tgt.grants,
265            &src.grants,
266            managed_roles,
267            ColumnGrantMode::ColumnAware,
268            out,
269        );
270    }
271}
272
273/// Emit [`ViewChange::SetColumnComment`] for each column where the comment
274/// differs between target and source.
275fn diff_view_column_comments(
276    qname: &QualifiedName,
277    target_cols: &[ViewColumn],
278    source_cols: &[ViewColumn],
279    out: &mut ChangeSet,
280) {
281    use std::collections::BTreeMap;
282
283    let tgt_map: BTreeMap<_, _> = target_cols.iter().map(|c| (&c.name, c)).collect();
284    let src_map: BTreeMap<_, _> = source_cols.iter().map(|c| (&c.name, c)).collect();
285
286    for (name, src_col) in &src_map {
287        let tgt_comment = tgt_map.get(name).and_then(|c| c.comment.as_deref());
288        let src_comment = src_col.comment.as_deref();
289        if src_comment != tgt_comment {
290            out.push(
291                Change::View(ViewChange::SetColumnComment {
292                    qname: qname.clone(),
293                    column: (*name).clone(),
294                    comment: src_col.comment.clone(),
295                }),
296                Destructiveness::Safe,
297            );
298        }
299    }
300}
301
302/// Emit [`MvChange::SetColumnComment`] for each column where the comment
303/// differs between target and source.
304fn diff_mv_column_comments(
305    qname: &QualifiedName,
306    target_cols: &[ViewColumn],
307    source_cols: &[ViewColumn],
308    out: &mut ChangeSet,
309) {
310    use std::collections::BTreeMap;
311
312    let tgt_map: BTreeMap<_, _> = target_cols.iter().map(|c| (&c.name, c)).collect();
313    let src_map: BTreeMap<_, _> = source_cols.iter().map(|c| (&c.name, c)).collect();
314
315    for (name, src_col) in &src_map {
316        let tgt_comment = tgt_map.get(name).and_then(|c| c.comment.as_deref());
317        let src_comment = src_col.comment.as_deref();
318        if src_comment != tgt_comment {
319            out.push(
320                Change::Mv(MvChange::SetColumnComment {
321                    qname: qname.clone(),
322                    column: (*name).clone(),
323                    comment: src_col.comment.clone(),
324                }),
325                Destructiveness::Safe,
326            );
327        }
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use std::collections::BTreeSet;
335
336    use crate::identifier::Identifier;
337    use crate::ir::column_type::ColumnType;
338    use crate::ir::view::{MaterializedView, View, ViewColumn};
339    use crate::parse::normalize_body::NormalizedBody;
340
341    fn id(s: &str) -> Identifier {
342        Identifier::from_unquoted(s).unwrap()
343    }
344
345    fn qn(schema: &str, name: &str) -> QualifiedName {
346        QualifiedName::new(id(schema), id(name))
347    }
348
349    fn body(sql: &str) -> NormalizedBody {
350        NormalizedBody::from_sql(sql).unwrap()
351    }
352
353    fn col(name: &str, ty: ColumnType) -> ViewColumn {
354        ViewColumn {
355            name: id(name),
356            column_type: Some(ty),
357            comment: None,
358        }
359    }
360
361    fn simple_view(qname: QualifiedName, body_sql: &str, cols: Vec<ViewColumn>) -> View {
362        View {
363            qname,
364            columns: cols,
365            body_canonical: body(body_sql),
366            body_dependencies: vec![],
367            security_barrier: None,
368            security_invoker: None,
369            check_option: None,
370            comment: None,
371            raw_body: String::new(),
372            owner: None,
373            grants: vec![],
374        }
375    }
376
377    fn simple_mv(qname: QualifiedName, body_sql: &str, cols: Vec<ViewColumn>) -> MaterializedView {
378        MaterializedView {
379            qname,
380            columns: cols,
381            body_canonical: body(body_sql),
382            body_dependencies: vec![],
383            comment: None,
384            raw_body: String::new(),
385            owner: None,
386            grants: vec![],
387            storage: crate::ir::reloptions::MaterializedViewStorageOptions::default(),
388        }
389    }
390
391    // ------------------------------------------------------------------ //
392    // OR-REPLACE compatibility predicate tests
393    // ------------------------------------------------------------------ //
394
395    #[test]
396    fn identical_column_lists_are_compatible() {
397        let cols = vec![col("id", ColumnType::BigInt), col("name", ColumnType::Text)];
398        assert!(or_replace_compatible(&cols, &cols));
399    }
400
401    #[test]
402    fn appending_columns_is_compatible() {
403        let catalog = vec![col("id", ColumnType::BigInt)];
404        let source = vec![col("id", ColumnType::BigInt), col("name", ColumnType::Text)];
405        assert!(or_replace_compatible(&catalog, &source));
406    }
407
408    #[test]
409    fn renaming_a_column_is_incompatible() {
410        let catalog = vec![col("id", ColumnType::BigInt)];
411        let source = vec![col("user_id", ColumnType::BigInt)];
412        assert!(!or_replace_compatible(&catalog, &source));
413    }
414
415    #[test]
416    fn dropping_a_column_is_incompatible() {
417        let catalog = vec![col("id", ColumnType::BigInt), col("name", ColumnType::Text)];
418        let source = vec![col("id", ColumnType::BigInt)];
419        assert!(!or_replace_compatible(&catalog, &source));
420    }
421
422    #[test]
423    fn reordering_is_incompatible() {
424        let catalog = vec![col("id", ColumnType::BigInt), col("name", ColumnType::Text)];
425        let source = vec![col("name", ColumnType::Text), col("id", ColumnType::BigInt)];
426        assert!(!or_replace_compatible(&catalog, &source));
427    }
428
429    #[test]
430    fn type_change_is_incompatible() {
431        let catalog = vec![col("id", ColumnType::Integer)];
432        let source = vec![col("id", ColumnType::BigInt)];
433        assert!(!or_replace_compatible(&catalog, &source));
434    }
435
436    // ------------------------------------------------------------------ //
437    // diff_views tests
438    // ------------------------------------------------------------------ //
439
440    #[test]
441    fn view_only_in_source_is_create() {
442        let src = vec![simple_view(qn("app", "v"), "SELECT 1", vec![])];
443        let mut out = ChangeSet::new();
444        diff_views(&[], &src, &mut out, &BTreeSet::new());
445        let changes = &out.entries;
446        assert_eq!(changes.len(), 1);
447        assert!(matches!(
448            &changes[0].change,
449            Change::View(ViewChange::Create(_))
450        ));
451    }
452
453    #[test]
454    fn view_only_in_target_is_drop() {
455        let tgt = vec![simple_view(qn("app", "v"), "SELECT 1", vec![])];
456        let mut out = ChangeSet::new();
457        diff_views(&tgt, &[], &mut out, &BTreeSet::new());
458        let changes = &out.entries;
459        assert_eq!(changes.len(), 1);
460        assert!(matches!(
461            &changes[0].change,
462            Change::View(ViewChange::Drop(_))
463        ));
464        assert!(changes[0].destructiveness.requires_approval());
465    }
466
467    #[test]
468    fn view_body_change_with_compatible_columns_is_replace_body_compatible_true() {
469        let cols = vec![col("id", ColumnType::BigInt)];
470        let tgt = vec![simple_view(qn("app", "v"), "SELECT 1 AS id", cols.clone())];
471        let src = vec![simple_view(qn("app", "v"), "SELECT 2 AS id", cols)];
472        let mut out = ChangeSet::new();
473        diff_views(&tgt, &src, &mut out, &BTreeSet::new());
474        let changes = &out.entries;
475        assert_eq!(changes.len(), 1);
476        match &changes[0].change {
477            Change::View(ViewChange::ReplaceBody { strategy, .. }) => {
478                assert_eq!(
479                    *strategy,
480                    BodyReplaceStrategy::InPlace,
481                    "expected InPlace strategy"
482                );
483            }
484            other => panic!("unexpected change: {other:?}"),
485        }
486    }
487
488    #[test]
489    fn view_body_change_with_incompatible_columns_is_replace_body_compatible_false() {
490        let tgt_cols = vec![col("id", ColumnType::Integer)];
491        let src_cols = vec![col("id", ColumnType::BigInt)];
492        let tgt = vec![simple_view(qn("app", "v"), "SELECT 1::int AS id", tgt_cols)];
493        let src = vec![simple_view(
494            qn("app", "v"),
495            "SELECT 2::bigint AS id",
496            src_cols,
497        )];
498        let mut out = ChangeSet::new();
499        diff_views(&tgt, &src, &mut out, &BTreeSet::new());
500        let changes = &out.entries;
501        assert_eq!(changes.len(), 1);
502        match &changes[0].change {
503            Change::View(ViewChange::ReplaceBody { strategy, .. }) => {
504                assert_eq!(
505                    *strategy,
506                    BodyReplaceStrategy::Recreate,
507                    "expected Recreate strategy"
508                );
509            }
510            other => panic!("unexpected change: {other:?}"),
511        }
512    }
513
514    #[test]
515    fn view_security_barrier_change_emits_set_reloption() {
516        let body_sql = "SELECT 1";
517        let tgt = vec![{
518            let mut v = simple_view(qn("app", "v"), body_sql, vec![]);
519            v.security_barrier = Some(false);
520            v
521        }];
522        let src = vec![{
523            let mut v = simple_view(qn("app", "v"), body_sql, vec![]);
524            v.security_barrier = Some(true);
525            v
526        }];
527        let mut out = ChangeSet::new();
528        diff_views(&tgt, &src, &mut out, &BTreeSet::new());
529        let changes = &out.entries;
530        assert_eq!(changes.len(), 1);
531        assert!(matches!(
532            &changes[0].change,
533            Change::View(ViewChange::SetReloption { .. })
534        ));
535    }
536
537    #[test]
538    fn identical_views_emit_no_changes() {
539        let v = simple_view(qn("app", "v"), "SELECT 1", vec![]);
540        let mut out = ChangeSet::new();
541        diff_views(
542            std::slice::from_ref(&v),
543            std::slice::from_ref(&v),
544            &mut out,
545            &BTreeSet::new(),
546        );
547        assert!(out.is_empty());
548    }
549
550    // ------------------------------------------------------------------ //
551    // diff_materialized_views tests
552    // ------------------------------------------------------------------ //
553
554    #[test]
555    fn mv_only_in_source_is_create() {
556        let src = vec![simple_mv(qn("app", "mv"), "SELECT 1", vec![])];
557        let mut out = ChangeSet::new();
558        diff_materialized_views(&[], &src, &mut out, &BTreeSet::new());
559        let changes = &out.entries;
560        assert_eq!(changes.len(), 1);
561        assert!(matches!(
562            &changes[0].change,
563            Change::Mv(MvChange::Create(_))
564        ));
565    }
566
567    #[test]
568    fn mv_drop_does_not_set_destructive() {
569        let tgt = vec![simple_mv(qn("app", "mv"), "SELECT 1", vec![])];
570        let mut out = ChangeSet::new();
571        diff_materialized_views(&tgt, &[], &mut out, &BTreeSet::new());
572        let changes = &out.entries;
573        assert_eq!(changes.len(), 1);
574        assert!(matches!(&changes[0].change, Change::Mv(MvChange::Drop(_))));
575        // MVs are derived data — drop is Safe.
576        assert!(!changes[0].destructiveness.requires_approval());
577    }
578
579    #[test]
580    fn identical_mvs_emit_no_changes() {
581        let mv = simple_mv(qn("app", "mv"), "SELECT 1", vec![]);
582        let mut out = ChangeSet::new();
583        diff_materialized_views(
584            std::slice::from_ref(&mv),
585            std::slice::from_ref(&mv),
586            &mut out,
587            &BTreeSet::new(),
588        );
589        assert!(out.is_empty());
590    }
591}