1use 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
32pub(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#[allow(clippy::too_many_lines)] pub 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 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 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 for (qname, src) in &source_map {
102 let Some(tgt) = target_map.get(qname) else {
103 continue;
104 };
105
106 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 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 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 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 diff_view_column_comments(qname, &tgt.columns, &src.columns, out);
157
158 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 {
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_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#[allow(clippy::too_many_lines)] pub 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 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 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 for (qname, src) in &source_map {
292 let Some(tgt) = target_map.get(qname) else {
293 continue;
294 };
295
296 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 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 diff_mv_column_comments(qname, &tgt.columns, &src.columns, out);
320
321 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 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 {
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 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
422fn 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
451fn 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 #[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 #[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 #[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 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}