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};
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/// A single named column in a view or materialized view.
19///
20/// Postgres allows column alias lists on `CREATE VIEW` to override the
21/// column names derived from the SELECT list. This struct records the
22/// (possibly overridden) column name, its resolved type, and any attached
23/// comment.
24///
25/// ## `column_type` sentinel
26///
27/// When `ViewColumn` is constructed from an explicit alias list in T3
28/// (parsing), the type is not yet known — it requires resolving the SELECT
29/// body against the catalog. In that case `column_type` is set to
30/// `ColumnType::Other { raw: "unresolved".to_string() }`, which serves as
31/// a parser-internal sentinel. T4's AST canonicalization pass replaces it
32/// with the resolved type. The sentinel **must never appear** in a serialized
33/// catalog or plan — T4 always runs before serialization.
34///
35/// When `ViewColumn` is built from the live catalog (T5), `column_type` is
36/// parsed directly from `format_type(a.atttypid, a.atttypmod)`.
37#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
38pub struct ViewColumn {
39    /// Column name as it appears in the view definition (or is aliased).
40    pub name: Identifier,
41    /// Resolved data type of the column.
42    ///
43    /// Set to `ColumnType::Other { raw: "unresolved".to_string() }` as a
44    /// parser-internal sentinel when type resolution has not yet occurred
45    /// (T3 → T4 transition). Must be fully resolved before serialization.
46    pub column_type: ColumnType,
47    /// Optional `COMMENT ON COLUMN` text.
48    pub comment: Option<String>,
49}
50
51/// A Postgres `CREATE VIEW`.
52///
53/// The `body_canonical` is the parsed-and-deparsed SELECT statement in
54/// canonical form. `body_dependencies` lists the IR objects the body
55/// references, extracted from the AST (v0.2 task 4; initially empty until
56/// the AST-walk pass lands).
57#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
58pub struct View {
59    /// Schema-qualified view name.
60    pub qname: QualifiedName,
61    /// Explicit column alias list (empty when none was provided).
62    pub columns: Vec<ViewColumn>,
63    /// Canonical form of the SELECT body.
64    pub body_canonical: NormalizedBody,
65    /// Dependency edges extracted from the body AST.
66    pub body_dependencies: Vec<DepEdge>,
67    /// `WITH (security_barrier = ...)` option, if present.
68    pub security_barrier: Option<bool>,
69    /// `WITH (security_invoker = ...)` option, if present.
70    pub security_invoker: Option<bool>,
71    /// Optional `COMMENT ON VIEW` text.
72    pub comment: Option<String>,
73    /// Raw SELECT body text from source SQL. Populated by the parser (T3);
74    /// consumed by the AST canonicalization pass (T4) to fill
75    /// `body_canonical` and `body_dependencies`. Not serialized to plan
76    /// output or JSON (T4 produces the canonical form which IS serialized).
77    #[serde(skip, default)]
78    pub raw_body: String,
79    /// Object owner. `None` = unmanaged (the differ ignores ownership).
80    /// `Some(role)` = managed: diff emits `ALTER VIEW ... OWNER TO role`.
81    pub owner: Option<Identifier>,
82    /// Grants on this object. Empty = no grants. Canonicalized.
83    pub grants: Vec<crate::ir::grant::Grant>,
84}
85
86/// A Postgres `CREATE MATERIALIZED VIEW`.
87///
88/// Unlike regular views, materialized views are physically stored.
89/// They lack the `security_barrier` / `security_invoker` options of regular
90/// views but are otherwise structurally similar.
91#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
92pub struct MaterializedView {
93    /// Schema-qualified materialized view name.
94    pub qname: QualifiedName,
95    /// Explicit column alias list (empty when none was provided).
96    pub columns: Vec<ViewColumn>,
97    /// Canonical form of the SELECT body.
98    pub body_canonical: NormalizedBody,
99    /// Dependency edges extracted from the body AST.
100    pub body_dependencies: Vec<DepEdge>,
101    /// Optional `COMMENT ON MATERIALIZED VIEW` text.
102    pub comment: Option<String>,
103    /// Raw SELECT body text from source SQL. Populated by the parser (T3);
104    /// consumed by the AST canonicalization pass (T4) to fill
105    /// `body_canonical` and `body_dependencies`. Not serialized to plan
106    /// output or JSON (T4 produces the canonical form which IS serialized).
107    #[serde(skip, default)]
108    pub raw_body: String,
109    /// Object owner. `None` = unmanaged (the differ ignores ownership).
110    /// `Some(role)` = managed: diff emits `ALTER MATERIALIZED VIEW ... OWNER TO role`.
111    pub owner: Option<Identifier>,
112    /// Grants on this object. Empty = no grants. Canonicalized.
113    pub grants: Vec<crate::ir::grant::Grant>,
114    /// Storage parameters. Same key set as Table.
115    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        // Column diff: pair by name.
154        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        // Dependency-edge diff: format vec for comparison.
201        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        // Column diff: pair by name.
242        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        // Dependency-edge diff: format vec for comparison.
289        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}