1use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::identifier::{Identifier, QualifiedName};
12use crate::ir::column_type::ColumnType;
13use crate::ir::difference::Difference;
14use crate::ir::eq::{Diff, diff_field};
15use crate::parse::normalize_body::NormalizedBody;
16use crate::plan::edges::DepEdge;
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
38pub struct ViewColumn {
39 pub name: Identifier,
41 pub column_type: ColumnType,
47 pub comment: Option<String>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
58pub struct View {
59 pub qname: QualifiedName,
61 pub columns: Vec<ViewColumn>,
63 pub body_canonical: NormalizedBody,
65 pub body_dependencies: Vec<DepEdge>,
67 pub security_barrier: Option<bool>,
69 pub security_invoker: Option<bool>,
71 pub comment: Option<String>,
73 #[serde(skip, default)]
78 pub raw_body: String,
79 pub owner: Option<Identifier>,
82 pub grants: Vec<crate::ir::grant::Grant>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
92pub struct MaterializedView {
93 pub qname: QualifiedName,
95 pub columns: Vec<ViewColumn>,
97 pub body_canonical: NormalizedBody,
99 pub body_dependencies: Vec<DepEdge>,
101 pub comment: Option<String>,
103 #[serde(skip, default)]
108 pub raw_body: String,
109 pub owner: Option<Identifier>,
112 pub grants: Vec<crate::ir::grant::Grant>,
114 pub storage: crate::ir::reloptions::MaterializedViewStorageOptions,
116}
117
118impl Diff for View {
119 fn diff(&self, other: &Self) -> Vec<Difference> {
120 let mut out = Vec::new();
121 out.extend(diff_field("qname", &self.qname, &other.qname));
122 out.extend(diff_field(
123 "body_canonical",
124 &self.body_canonical.canonical_text(),
125 &other.body_canonical.canonical_text(),
126 ));
127 out.extend(diff_field(
128 "security_barrier",
129 &format!("{:?}", self.security_barrier),
130 &format!("{:?}", other.security_barrier),
131 ));
132 out.extend(diff_field(
133 "security_invoker",
134 &format!("{:?}", self.security_invoker),
135 &format!("{:?}", other.security_invoker),
136 ));
137 out.extend(diff_field(
138 "comment",
139 &format!("{:?}", self.comment),
140 &format!("{:?}", other.comment),
141 ));
142 out.extend(diff_field(
143 "owner",
144 &format!("{:?}", self.owner),
145 &format!("{:?}", other.owner),
146 ));
147 out.extend(diff_field(
148 "grants",
149 &format!("{:?}", self.grants),
150 &format!("{:?}", other.grants),
151 ));
152
153 let lhs: BTreeMap<_, _> = self.columns.iter().map(|c| (c.name.as_str(), c)).collect();
155 let rhs: BTreeMap<_, _> = other.columns.iter().map(|c| (c.name.as_str(), c)).collect();
156 for (name, l) in &lhs {
157 match rhs.get(name) {
158 None => out.push(Difference::new(
159 format!("columns.{name}"),
160 "present",
161 "removed",
162 )),
163 Some(r) => {
164 if l.column_type != r.column_type {
165 out.push(Difference::new(
166 format!("columns.{name}.column_type"),
167 l.column_type.render_sql(),
168 r.column_type.render_sql(),
169 ));
170 }
171 if l.comment != r.comment {
172 out.push(Difference::new(
173 format!("columns.{name}.comment"),
174 format!("{:?}", l.comment),
175 format!("{:?}", r.comment),
176 ));
177 }
178 }
179 }
180 }
181 for name in rhs.keys() {
182 if !lhs.contains_key(name) {
183 out.push(Difference::new(
184 format!("columns.{name}"),
185 "missing",
186 "added",
187 ));
188 }
189 }
190 let lhs_order: Vec<&str> = self.columns.iter().map(|c| c.name.as_str()).collect();
191 let rhs_order: Vec<&str> = other.columns.iter().map(|c| c.name.as_str()).collect();
192 if lhs_order != rhs_order {
193 out.push(Difference::new(
194 "columns.<order>",
195 lhs_order.join(","),
196 rhs_order.join(","),
197 ));
198 }
199
200 out.extend(diff_field(
202 "body_dependencies",
203 &format!("{:?}", self.body_dependencies),
204 &format!("{:?}", other.body_dependencies),
205 ));
206
207 out
208 }
209}
210
211impl Diff for MaterializedView {
212 fn diff(&self, other: &Self) -> Vec<Difference> {
213 let mut out = Vec::new();
214 out.extend(diff_field("qname", &self.qname, &other.qname));
215 out.extend(diff_field(
216 "body_canonical",
217 &self.body_canonical.canonical_text(),
218 &other.body_canonical.canonical_text(),
219 ));
220 out.extend(diff_field(
221 "comment",
222 &format!("{:?}", self.comment),
223 &format!("{:?}", other.comment),
224 ));
225 out.extend(diff_field(
226 "owner",
227 &format!("{:?}", self.owner),
228 &format!("{:?}", other.owner),
229 ));
230 out.extend(diff_field(
231 "grants",
232 &format!("{:?}", self.grants),
233 &format!("{:?}", other.grants),
234 ));
235 out.extend(diff_field(
236 "storage",
237 &format!("{:?}", self.storage),
238 &format!("{:?}", other.storage),
239 ));
240
241 let lhs: BTreeMap<_, _> = self.columns.iter().map(|c| (c.name.as_str(), c)).collect();
243 let rhs: BTreeMap<_, _> = other.columns.iter().map(|c| (c.name.as_str(), c)).collect();
244 for (name, l) in &lhs {
245 match rhs.get(name) {
246 None => out.push(Difference::new(
247 format!("columns.{name}"),
248 "present",
249 "removed",
250 )),
251 Some(r) => {
252 if l.column_type != r.column_type {
253 out.push(Difference::new(
254 format!("columns.{name}.column_type"),
255 l.column_type.render_sql(),
256 r.column_type.render_sql(),
257 ));
258 }
259 if l.comment != r.comment {
260 out.push(Difference::new(
261 format!("columns.{name}.comment"),
262 format!("{:?}", l.comment),
263 format!("{:?}", r.comment),
264 ));
265 }
266 }
267 }
268 }
269 for name in rhs.keys() {
270 if !lhs.contains_key(name) {
271 out.push(Difference::new(
272 format!("columns.{name}"),
273 "missing",
274 "added",
275 ));
276 }
277 }
278 let lhs_order: Vec<&str> = self.columns.iter().map(|c| c.name.as_str()).collect();
279 let rhs_order: Vec<&str> = other.columns.iter().map(|c| c.name.as_str()).collect();
280 if lhs_order != rhs_order {
281 out.push(Difference::new(
282 "columns.<order>",
283 lhs_order.join(","),
284 rhs_order.join(","),
285 ));
286 }
287
288 out.extend(diff_field(
290 "body_dependencies",
291 &format!("{:?}", self.body_dependencies),
292 &format!("{:?}", other.body_dependencies),
293 ));
294
295 out
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use crate::ir::IrError;
303 use crate::ir::catalog::Catalog;
304 use crate::ir::column_type::ColumnType;
305 use crate::plan::edges::{DepSource, NodeId};
306
307 fn id(s: &str) -> Identifier {
308 Identifier::from_unquoted(s).unwrap()
309 }
310
311 fn qn(schema: &str, name: &str) -> QualifiedName {
312 QualifiedName::new(id(schema), id(name))
313 }
314
315 fn body(sql: &str) -> NormalizedBody {
316 NormalizedBody::from_sql(sql).unwrap()
317 }
318
319 fn simple_view(schema: &str, name: &str) -> View {
320 View {
321 qname: qn(schema, name),
322 columns: vec![ViewColumn {
323 name: id("id"),
324 column_type: ColumnType::BigInt,
325 comment: None,
326 }],
327 body_canonical: body("SELECT 1"),
328 body_dependencies: vec![],
329 security_barrier: None,
330 security_invoker: None,
331 comment: None,
332 raw_body: String::new(),
333 owner: None,
334 grants: vec![],
335 }
336 }
337
338 fn simple_mv(schema: &str, name: &str) -> MaterializedView {
339 MaterializedView {
340 qname: qn(schema, name),
341 columns: vec![],
342 body_canonical: body("SELECT 1"),
343 body_dependencies: vec![],
344 comment: None,
345 raw_body: String::new(),
346 owner: None,
347 grants: vec![],
348 storage: crate::ir::reloptions::MaterializedViewStorageOptions::default(),
349 }
350 }
351
352 #[test]
353 fn views_with_equal_fields_compare_equal() {
354 let v1 = simple_view("app", "active_users");
355 let v2 = View {
356 qname: qn("app", "active_users"),
357 columns: vec![ViewColumn {
358 name: id("id"),
359 column_type: ColumnType::BigInt,
360 comment: None,
361 }],
362 body_canonical: body("SELECT 1"),
363 body_dependencies: vec![],
364 security_barrier: None,
365 security_invoker: None,
366 comment: None,
367 raw_body: String::new(),
368 owner: None,
369 grants: vec![],
370 };
371 assert_eq!(v1, v2);
372 }
373
374 #[test]
375 fn materialized_view_round_trips_through_serde() {
376 let mv = MaterializedView {
377 qname: qn("app", "summary"),
378 columns: vec![ViewColumn {
379 name: id("total"),
380 column_type: ColumnType::BigInt,
381 comment: Some("total count".to_string()),
382 }],
383 body_canonical: body("SELECT count(*) FROM users"),
384 body_dependencies: vec![DepEdge {
385 from: NodeId::Table(qn("app", "summary")),
386 to: NodeId::Table(qn("app", "users")),
387 source: DepSource::AstExtracted,
388 }],
389 comment: Some("materialized summary".to_string()),
390 raw_body: String::new(),
391 owner: None,
392 grants: vec![],
393 storage: crate::ir::reloptions::MaterializedViewStorageOptions::default(),
394 };
395 let json = serde_json::to_string(&mv).expect("serialization must succeed");
396 let roundtripped: MaterializedView =
397 serde_json::from_str(&json).expect("deserialization must succeed");
398 assert_eq!(mv, roundtripped);
399 }
400
401 #[test]
402 fn catalog_with_views_canonicalizes() {
403 let mut c = Catalog::empty();
404 c.views.push(simple_view("app", "zzz_view"));
405 c.views.push(simple_view("app", "aaa_view"));
406 c.materialized_views.push(simple_mv("app", "zzz_mv"));
407 c.materialized_views.push(simple_mv("app", "aaa_mv"));
408
409 let result = c.canonicalize();
410 assert!(result.is_ok(), "canonicalize should succeed: {result:?}");
411 let canonical = result.unwrap();
412
413 assert_eq!(canonical.views[0].qname, qn("app", "aaa_view"));
414 assert_eq!(canonical.views[1].qname, qn("app", "zzz_view"));
415 assert_eq!(canonical.materialized_views[0].qname, qn("app", "aaa_mv"));
416 assert_eq!(canonical.materialized_views[1].qname, qn("app", "zzz_mv"));
417 }
418
419 #[test]
420 fn catalog_rejects_duplicate_view_qname() {
421 let mut c = Catalog::empty();
422 c.views.push(simple_view("app", "my_view"));
423 c.views.push(simple_view("app", "my_view"));
424
425 let result = c.canonicalize();
426 assert!(
427 matches!(result, Err(IrError::InvalidIdentifier(_))),
428 "expected duplicate-view error, got: {result:?}",
429 );
430 }
431
432 #[test]
433 fn catalog_rejects_duplicate_materialized_view_qname() {
434 let mut c = Catalog::empty();
435 c.materialized_views.push(simple_mv("app", "my_mv"));
436 c.materialized_views.push(simple_mv("app", "my_mv"));
437
438 let result = c.canonicalize();
439 assert!(
440 matches!(result, Err(IrError::InvalidIdentifier(_))),
441 "expected duplicate-mv error, got: {result:?}",
442 );
443 }
444
445 #[test]
446 fn view_owner_change_diffs() {
447 use crate::ir::eq::Diff;
448 let mut b = simple_view("app", "active_users");
449 b.owner = Some(id("new_owner"));
450 assert!(
451 simple_view("app", "active_users")
452 .diff(&b)
453 .iter()
454 .any(|x| x.path == "owner")
455 );
456 }
457
458 #[test]
459 fn view_grants_change_diffs() {
460 use crate::ir::eq::Diff;
461 let mut b = simple_view("app", "active_users");
462 b.grants.push(crate::ir::grant::Grant {
463 grantee: crate::ir::grant::GrantTarget::Public,
464 privilege: crate::ir::grant::Privilege::Select,
465 with_grant_option: false,
466 columns: None,
467 });
468 assert!(
469 simple_view("app", "active_users")
470 .diff(&b)
471 .iter()
472 .any(|x| x.path == "grants")
473 );
474 }
475
476 #[test]
477 fn materialized_view_owner_change_diffs() {
478 use crate::ir::eq::Diff;
479 let mut b = simple_mv("app", "my_mv");
480 b.owner = Some(id("new_owner"));
481 assert!(
482 simple_mv("app", "my_mv")
483 .diff(&b)
484 .iter()
485 .any(|x| x.path == "owner")
486 );
487 }
488
489 #[test]
490 fn materialized_view_grants_change_diffs() {
491 use crate::ir::eq::Diff;
492 let mut b = simple_mv("app", "my_mv");
493 b.grants.push(crate::ir::grant::Grant {
494 grantee: crate::ir::grant::GrantTarget::Public,
495 privilege: crate::ir::grant::Privilege::Select,
496 with_grant_option: false,
497 columns: None,
498 });
499 assert!(
500 simple_mv("app", "my_mv")
501 .diff(&b)
502 .iter()
503 .any(|x| x.path == "grants")
504 );
505 }
506
507 #[test]
508 fn materialized_view_storage_change_diffs() {
509 use crate::ir::eq::Diff;
510 let mut b = simple_mv("app", "my_mv");
511 b.storage = crate::ir::reloptions::MaterializedViewStorageOptions {
512 fillfactor: Some(80),
513 ..Default::default()
514 };
515 assert!(
516 simple_mv("app", "my_mv")
517 .diff(&b)
518 .iter()
519 .any(|x| x.path == "storage")
520 );
521 }
522}