1use 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
31pub(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#[allow(clippy::too_many_lines)] pub 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 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 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 for (qname, src) in &source_map {
101 let Some(tgt) = target_map.get(qname) else {
102 continue;
103 };
104
105 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 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 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 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 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#[allow(clippy::too_many_lines)] pub 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 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 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 for (qname, src) in &source_map {
219 let Some(tgt) = target_map.get(qname) else {
220 continue;
221 };
222
223 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 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 diff_mv_column_comments(qname, &tgt.columns, &src.columns, out);
247
248 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
273fn 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
302fn 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 #[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 #[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 #[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 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}