Skip to main content

pgevolve_core/ir/
view.rs

1//! `View` and `MaterializedView` — Postgres view IR records.
2//!
3//! These types are the flat IR representation of views introduced in v0.2.
4//! They reference [`NormalizedBody`] for the canonicalized SELECT body and
5//! [`DepEdge`] for body-extracted dependency provenance.
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::identifier::{Identifier, QualifiedName};
12
13/// `WITH [LOCAL | CASCADED] CHECK OPTION` setting on a view.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub enum CheckOption {
16    /// `WITH LOCAL CHECK OPTION` — applies only to this view's predicate.
17    Local,
18    /// `WITH CASCADED CHECK OPTION` — applies through chained updatable views.
19    Cascaded,
20}
21use crate::ir::column_type::ColumnType;
22use crate::ir::difference::Difference;
23use crate::ir::eq::{Equiv, field_difference};
24use crate::parse::normalize_body::NormalizedBody;
25use crate::plan::edges::DepEdge;
26
27/// A single named column in a view or materialized view.
28///
29/// `column_type` is `None` while unresolved — when `ViewColumn` is built from
30/// an explicit alias list during parsing, the type requires resolving the
31/// SELECT body against the catalog. The AST-canonicalization pass fills it in.
32/// Resolution is enforced by `Catalog::canonicalize`: a `None` that survives
33/// to canon is an error, so a serialized catalog never carries an unresolved
34/// column type. When built from the live catalog the type is always `Some`.
35#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
36pub struct ViewColumn {
37    /// Column name as it appears in the view definition (or is aliased).
38    pub name: Identifier,
39    /// Resolved data type of the column, or `None` while unresolved.
40    pub column_type: Option<ColumnType>,
41    /// Optional `COMMENT ON COLUMN` text.
42    pub comment: Option<String>,
43}
44
45/// A Postgres `CREATE VIEW`.
46///
47/// The `body_canonical` is the parsed-and-deparsed SELECT statement in
48/// canonical form. `body_dependencies` lists the IR objects the body
49/// references, extracted from the AST (v0.2 task 4; initially empty until
50/// the AST-walk pass lands).
51#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
52pub struct View {
53    /// Schema-qualified view name.
54    pub qname: QualifiedName,
55    /// Explicit column alias list (empty when none was provided).
56    pub columns: Vec<ViewColumn>,
57    /// Canonical form of the SELECT body.
58    pub body_canonical: NormalizedBody,
59    /// Dependency edges extracted from the body AST.
60    pub body_dependencies: Vec<DepEdge>,
61    /// `WITH (security_barrier = ...)` option, if present.
62    pub security_barrier: Option<bool>,
63    /// `WITH (security_invoker = ...)` option, if present.
64    pub security_invoker: Option<bool>,
65    /// `WITH [LOCAL | CASCADED] CHECK OPTION`, when set in source.
66    /// `None` = unmanaged (lenient — operator may have set it out-of-band;
67    /// pgevolve neither sets nor resets unless source declares).
68    pub check_option: Option<CheckOption>,
69    /// Optional `COMMENT ON VIEW` text.
70    pub comment: Option<String>,
71    /// Raw SELECT body text from source SQL. Populated by the parser (T3);
72    /// consumed by the AST canonicalization pass (T4) to fill
73    /// `body_canonical` and `body_dependencies`. Not serialized to plan
74    /// output or JSON (T4 produces the canonical form which IS serialized).
75    #[serde(skip, default)]
76    pub raw_body: String,
77    /// Object owner. `None` = unmanaged (the differ ignores ownership).
78    /// `Some(role)` = managed: diff emits `ALTER VIEW ... OWNER TO role`.
79    pub owner: Option<Identifier>,
80    /// Grants on this object. Empty = no grants. Canonicalized.
81    pub grants: Vec<crate::ir::grant::Grant>,
82}
83
84/// A Postgres `CREATE MATERIALIZED VIEW`.
85///
86/// Unlike regular views, materialized views are physically stored.
87/// They lack the `security_barrier` / `security_invoker` options of regular
88/// views but are otherwise structurally similar.
89#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
90pub struct MaterializedView {
91    /// Schema-qualified materialized view name.
92    pub qname: QualifiedName,
93    /// Explicit column alias list (empty when none was provided).
94    pub columns: Vec<ViewColumn>,
95    /// Canonical form of the SELECT body.
96    pub body_canonical: NormalizedBody,
97    /// Dependency edges extracted from the body AST.
98    pub body_dependencies: Vec<DepEdge>,
99    /// Optional `COMMENT ON MATERIALIZED VIEW` text.
100    pub comment: Option<String>,
101    /// Raw SELECT body text from source SQL. Populated by the parser (T3);
102    /// consumed by the AST canonicalization pass (T4) to fill
103    /// `body_canonical` and `body_dependencies`. Not serialized to plan
104    /// output or JSON (T4 produces the canonical form which IS serialized).
105    #[serde(skip, default)]
106    pub raw_body: String,
107    /// Object owner. `None` = unmanaged (the differ ignores ownership).
108    /// `Some(role)` = managed: diff emits `ALTER MATERIALIZED VIEW ... OWNER TO role`.
109    pub owner: Option<Identifier>,
110    /// Grants on this object. Empty = no grants. Canonicalized.
111    pub grants: Vec<crate::ir::grant::Grant>,
112    /// Storage parameters. Same key set as Table.
113    pub storage: crate::ir::reloptions::MaterializedViewStorageOptions,
114}
115
116/// Render an optional view-column type for diff display. An unresolved
117/// (`None`) type renders as `"<unresolved>"`; this should not occur
118/// post-canonicalization, but the diff impl must total over both arms.
119fn render_column_type(ty: Option<&ColumnType>) -> String {
120    ty.map_or_else(|| "<unresolved>".to_string(), ColumnType::render_sql)
121}
122
123impl Equiv for View {
124    #[allow(clippy::too_many_lines)] // flat field-by-field table plus column pairing — extraction would obscure intent.
125    fn differences(&self, other: &Self) -> Vec<Difference> {
126        // Field-completeness guard: the compiler errors if a field is added to
127        // `View` without being handled below. `raw_body` is a `#[serde(skip)]`
128        // parser-transient field consumed by canon (it populates
129        // `body_canonical` / `body_dependencies`); it is not part of canonical
130        // identity, so it is intentionally not diffed. Bindings are unused
131        // (values read via `self`/`other`).
132        let Self {
133            qname: _,
134            columns: _,
135            body_canonical: _,
136            body_dependencies: _,
137            security_barrier: _,
138            security_invoker: _,
139            check_option: _,
140            comment: _,
141            raw_body: _, // parser-transient, #[serde(skip)], excluded from equivalence
142            owner: _,
143            grants: _,
144        } = self;
145        let mut out = Vec::new();
146        out.extend(field_difference("qname", &self.qname, &other.qname));
147        out.extend(field_difference(
148            "body_canonical",
149            &self.body_canonical.canonical_text(),
150            &other.body_canonical.canonical_text(),
151        ));
152        out.extend(field_difference(
153            "security_barrier",
154            &format!("{:?}", self.security_barrier),
155            &format!("{:?}", other.security_barrier),
156        ));
157        out.extend(field_difference(
158            "security_invoker",
159            &format!("{:?}", self.security_invoker),
160            &format!("{:?}", other.security_invoker),
161        ));
162        out.extend(field_difference(
163            "check_option",
164            &format!("{:?}", self.check_option),
165            &format!("{:?}", other.check_option),
166        ));
167        out.extend(field_difference(
168            "comment",
169            &format!("{:?}", self.comment),
170            &format!("{:?}", other.comment),
171        ));
172        out.extend(field_difference(
173            "owner",
174            &format!("{:?}", self.owner),
175            &format!("{:?}", other.owner),
176        ));
177        out.extend(field_difference(
178            "grants",
179            &format!("{:?}", self.grants),
180            &format!("{:?}", other.grants),
181        ));
182
183        // Column diff: pair by name.
184        let lhs: BTreeMap<_, _> = self.columns.iter().map(|c| (c.name.as_str(), c)).collect();
185        let rhs: BTreeMap<_, _> = other.columns.iter().map(|c| (c.name.as_str(), c)).collect();
186        for (name, l) in &lhs {
187            match rhs.get(name) {
188                None => out.push(Difference::new(
189                    format!("columns.{name}"),
190                    "present",
191                    "removed",
192                )),
193                Some(r) => {
194                    if l.column_type != r.column_type {
195                        out.push(Difference::new(
196                            format!("columns.{name}.column_type"),
197                            render_column_type(l.column_type.as_ref()),
198                            render_column_type(r.column_type.as_ref()),
199                        ));
200                    }
201                    if l.comment != r.comment {
202                        out.push(Difference::new(
203                            format!("columns.{name}.comment"),
204                            format!("{:?}", l.comment),
205                            format!("{:?}", r.comment),
206                        ));
207                    }
208                }
209            }
210        }
211        for name in rhs.keys() {
212            if !lhs.contains_key(name) {
213                out.push(Difference::new(
214                    format!("columns.{name}"),
215                    "missing",
216                    "added",
217                ));
218            }
219        }
220        let lhs_order: Vec<&str> = self.columns.iter().map(|c| c.name.as_str()).collect();
221        let rhs_order: Vec<&str> = other.columns.iter().map(|c| c.name.as_str()).collect();
222        if lhs_order != rhs_order {
223            out.push(Difference::new(
224                "columns.<order>",
225                lhs_order.join(","),
226                rhs_order.join(","),
227            ));
228        }
229
230        // Dependency-edge diff: format vec for comparison.
231        out.extend(field_difference(
232            "body_dependencies",
233            &format!("{:?}", self.body_dependencies),
234            &format!("{:?}", other.body_dependencies),
235        ));
236
237        out
238    }
239}
240
241impl Equiv for MaterializedView {
242    fn differences(&self, other: &Self) -> Vec<Difference> {
243        // Field-completeness guard: the compiler errors if a field is added to
244        // `MaterializedView` without being handled below. `raw_body` is a
245        // `#[serde(skip)]` parser-transient field consumed by canon and not part
246        // of canonical identity, so it is intentionally not diffed. Bindings are
247        // unused (values read via `self`/`other`).
248        let Self {
249            qname: _,
250            columns: _,
251            body_canonical: _,
252            body_dependencies: _,
253            comment: _,
254            raw_body: _,
255            owner: _,
256            grants: _,
257            storage: _,
258        } = self;
259        let mut out = Vec::new();
260        out.extend(field_difference("qname", &self.qname, &other.qname));
261        out.extend(field_difference(
262            "body_canonical",
263            &self.body_canonical.canonical_text(),
264            &other.body_canonical.canonical_text(),
265        ));
266        out.extend(field_difference(
267            "comment",
268            &format!("{:?}", self.comment),
269            &format!("{:?}", other.comment),
270        ));
271        out.extend(field_difference(
272            "owner",
273            &format!("{:?}", self.owner),
274            &format!("{:?}", other.owner),
275        ));
276        out.extend(field_difference(
277            "grants",
278            &format!("{:?}", self.grants),
279            &format!("{:?}", other.grants),
280        ));
281        out.extend(field_difference(
282            "storage",
283            &format!("{:?}", self.storage),
284            &format!("{:?}", other.storage),
285        ));
286
287        // Column diff: pair by name.
288        let lhs: BTreeMap<_, _> = self.columns.iter().map(|c| (c.name.as_str(), c)).collect();
289        let rhs: BTreeMap<_, _> = other.columns.iter().map(|c| (c.name.as_str(), c)).collect();
290        for (name, l) in &lhs {
291            match rhs.get(name) {
292                None => out.push(Difference::new(
293                    format!("columns.{name}"),
294                    "present",
295                    "removed",
296                )),
297                Some(r) => {
298                    if l.column_type != r.column_type {
299                        out.push(Difference::new(
300                            format!("columns.{name}.column_type"),
301                            render_column_type(l.column_type.as_ref()),
302                            render_column_type(r.column_type.as_ref()),
303                        ));
304                    }
305                    if l.comment != r.comment {
306                        out.push(Difference::new(
307                            format!("columns.{name}.comment"),
308                            format!("{:?}", l.comment),
309                            format!("{:?}", r.comment),
310                        ));
311                    }
312                }
313            }
314        }
315        for name in rhs.keys() {
316            if !lhs.contains_key(name) {
317                out.push(Difference::new(
318                    format!("columns.{name}"),
319                    "missing",
320                    "added",
321                ));
322            }
323        }
324        let lhs_order: Vec<&str> = self.columns.iter().map(|c| c.name.as_str()).collect();
325        let rhs_order: Vec<&str> = other.columns.iter().map(|c| c.name.as_str()).collect();
326        if lhs_order != rhs_order {
327            out.push(Difference::new(
328                "columns.<order>",
329                lhs_order.join(","),
330                rhs_order.join(","),
331            ));
332        }
333
334        // Dependency-edge diff: format vec for comparison.
335        out.extend(field_difference(
336            "body_dependencies",
337            &format!("{:?}", self.body_dependencies),
338            &format!("{:?}", other.body_dependencies),
339        ));
340
341        out
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::ir::IrError;
349    use crate::ir::catalog::Catalog;
350    use crate::ir::column_type::ColumnType;
351    use crate::plan::edges::{DepSource, NodeId};
352
353    fn id(s: &str) -> Identifier {
354        Identifier::from_unquoted(s).unwrap()
355    }
356
357    fn qn(schema: &str, name: &str) -> QualifiedName {
358        QualifiedName::new(id(schema), id(name))
359    }
360
361    fn body(sql: &str) -> NormalizedBody {
362        NormalizedBody::from_sql(sql).unwrap()
363    }
364
365    fn simple_view(schema: &str, name: &str) -> View {
366        View {
367            qname: qn(schema, name),
368            columns: vec![ViewColumn {
369                name: id("id"),
370                column_type: Some(ColumnType::BigInt),
371                comment: None,
372            }],
373            body_canonical: body("SELECT 1"),
374            body_dependencies: vec![],
375            security_barrier: None,
376            security_invoker: None,
377            check_option: None,
378            comment: None,
379            raw_body: String::new(),
380            owner: None,
381            grants: vec![],
382        }
383    }
384
385    fn simple_mv(schema: &str, name: &str) -> MaterializedView {
386        MaterializedView {
387            qname: qn(schema, name),
388            columns: vec![],
389            body_canonical: body("SELECT 1"),
390            body_dependencies: vec![],
391            comment: None,
392            raw_body: String::new(),
393            owner: None,
394            grants: vec![],
395            storage: crate::ir::reloptions::MaterializedViewStorageOptions::default(),
396        }
397    }
398
399    #[test]
400    fn views_with_equal_fields_compare_equal() {
401        let v1 = simple_view("app", "active_users");
402        let v2 = View {
403            qname: qn("app", "active_users"),
404            columns: vec![ViewColumn {
405                name: id("id"),
406                column_type: Some(ColumnType::BigInt),
407                comment: None,
408            }],
409            body_canonical: body("SELECT 1"),
410            body_dependencies: vec![],
411            security_barrier: None,
412            security_invoker: None,
413            check_option: None,
414            comment: None,
415            raw_body: String::new(),
416            owner: None,
417            grants: vec![],
418        };
419        assert_eq!(v1, v2);
420    }
421
422    #[test]
423    fn materialized_view_round_trips_through_serde() {
424        let mv = MaterializedView {
425            qname: qn("app", "summary"),
426            columns: vec![ViewColumn {
427                name: id("total"),
428                column_type: Some(ColumnType::BigInt),
429                comment: Some("total count".to_string()),
430            }],
431            body_canonical: body("SELECT count(*) FROM users"),
432            body_dependencies: vec![DepEdge {
433                from: NodeId::Table(qn("app", "summary")),
434                to: NodeId::Table(qn("app", "users")),
435                source: DepSource::AstExtracted,
436            }],
437            comment: Some("materialized summary".to_string()),
438            raw_body: String::new(),
439            owner: None,
440            grants: vec![],
441            storage: crate::ir::reloptions::MaterializedViewStorageOptions::default(),
442        };
443        let json = serde_json::to_string(&mv).expect("serialization must succeed");
444        let roundtripped: MaterializedView =
445            serde_json::from_str(&json).expect("deserialization must succeed");
446        assert_eq!(mv, roundtripped);
447    }
448
449    #[test]
450    fn catalog_with_views_canonicalizes() {
451        let mut c = Catalog::empty();
452        c.views.push(simple_view("app", "zzz_view"));
453        c.views.push(simple_view("app", "aaa_view"));
454        c.materialized_views.push(simple_mv("app", "zzz_mv"));
455        c.materialized_views.push(simple_mv("app", "aaa_mv"));
456
457        let result = c.canonicalize();
458        assert!(result.is_ok(), "canonicalize should succeed: {result:?}");
459        let canonical = result.unwrap();
460
461        assert_eq!(canonical.views[0].qname, qn("app", "aaa_view"));
462        assert_eq!(canonical.views[1].qname, qn("app", "zzz_view"));
463        assert_eq!(canonical.materialized_views[0].qname, qn("app", "aaa_mv"));
464        assert_eq!(canonical.materialized_views[1].qname, qn("app", "zzz_mv"));
465    }
466
467    #[test]
468    fn unresolved_view_column_rejected_by_canon() {
469        let mut c = Catalog::empty();
470        let mut v = simple_view("app", "v");
471        // Force an unresolved column type — as if ast_canon never ran.
472        v.columns[0].column_type = None;
473        c.views.push(v);
474
475        let result = c.canonicalize();
476        assert!(
477            matches!(result, Err(IrError::UnresolvedViewColumn { .. })),
478            "expected UnresolvedViewColumn error, got: {result:?}",
479        );
480    }
481
482    #[test]
483    fn catalog_rejects_duplicate_view_qname() {
484        let mut c = Catalog::empty();
485        c.views.push(simple_view("app", "my_view"));
486        c.views.push(simple_view("app", "my_view"));
487
488        let result = c.canonicalize();
489        assert!(
490            matches!(result, Err(IrError::DuplicateObject { kind: "view", .. })),
491            "expected duplicate-view error, got: {result:?}",
492        );
493    }
494
495    #[test]
496    fn catalog_rejects_duplicate_materialized_view_qname() {
497        let mut c = Catalog::empty();
498        c.materialized_views.push(simple_mv("app", "my_mv"));
499        c.materialized_views.push(simple_mv("app", "my_mv"));
500
501        let result = c.canonicalize();
502        assert!(
503            matches!(
504                result,
505                Err(IrError::DuplicateObject {
506                    kind: "materialized view",
507                    ..
508                })
509            ),
510            "expected duplicate-mv error, got: {result:?}",
511        );
512    }
513
514    #[test]
515    fn view_owner_change_diffs() {
516        use crate::ir::eq::Equiv;
517        let mut b = simple_view("app", "active_users");
518        b.owner = Some(id("new_owner"));
519        assert!(
520            simple_view("app", "active_users")
521                .differences(&b)
522                .iter()
523                .any(|x| x.path == "owner")
524        );
525    }
526
527    #[test]
528    fn view_grants_change_diffs() {
529        use crate::ir::eq::Equiv;
530        let mut b = simple_view("app", "active_users");
531        b.grants.push(crate::ir::grant::Grant {
532            grantee: crate::ir::grant::GrantTarget::Public,
533            privilege: crate::ir::grant::Privilege::Select,
534            with_grant_option: false,
535            columns: None,
536        });
537        assert!(
538            simple_view("app", "active_users")
539                .differences(&b)
540                .iter()
541                .any(|x| x.path == "grants")
542        );
543    }
544
545    #[test]
546    fn materialized_view_owner_change_diffs() {
547        use crate::ir::eq::Equiv;
548        let mut b = simple_mv("app", "my_mv");
549        b.owner = Some(id("new_owner"));
550        assert!(
551            simple_mv("app", "my_mv")
552                .differences(&b)
553                .iter()
554                .any(|x| x.path == "owner")
555        );
556    }
557
558    #[test]
559    fn materialized_view_grants_change_diffs() {
560        use crate::ir::eq::Equiv;
561        let mut b = simple_mv("app", "my_mv");
562        b.grants.push(crate::ir::grant::Grant {
563            grantee: crate::ir::grant::GrantTarget::Public,
564            privilege: crate::ir::grant::Privilege::Select,
565            with_grant_option: false,
566            columns: None,
567        });
568        assert!(
569            simple_mv("app", "my_mv")
570                .differences(&b)
571                .iter()
572                .any(|x| x.path == "grants")
573        );
574    }
575
576    #[test]
577    fn materialized_view_storage_change_diffs() {
578        use crate::ir::eq::Equiv;
579        let mut b = simple_mv("app", "my_mv");
580        b.storage = crate::ir::reloptions::MaterializedViewStorageOptions {
581            fillfactor: Some(80),
582            ..Default::default()
583        };
584        assert!(
585            simple_mv("app", "my_mv")
586                .differences(&b)
587                .iter()
588                .any(|x| x.path == "storage")
589        );
590    }
591
592    #[test]
593    fn view_check_option_change_diffs() {
594        use crate::ir::eq::Equiv;
595        let a = simple_view("app", "v");
596        let mut b = simple_view("app", "v");
597        b.check_option = Some(CheckOption::Cascaded);
598        let d = a.differences(&b);
599        assert!(
600            d.iter().any(|x| x.path == "check_option"),
601            "check_option change must be reported (was silently ignored before): {d:?}",
602        );
603    }
604
605    #[test]
606    fn check_option_local_does_not_equal_cascaded() {
607        assert_ne!(CheckOption::Local, CheckOption::Cascaded);
608    }
609
610    #[test]
611    fn check_option_implements_copy() {
612        let a = CheckOption::Local;
613        let b = a; // copies
614        let c = a; // still usable
615        assert_eq!(b, CheckOption::Local);
616        assert_eq!(c, CheckOption::Local);
617    }
618}