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_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#[allow(clippy::too_many_lines)] pub 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 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 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 for (qname, src) in &source_map {
289 let Some(tgt) = target_map.get(qname) else {
290 continue;
291 };
292
293 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 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 diff_mv_column_comments(qname, &tgt.columns, &src.columns, out);
317
318 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 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 {
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
416fn 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
445fn 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 #[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 #[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 #[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 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}