Skip to main content

pgevolve_core/ir/
table.rs

1//! `Table` — a Postgres table.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::identifier::{Identifier, QualifiedName};
8use crate::ir::column::Column;
9use crate::ir::constraint::Constraint;
10use crate::ir::difference::Difference;
11use crate::ir::eq::{Diff, diff_field, prefix_diffs};
12
13/// A Postgres table.
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct Table {
16    /// Schema-qualified table name.
17    pub qname: QualifiedName,
18    /// Columns in their logical order.
19    pub columns: Vec<Column>,
20    /// Constraints, paired by `qname` for diffing.
21    pub constraints: Vec<Constraint>,
22    /// `Some` → this table is a partitioned parent (`PARTITION BY …`).
23    pub partition_by: Option<crate::ir::partition::PartitionBy>,
24    /// `Some` → this table is itself a partition (`PARTITION OF … FOR VALUES …`).
25    pub partition_of: Option<crate::ir::partition::PartitionOf>,
26    /// Optional comment.
27    pub comment: Option<String>,
28    /// Object owner. `None` = unmanaged (the differ ignores ownership).
29    /// `Some(role)` = managed: diff emits `ALTER TABLE ... OWNER TO role`.
30    pub owner: Option<Identifier>,
31    /// Grants on this object. Empty = no grants. Canonicalized.
32    pub grants: Vec<crate::ir::grant::Grant>,
33    /// `ROW LEVEL SECURITY` enabled flag. PG default: false.
34    pub rls_enabled: bool,
35    /// `FORCE ROW LEVEL SECURITY` flag (applies even to owner). PG default: false.
36    pub rls_forced: bool,
37    /// Policies attached to this table. Canonicalized in `ir::canon::policies`.
38    pub policies: Vec<crate::ir::policy::Policy>,
39    /// Storage parameters (`WITH (fillfactor = …, autovacuum_* = …, …)`).
40    /// Default is the empty/no-overrides state.
41    pub storage: crate::ir::reloptions::TableStorageOptions,
42    /// Table access method (`CREATE TABLE … USING <am>`). `None` = inherit the
43    /// cluster default (`heap`). Canon normalizes `Some("heap")` → `None`.
44    pub access_method: Option<Identifier>,
45    /// Tablespace placement (`TABLESPACE <name>`). `None` = the database default (`pg_default`). Applies to regular tables, partitioned parents (default for future partitions), and partition children (overrides parent default).
46    pub tablespace: Option<Identifier>,
47}
48
49impl Diff for Table {
50    fn diff(&self, other: &Self) -> Vec<Difference> {
51        let mut out = Vec::new();
52        out.extend(diff_field("qname", &self.qname, &other.qname));
53        out.extend(diff_field(
54            "partition_by",
55            &format!("{:?}", self.partition_by),
56            &format!("{:?}", other.partition_by),
57        ));
58        out.extend(diff_field(
59            "partition_of",
60            &format!("{:?}", self.partition_of),
61            &format!("{:?}", other.partition_of),
62        ));
63        out.extend(diff_field(
64            "comment",
65            &format!("{:?}", self.comment),
66            &format!("{:?}", other.comment),
67        ));
68        out.extend(diff_field(
69            "owner",
70            &format!("{:?}", self.owner),
71            &format!("{:?}", other.owner),
72        ));
73        out.extend(diff_field(
74            "grants",
75            &format!("{:?}", self.grants),
76            &format!("{:?}", other.grants),
77        ));
78        out.extend(diff_field(
79            "rls_enabled",
80            &format!("{:?}", self.rls_enabled),
81            &format!("{:?}", other.rls_enabled),
82        ));
83        out.extend(diff_field(
84            "rls_forced",
85            &format!("{:?}", self.rls_forced),
86            &format!("{:?}", other.rls_forced),
87        ));
88        out.extend(diff_field(
89            "policies",
90            &format!("{:?}", self.policies),
91            &format!("{:?}", other.policies),
92        ));
93        out.extend(diff_field(
94            "storage",
95            &format!("{:?}", self.storage),
96            &format!("{:?}", other.storage),
97        ));
98        out.extend(diff_field(
99            "access_method",
100            &format!("{:?}", self.access_method),
101            &format!("{:?}", other.access_method),
102        ));
103        out.extend(diff_field(
104            "tablespace",
105            &format!("{:?}", self.tablespace),
106            &format!("{:?}", other.tablespace),
107        ));
108        out.extend(diff_columns(&self.columns, &other.columns));
109        out.extend(diff_constraints(&self.constraints, &other.constraints));
110        out
111    }
112}
113
114/// Diff two column slices: add/remove/change by name, then order drift.
115fn diff_columns(
116    lhs_cols: &[crate::ir::column::Column],
117    rhs_cols: &[crate::ir::column::Column],
118) -> Vec<Difference> {
119    let mut out = Vec::new();
120    let lhs: BTreeMap<_, _> = lhs_cols.iter().map(|c| (c.name.as_str(), c)).collect();
121    let rhs: BTreeMap<_, _> = rhs_cols.iter().map(|c| (c.name.as_str(), c)).collect();
122    for (name, l) in &lhs {
123        match rhs.get(name) {
124            None => out.push(Difference::new(
125                format!("columns.{name}"),
126                "present",
127                "removed",
128            )),
129            Some(r) => {
130                out.extend(prefix_diffs(&format!("columns.{name}"), l.diff(r)));
131            }
132        }
133    }
134    for name in rhs.keys() {
135        if !lhs.contains_key(name) {
136            out.push(Difference::new(
137                format!("columns.{name}"),
138                "missing",
139                "added",
140            ));
141        }
142    }
143    let lhs_order: Vec<&str> = lhs_cols.iter().map(|c| c.name.as_str()).collect();
144    let rhs_order: Vec<&str> = rhs_cols.iter().map(|c| c.name.as_str()).collect();
145    if lhs_order != rhs_order {
146        out.push(Difference::new(
147            "columns.<order>",
148            lhs_order.join(","),
149            rhs_order.join(","),
150        ));
151    }
152    out
153}
154
155/// Diff two constraint slices: add/remove/change by qname.
156fn diff_constraints(
157    lhs_cs_slice: &[crate::ir::constraint::Constraint],
158    rhs_cs_slice: &[crate::ir::constraint::Constraint],
159) -> Vec<Difference> {
160    let mut out = Vec::new();
161    let lhs_cs: BTreeMap<_, _> = lhs_cs_slice.iter().map(|c| (&c.qname, c)).collect();
162    let rhs_cs: BTreeMap<_, _> = rhs_cs_slice.iter().map(|c| (&c.qname, c)).collect();
163    for (qn, l) in &lhs_cs {
164        match rhs_cs.get(qn) {
165            None => out.push(Difference::new(
166                format!("constraints.{qn}"),
167                "present",
168                "removed",
169            )),
170            Some(r) => {
171                out.extend(prefix_diffs(&format!("constraints.{qn}"), l.diff(r)));
172            }
173        }
174    }
175    for qn in rhs_cs.keys() {
176        if !lhs_cs.contains_key(qn) {
177            out.push(Difference::new(
178                format!("constraints.{qn}"),
179                "missing",
180                "added",
181            ));
182        }
183    }
184    out
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::identifier::Identifier;
191    use crate::ir::column_type::ColumnType;
192    use crate::ir::constraint::{ConstraintKind, Deferrable};
193
194    fn id(s: &str) -> Identifier {
195        Identifier::from_unquoted(s).unwrap()
196    }
197
198    fn qn(name: &str) -> QualifiedName {
199        QualifiedName::new(id("app"), id(name))
200    }
201
202    fn col(name: &str, ty: ColumnType, nullable: bool) -> Column {
203        Column {
204            name: id(name),
205            ty,
206            nullable,
207            default: None,
208            identity: None,
209            generated: None,
210            collation: None,
211            storage: None,
212            compression: None,
213            comment: None,
214        }
215    }
216
217    fn pk(name: &str, cols: &[&str]) -> Constraint {
218        Constraint {
219            qname: qn(name),
220            kind: ConstraintKind::PrimaryKey {
221                columns: cols.iter().map(|c| id(c)).collect(),
222                include: vec![],
223            },
224            deferrable: Deferrable::NotDeferrable,
225            comment: None,
226        }
227    }
228
229    fn base() -> Table {
230        Table {
231            qname: qn("users"),
232            columns: vec![
233                col("id", ColumnType::BigInt, false),
234                col("email", ColumnType::Text, false),
235            ],
236            constraints: vec![pk("users_pkey", &["id"])],
237            partition_by: None,
238            partition_of: None,
239            comment: None,
240            owner: None,
241            grants: vec![],
242            rls_enabled: false,
243            rls_forced: false,
244            policies: vec![],
245            storage: crate::ir::reloptions::TableStorageOptions::default(),
246            access_method: None,
247            tablespace: None,
248        }
249    }
250
251    #[test]
252    fn equal_tables_have_no_diff() {
253        assert!(base().canonical_eq(&base()));
254    }
255
256    #[test]
257    fn add_column_diffs() {
258        let mut b = base();
259        b.columns.push(col("name", ColumnType::Text, true));
260        let d = base().diff(&b);
261        assert!(d.iter().any(|x| x.path == "columns.name"));
262    }
263
264    #[test]
265    fn remove_column_diffs() {
266        let mut b = base();
267        b.columns.pop();
268        let d = base().diff(&b);
269        assert!(d.iter().any(|x| x.path == "columns.email"));
270    }
271
272    #[test]
273    fn reorder_columns_diffs_as_order() {
274        let mut b = base();
275        b.columns.reverse();
276        let d = base().diff(&b);
277        assert!(d.iter().any(|x| x.path == "columns.<order>"));
278    }
279
280    #[test]
281    fn add_constraint_diffs() {
282        let mut b = base();
283        b.constraints.push(pk("users_alt_pkey", &["email"]));
284        let d = base().diff(&b);
285        assert!(d.iter().any(|x| x.path == "constraints.app.users_alt_pkey"));
286    }
287
288    #[test]
289    fn changed_column_definition_diffs_under_path() {
290        let mut b = base();
291        b.columns[1].nullable = true;
292        let d = base().diff(&b);
293        assert!(d.iter().any(|x| x.path == "columns.email.nullable"));
294    }
295
296    #[test]
297    fn owner_change_diffs() {
298        let mut b = base();
299        b.owner = Some(id("new_owner"));
300        assert!(base().diff(&b).iter().any(|x| x.path == "owner"));
301    }
302
303    #[test]
304    fn grants_change_diffs() {
305        let mut b = base();
306        b.grants.push(crate::ir::grant::Grant {
307            grantee: crate::ir::grant::GrantTarget::Public,
308            privilege: crate::ir::grant::Privilege::Select,
309            with_grant_option: false,
310            columns: None,
311        });
312        assert!(base().diff(&b).iter().any(|x| x.path == "grants"));
313    }
314
315    #[test]
316    fn rls_enabled_change_diffs() {
317        let mut b = base();
318        b.rls_enabled = true;
319        assert!(base().diff(&b).iter().any(|x| x.path == "rls_enabled"));
320    }
321
322    #[test]
323    fn rls_forced_change_diffs() {
324        let mut b = base();
325        b.rls_forced = true;
326        assert!(base().diff(&b).iter().any(|x| x.path == "rls_forced"));
327    }
328
329    #[test]
330    fn policies_change_diffs() {
331        use crate::ir::grant::GrantTarget;
332        use crate::ir::policy::{Policy, PolicyCommand};
333        let mut b = base();
334        b.policies.push(Policy {
335            name: id("p1"),
336            permissive: true,
337            command: PolicyCommand::All,
338            roles: vec![GrantTarget::Public],
339            using: None,
340            with_check: None,
341        });
342        assert!(base().diff(&b).iter().any(|x| x.path == "policies"));
343    }
344
345    #[test]
346    fn storage_change_diffs() {
347        let mut b = base();
348        b.storage = crate::ir::reloptions::TableStorageOptions {
349            fillfactor: Some(80),
350            ..Default::default()
351        };
352        assert!(base().diff(&b).iter().any(|x| x.path == "storage"));
353    }
354
355    #[test]
356    fn access_method_field_roundtrips() {
357        let mut t = base();
358        assert!(
359            t.access_method.is_none(),
360            "default access_method must be None"
361        );
362        t.access_method = Some(Identifier::from_unquoted("columnar").unwrap());
363        assert_eq!(
364            t.access_method.as_ref().map(Identifier::as_str),
365            Some("columnar"),
366        );
367        // JSON round-trip preserves the field.
368        let json = serde_json::to_string(&t).unwrap();
369        let restored: Table = serde_json::from_str(&json).unwrap();
370        assert_eq!(
371            restored.access_method.as_ref().map(Identifier::as_str),
372            Some("columnar"),
373        );
374    }
375
376    #[test]
377    fn tablespace_field_roundtrips() {
378        let mut t = base();
379        assert!(t.tablespace.is_none(), "default tablespace must be None");
380        t.tablespace = Some(Identifier::from_unquoted("fast").unwrap());
381        assert_eq!(t.tablespace.as_ref().map(Identifier::as_str), Some("fast"),);
382        // JSON round-trip preserves the field.
383        let json = serde_json::to_string(&t).unwrap();
384        let restored: Table = serde_json::from_str(&json).unwrap();
385        assert_eq!(
386            restored.tablespace.as_ref().map(Identifier::as_str),
387            Some("fast"),
388        );
389    }
390}