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)]
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 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.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 diff_view_column_comments(qname, &tgt.columns, &src.columns, out);
146
147 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 {
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#[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 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 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 for (qname, src) in &source_map {
281 let Some(tgt) = target_map.get(qname) else {
282 continue;
283 };
284
285 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 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 diff_mv_column_comments(qname, &tgt.columns, &src.columns, out);
309
310 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 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 {
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
411fn 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
440fn 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 #[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 #[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 #[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 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}