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::{Diff, diff_field};
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/// Postgres allows column alias lists on `CREATE VIEW` to override the
30/// column names derived from the SELECT list. This struct records the
31/// (possibly overridden) column name, its resolved type, and any attached
32/// comment.
33///
34/// ## `column_type` sentinel
35///
36/// When `ViewColumn` is constructed from an explicit alias list in T3
37/// (parsing), the type is not yet known — it requires resolving the SELECT
38/// body against the catalog. In that case `column_type` is set to
39/// `ColumnType::Other { raw: "unresolved".to_string() }`, which serves as
40/// a parser-internal sentinel. T4's AST canonicalization pass replaces it
41/// with the resolved type. The sentinel **must never appear** in a serialized
42/// catalog or plan — T4 always runs before serialization.
43///
44/// When `ViewColumn` is built from the live catalog (T5), `column_type` is
45/// parsed directly from `format_type(a.atttypid, a.atttypmod)`.
46#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
47pub struct ViewColumn {
48    /// Column name as it appears in the view definition (or is aliased).
49    pub name: Identifier,
50    /// Resolved data type of the column.
51    ///
52    /// Set to `ColumnType::Other { raw: "unresolved".to_string() }` as a
53    /// parser-internal sentinel when type resolution has not yet occurred
54    /// (T3 → T4 transition). Must be fully resolved before serialization.
55    pub column_type: ColumnType,
56    /// Optional `COMMENT ON COLUMN` text.
57    pub comment: Option<String>,
58}
59
60/// A Postgres `CREATE VIEW`.
61///
62/// The `body_canonical` is the parsed-and-deparsed SELECT statement in
63/// canonical form. `body_dependencies` lists the IR objects the body
64/// references, extracted from the AST (v0.2 task 4; initially empty until
65/// the AST-walk pass lands).
66#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
67pub struct View {
68    /// Schema-qualified view name.
69    pub qname: QualifiedName,
70    /// Explicit column alias list (empty when none was provided).
71    pub columns: Vec<ViewColumn>,
72    /// Canonical form of the SELECT body.
73    pub body_canonical: NormalizedBody,
74    /// Dependency edges extracted from the body AST.
75    pub body_dependencies: Vec<DepEdge>,
76    /// `WITH (security_barrier = ...)` option, if present.
77    pub security_barrier: Option<bool>,
78    /// `WITH (security_invoker = ...)` option, if present.
79    pub security_invoker: Option<bool>,
80    /// `WITH [LOCAL | CASCADED] CHECK OPTION`, when set in source.
81    /// `None` = unmanaged (lenient — operator may have set it out-of-band;
82    /// pgevolve neither sets nor resets unless source declares).
83    pub check_option: Option<CheckOption>,
84    /// Optional `COMMENT ON VIEW` text.
85    pub comment: Option<String>,
86    /// Raw SELECT body text from source SQL. Populated by the parser (T3);
87    /// consumed by the AST canonicalization pass (T4) to fill
88    /// `body_canonical` and `body_dependencies`. Not serialized to plan
89    /// output or JSON (T4 produces the canonical form which IS serialized).
90    #[serde(skip, default)]
91    pub raw_body: String,
92    /// Object owner. `None` = unmanaged (the differ ignores ownership).
93    /// `Some(role)` = managed: diff emits `ALTER VIEW ... OWNER TO role`.
94    pub owner: Option<Identifier>,
95    /// Grants on this object. Empty = no grants. Canonicalized.
96    pub grants: Vec<crate::ir::grant::Grant>,
97}
98
99/// A Postgres `CREATE MATERIALIZED VIEW`.
100///
101/// Unlike regular views, materialized views are physically stored.
102/// They lack the `security_barrier` / `security_invoker` options of regular
103/// views but are otherwise structurally similar.
104#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
105pub struct MaterializedView {
106    /// Schema-qualified materialized view name.
107    pub qname: QualifiedName,
108    /// Explicit column alias list (empty when none was provided).
109    pub columns: Vec<ViewColumn>,
110    /// Canonical form of the SELECT body.
111    pub body_canonical: NormalizedBody,
112    /// Dependency edges extracted from the body AST.
113    pub body_dependencies: Vec<DepEdge>,
114    /// Optional `COMMENT ON MATERIALIZED VIEW` text.
115    pub comment: Option<String>,
116    /// Raw SELECT body text from source SQL. Populated by the parser (T3);
117    /// consumed by the AST canonicalization pass (T4) to fill
118    /// `body_canonical` and `body_dependencies`. Not serialized to plan
119    /// output or JSON (T4 produces the canonical form which IS serialized).
120    #[serde(skip, default)]
121    pub raw_body: String,
122    /// Object owner. `None` = unmanaged (the differ ignores ownership).
123    /// `Some(role)` = managed: diff emits `ALTER MATERIALIZED VIEW ... OWNER TO role`.
124    pub owner: Option<Identifier>,
125    /// Grants on this object. Empty = no grants. Canonicalized.
126    pub grants: Vec<crate::ir::grant::Grant>,
127    /// Storage parameters. Same key set as Table.
128    pub storage: crate::ir::reloptions::MaterializedViewStorageOptions,
129}
130
131impl Diff for View {
132    fn diff(&self, other: &Self) -> Vec<Difference> {
133        let mut out = Vec::new();
134        out.extend(diff_field("qname", &self.qname, &other.qname));
135        out.extend(diff_field(
136            "body_canonical",
137            &self.body_canonical.canonical_text(),
138            &other.body_canonical.canonical_text(),
139        ));
140        out.extend(diff_field(
141            "security_barrier",
142            &format!("{:?}", self.security_barrier),
143            &format!("{:?}", other.security_barrier),
144        ));
145        out.extend(diff_field(
146            "security_invoker",
147            &format!("{:?}", self.security_invoker),
148            &format!("{:?}", other.security_invoker),
149        ));
150        out.extend(diff_field(
151            "comment",
152            &format!("{:?}", self.comment),
153            &format!("{:?}", other.comment),
154        ));
155        out.extend(diff_field(
156            "owner",
157            &format!("{:?}", self.owner),
158            &format!("{:?}", other.owner),
159        ));
160        out.extend(diff_field(
161            "grants",
162            &format!("{:?}", self.grants),
163            &format!("{:?}", other.grants),
164        ));
165
166        // Column diff: pair by name.
167        let lhs: BTreeMap<_, _> = self.columns.iter().map(|c| (c.name.as_str(), c)).collect();
168        let rhs: BTreeMap<_, _> = other.columns.iter().map(|c| (c.name.as_str(), c)).collect();
169        for (name, l) in &lhs {
170            match rhs.get(name) {
171                None => out.push(Difference::new(
172                    format!("columns.{name}"),
173                    "present",
174                    "removed",
175                )),
176                Some(r) => {
177                    if l.column_type != r.column_type {
178                        out.push(Difference::new(
179                            format!("columns.{name}.column_type"),
180                            l.column_type.render_sql(),
181                            r.column_type.render_sql(),
182                        ));
183                    }
184                    if l.comment != r.comment {
185                        out.push(Difference::new(
186                            format!("columns.{name}.comment"),
187                            format!("{:?}", l.comment),
188                            format!("{:?}", r.comment),
189                        ));
190                    }
191                }
192            }
193        }
194        for name in rhs.keys() {
195            if !lhs.contains_key(name) {
196                out.push(Difference::new(
197                    format!("columns.{name}"),
198                    "missing",
199                    "added",
200                ));
201            }
202        }
203        let lhs_order: Vec<&str> = self.columns.iter().map(|c| c.name.as_str()).collect();
204        let rhs_order: Vec<&str> = other.columns.iter().map(|c| c.name.as_str()).collect();
205        if lhs_order != rhs_order {
206            out.push(Difference::new(
207                "columns.<order>",
208                lhs_order.join(","),
209                rhs_order.join(","),
210            ));
211        }
212
213        // Dependency-edge diff: format vec for comparison.
214        out.extend(diff_field(
215            "body_dependencies",
216            &format!("{:?}", self.body_dependencies),
217            &format!("{:?}", other.body_dependencies),
218        ));
219
220        out
221    }
222}
223
224impl Diff for MaterializedView {
225    fn diff(&self, other: &Self) -> Vec<Difference> {
226        let mut out = Vec::new();
227        out.extend(diff_field("qname", &self.qname, &other.qname));
228        out.extend(diff_field(
229            "body_canonical",
230            &self.body_canonical.canonical_text(),
231            &other.body_canonical.canonical_text(),
232        ));
233        out.extend(diff_field(
234            "comment",
235            &format!("{:?}", self.comment),
236            &format!("{:?}", other.comment),
237        ));
238        out.extend(diff_field(
239            "owner",
240            &format!("{:?}", self.owner),
241            &format!("{:?}", other.owner),
242        ));
243        out.extend(diff_field(
244            "grants",
245            &format!("{:?}", self.grants),
246            &format!("{:?}", other.grants),
247        ));
248        out.extend(diff_field(
249            "storage",
250            &format!("{:?}", self.storage),
251            &format!("{:?}", other.storage),
252        ));
253
254        // Column diff: pair by name.
255        let lhs: BTreeMap<_, _> = self.columns.iter().map(|c| (c.name.as_str(), c)).collect();
256        let rhs: BTreeMap<_, _> = other.columns.iter().map(|c| (c.name.as_str(), c)).collect();
257        for (name, l) in &lhs {
258            match rhs.get(name) {
259                None => out.push(Difference::new(
260                    format!("columns.{name}"),
261                    "present",
262                    "removed",
263                )),
264                Some(r) => {
265                    if l.column_type != r.column_type {
266                        out.push(Difference::new(
267                            format!("columns.{name}.column_type"),
268                            l.column_type.render_sql(),
269                            r.column_type.render_sql(),
270                        ));
271                    }
272                    if l.comment != r.comment {
273                        out.push(Difference::new(
274                            format!("columns.{name}.comment"),
275                            format!("{:?}", l.comment),
276                            format!("{:?}", r.comment),
277                        ));
278                    }
279                }
280            }
281        }
282        for name in rhs.keys() {
283            if !lhs.contains_key(name) {
284                out.push(Difference::new(
285                    format!("columns.{name}"),
286                    "missing",
287                    "added",
288                ));
289            }
290        }
291        let lhs_order: Vec<&str> = self.columns.iter().map(|c| c.name.as_str()).collect();
292        let rhs_order: Vec<&str> = other.columns.iter().map(|c| c.name.as_str()).collect();
293        if lhs_order != rhs_order {
294            out.push(Difference::new(
295                "columns.<order>",
296                lhs_order.join(","),
297                rhs_order.join(","),
298            ));
299        }
300
301        // Dependency-edge diff: format vec for comparison.
302        out.extend(diff_field(
303            "body_dependencies",
304            &format!("{:?}", self.body_dependencies),
305            &format!("{:?}", other.body_dependencies),
306        ));
307
308        out
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::ir::IrError;
316    use crate::ir::catalog::Catalog;
317    use crate::ir::column_type::ColumnType;
318    use crate::plan::edges::{DepSource, NodeId};
319
320    fn id(s: &str) -> Identifier {
321        Identifier::from_unquoted(s).unwrap()
322    }
323
324    fn qn(schema: &str, name: &str) -> QualifiedName {
325        QualifiedName::new(id(schema), id(name))
326    }
327
328    fn body(sql: &str) -> NormalizedBody {
329        NormalizedBody::from_sql(sql).unwrap()
330    }
331
332    fn simple_view(schema: &str, name: &str) -> View {
333        View {
334            qname: qn(schema, name),
335            columns: vec![ViewColumn {
336                name: id("id"),
337                column_type: ColumnType::BigInt,
338                comment: None,
339            }],
340            body_canonical: body("SELECT 1"),
341            body_dependencies: vec![],
342            security_barrier: None,
343            security_invoker: None,
344            check_option: None,
345            comment: None,
346            raw_body: String::new(),
347            owner: None,
348            grants: vec![],
349        }
350    }
351
352    fn simple_mv(schema: &str, name: &str) -> MaterializedView {
353        MaterializedView {
354            qname: qn(schema, name),
355            columns: vec![],
356            body_canonical: body("SELECT 1"),
357            body_dependencies: vec![],
358            comment: None,
359            raw_body: String::new(),
360            owner: None,
361            grants: vec![],
362            storage: crate::ir::reloptions::MaterializedViewStorageOptions::default(),
363        }
364    }
365
366    #[test]
367    fn views_with_equal_fields_compare_equal() {
368        let v1 = simple_view("app", "active_users");
369        let v2 = View {
370            qname: qn("app", "active_users"),
371            columns: vec![ViewColumn {
372                name: id("id"),
373                column_type: ColumnType::BigInt,
374                comment: None,
375            }],
376            body_canonical: body("SELECT 1"),
377            body_dependencies: vec![],
378            security_barrier: None,
379            security_invoker: None,
380            check_option: None,
381            comment: None,
382            raw_body: String::new(),
383            owner: None,
384            grants: vec![],
385        };
386        assert_eq!(v1, v2);
387    }
388
389    #[test]
390    fn materialized_view_round_trips_through_serde() {
391        let mv = MaterializedView {
392            qname: qn("app", "summary"),
393            columns: vec![ViewColumn {
394                name: id("total"),
395                column_type: ColumnType::BigInt,
396                comment: Some("total count".to_string()),
397            }],
398            body_canonical: body("SELECT count(*) FROM users"),
399            body_dependencies: vec![DepEdge {
400                from: NodeId::Table(qn("app", "summary")),
401                to: NodeId::Table(qn("app", "users")),
402                source: DepSource::AstExtracted,
403            }],
404            comment: Some("materialized summary".to_string()),
405            raw_body: String::new(),
406            owner: None,
407            grants: vec![],
408            storage: crate::ir::reloptions::MaterializedViewStorageOptions::default(),
409        };
410        let json = serde_json::to_string(&mv).expect("serialization must succeed");
411        let roundtripped: MaterializedView =
412            serde_json::from_str(&json).expect("deserialization must succeed");
413        assert_eq!(mv, roundtripped);
414    }
415
416    #[test]
417    fn catalog_with_views_canonicalizes() {
418        let mut c = Catalog::empty();
419        c.views.push(simple_view("app", "zzz_view"));
420        c.views.push(simple_view("app", "aaa_view"));
421        c.materialized_views.push(simple_mv("app", "zzz_mv"));
422        c.materialized_views.push(simple_mv("app", "aaa_mv"));
423
424        let result = c.canonicalize();
425        assert!(result.is_ok(), "canonicalize should succeed: {result:?}");
426        let canonical = result.unwrap();
427
428        assert_eq!(canonical.views[0].qname, qn("app", "aaa_view"));
429        assert_eq!(canonical.views[1].qname, qn("app", "zzz_view"));
430        assert_eq!(canonical.materialized_views[0].qname, qn("app", "aaa_mv"));
431        assert_eq!(canonical.materialized_views[1].qname, qn("app", "zzz_mv"));
432    }
433
434    #[test]
435    fn catalog_rejects_duplicate_view_qname() {
436        let mut c = Catalog::empty();
437        c.views.push(simple_view("app", "my_view"));
438        c.views.push(simple_view("app", "my_view"));
439
440        let result = c.canonicalize();
441        assert!(
442            matches!(result, Err(IrError::InvalidIdentifier(_))),
443            "expected duplicate-view error, got: {result:?}",
444        );
445    }
446
447    #[test]
448    fn catalog_rejects_duplicate_materialized_view_qname() {
449        let mut c = Catalog::empty();
450        c.materialized_views.push(simple_mv("app", "my_mv"));
451        c.materialized_views.push(simple_mv("app", "my_mv"));
452
453        let result = c.canonicalize();
454        assert!(
455            matches!(result, Err(IrError::InvalidIdentifier(_))),
456            "expected duplicate-mv error, got: {result:?}",
457        );
458    }
459
460    #[test]
461    fn view_owner_change_diffs() {
462        use crate::ir::eq::Diff;
463        let mut b = simple_view("app", "active_users");
464        b.owner = Some(id("new_owner"));
465        assert!(
466            simple_view("app", "active_users")
467                .diff(&b)
468                .iter()
469                .any(|x| x.path == "owner")
470        );
471    }
472
473    #[test]
474    fn view_grants_change_diffs() {
475        use crate::ir::eq::Diff;
476        let mut b = simple_view("app", "active_users");
477        b.grants.push(crate::ir::grant::Grant {
478            grantee: crate::ir::grant::GrantTarget::Public,
479            privilege: crate::ir::grant::Privilege::Select,
480            with_grant_option: false,
481            columns: None,
482        });
483        assert!(
484            simple_view("app", "active_users")
485                .diff(&b)
486                .iter()
487                .any(|x| x.path == "grants")
488        );
489    }
490
491    #[test]
492    fn materialized_view_owner_change_diffs() {
493        use crate::ir::eq::Diff;
494        let mut b = simple_mv("app", "my_mv");
495        b.owner = Some(id("new_owner"));
496        assert!(
497            simple_mv("app", "my_mv")
498                .diff(&b)
499                .iter()
500                .any(|x| x.path == "owner")
501        );
502    }
503
504    #[test]
505    fn materialized_view_grants_change_diffs() {
506        use crate::ir::eq::Diff;
507        let mut b = simple_mv("app", "my_mv");
508        b.grants.push(crate::ir::grant::Grant {
509            grantee: crate::ir::grant::GrantTarget::Public,
510            privilege: crate::ir::grant::Privilege::Select,
511            with_grant_option: false,
512            columns: None,
513        });
514        assert!(
515            simple_mv("app", "my_mv")
516                .diff(&b)
517                .iter()
518                .any(|x| x.path == "grants")
519        );
520    }
521
522    #[test]
523    fn materialized_view_storage_change_diffs() {
524        use crate::ir::eq::Diff;
525        let mut b = simple_mv("app", "my_mv");
526        b.storage = crate::ir::reloptions::MaterializedViewStorageOptions {
527            fillfactor: Some(80),
528            ..Default::default()
529        };
530        assert!(
531            simple_mv("app", "my_mv")
532                .diff(&b)
533                .iter()
534                .any(|x| x.path == "storage")
535        );
536    }
537
538    #[test]
539    fn check_option_local_does_not_equal_cascaded() {
540        assert_ne!(CheckOption::Local, CheckOption::Cascaded);
541    }
542
543    #[test]
544    fn check_option_implements_copy() {
545        let a = CheckOption::Local;
546        let b = a; // copies
547        let c = a; // still usable
548        assert_eq!(b, CheckOption::Local);
549        assert_eq!(c, CheckOption::Local);
550    }
551}