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}
46
47impl Diff for Table {
48    fn diff(&self, other: &Self) -> Vec<Difference> {
49        let mut out = Vec::new();
50        out.extend(diff_field("qname", &self.qname, &other.qname));
51        out.extend(diff_field(
52            "partition_by",
53            &format!("{:?}", self.partition_by),
54            &format!("{:?}", other.partition_by),
55        ));
56        out.extend(diff_field(
57            "partition_of",
58            &format!("{:?}", self.partition_of),
59            &format!("{:?}", other.partition_of),
60        ));
61        out.extend(diff_field(
62            "comment",
63            &format!("{:?}", self.comment),
64            &format!("{:?}", other.comment),
65        ));
66        out.extend(diff_field(
67            "owner",
68            &format!("{:?}", self.owner),
69            &format!("{:?}", other.owner),
70        ));
71        out.extend(diff_field(
72            "grants",
73            &format!("{:?}", self.grants),
74            &format!("{:?}", other.grants),
75        ));
76        out.extend(diff_field(
77            "rls_enabled",
78            &format!("{:?}", self.rls_enabled),
79            &format!("{:?}", other.rls_enabled),
80        ));
81        out.extend(diff_field(
82            "rls_forced",
83            &format!("{:?}", self.rls_forced),
84            &format!("{:?}", other.rls_forced),
85        ));
86        out.extend(diff_field(
87            "policies",
88            &format!("{:?}", self.policies),
89            &format!("{:?}", other.policies),
90        ));
91        out.extend(diff_field(
92            "storage",
93            &format!("{:?}", self.storage),
94            &format!("{:?}", other.storage),
95        ));
96        out.extend(diff_columns(&self.columns, &other.columns));
97        out.extend(diff_constraints(&self.constraints, &other.constraints));
98        out
99    }
100}
101
102/// Diff two column slices: add/remove/change by name, then order drift.
103fn diff_columns(
104    lhs_cols: &[crate::ir::column::Column],
105    rhs_cols: &[crate::ir::column::Column],
106) -> Vec<Difference> {
107    let mut out = Vec::new();
108    let lhs: BTreeMap<_, _> = lhs_cols.iter().map(|c| (c.name.as_str(), c)).collect();
109    let rhs: BTreeMap<_, _> = rhs_cols.iter().map(|c| (c.name.as_str(), c)).collect();
110    for (name, l) in &lhs {
111        match rhs.get(name) {
112            None => out.push(Difference::new(
113                format!("columns.{name}"),
114                "present",
115                "removed",
116            )),
117            Some(r) => {
118                out.extend(prefix_diffs(&format!("columns.{name}"), l.diff(r)));
119            }
120        }
121    }
122    for name in rhs.keys() {
123        if !lhs.contains_key(name) {
124            out.push(Difference::new(
125                format!("columns.{name}"),
126                "missing",
127                "added",
128            ));
129        }
130    }
131    let lhs_order: Vec<&str> = lhs_cols.iter().map(|c| c.name.as_str()).collect();
132    let rhs_order: Vec<&str> = rhs_cols.iter().map(|c| c.name.as_str()).collect();
133    if lhs_order != rhs_order {
134        out.push(Difference::new(
135            "columns.<order>",
136            lhs_order.join(","),
137            rhs_order.join(","),
138        ));
139    }
140    out
141}
142
143/// Diff two constraint slices: add/remove/change by qname.
144fn diff_constraints(
145    lhs_cs_slice: &[crate::ir::constraint::Constraint],
146    rhs_cs_slice: &[crate::ir::constraint::Constraint],
147) -> Vec<Difference> {
148    let mut out = Vec::new();
149    let lhs_cs: BTreeMap<_, _> = lhs_cs_slice.iter().map(|c| (&c.qname, c)).collect();
150    let rhs_cs: BTreeMap<_, _> = rhs_cs_slice.iter().map(|c| (&c.qname, c)).collect();
151    for (qn, l) in &lhs_cs {
152        match rhs_cs.get(qn) {
153            None => out.push(Difference::new(
154                format!("constraints.{qn}"),
155                "present",
156                "removed",
157            )),
158            Some(r) => {
159                out.extend(prefix_diffs(&format!("constraints.{qn}"), l.diff(r)));
160            }
161        }
162    }
163    for qn in rhs_cs.keys() {
164        if !lhs_cs.contains_key(qn) {
165            out.push(Difference::new(
166                format!("constraints.{qn}"),
167                "missing",
168                "added",
169            ));
170        }
171    }
172    out
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::identifier::Identifier;
179    use crate::ir::column_type::ColumnType;
180    use crate::ir::constraint::{ConstraintKind, Deferrable};
181
182    fn id(s: &str) -> Identifier {
183        Identifier::from_unquoted(s).unwrap()
184    }
185
186    fn qn(name: &str) -> QualifiedName {
187        QualifiedName::new(id("app"), id(name))
188    }
189
190    fn col(name: &str, ty: ColumnType, nullable: bool) -> Column {
191        Column {
192            name: id(name),
193            ty,
194            nullable,
195            default: None,
196            identity: None,
197            generated: None,
198            collation: None,
199            storage: None,
200            compression: None,
201            comment: None,
202        }
203    }
204
205    fn pk(name: &str, cols: &[&str]) -> Constraint {
206        Constraint {
207            qname: qn(name),
208            kind: ConstraintKind::PrimaryKey {
209                columns: cols.iter().map(|c| id(c)).collect(),
210                include: vec![],
211            },
212            deferrable: Deferrable::NotDeferrable,
213            comment: None,
214        }
215    }
216
217    fn base() -> Table {
218        Table {
219            qname: qn("users"),
220            columns: vec![
221                col("id", ColumnType::BigInt, false),
222                col("email", ColumnType::Text, false),
223            ],
224            constraints: vec![pk("users_pkey", &["id"])],
225            partition_by: None,
226            partition_of: None,
227            comment: None,
228            owner: None,
229            grants: vec![],
230            rls_enabled: false,
231            rls_forced: false,
232            policies: vec![],
233            storage: crate::ir::reloptions::TableStorageOptions::default(),
234            access_method: None,
235        }
236    }
237
238    #[test]
239    fn equal_tables_have_no_diff() {
240        assert!(base().canonical_eq(&base()));
241    }
242
243    #[test]
244    fn add_column_diffs() {
245        let mut b = base();
246        b.columns.push(col("name", ColumnType::Text, true));
247        let d = base().diff(&b);
248        assert!(d.iter().any(|x| x.path == "columns.name"));
249    }
250
251    #[test]
252    fn remove_column_diffs() {
253        let mut b = base();
254        b.columns.pop();
255        let d = base().diff(&b);
256        assert!(d.iter().any(|x| x.path == "columns.email"));
257    }
258
259    #[test]
260    fn reorder_columns_diffs_as_order() {
261        let mut b = base();
262        b.columns.reverse();
263        let d = base().diff(&b);
264        assert!(d.iter().any(|x| x.path == "columns.<order>"));
265    }
266
267    #[test]
268    fn add_constraint_diffs() {
269        let mut b = base();
270        b.constraints.push(pk("users_alt_pkey", &["email"]));
271        let d = base().diff(&b);
272        assert!(d.iter().any(|x| x.path == "constraints.app.users_alt_pkey"));
273    }
274
275    #[test]
276    fn changed_column_definition_diffs_under_path() {
277        let mut b = base();
278        b.columns[1].nullable = true;
279        let d = base().diff(&b);
280        assert!(d.iter().any(|x| x.path == "columns.email.nullable"));
281    }
282
283    #[test]
284    fn owner_change_diffs() {
285        let mut b = base();
286        b.owner = Some(id("new_owner"));
287        assert!(base().diff(&b).iter().any(|x| x.path == "owner"));
288    }
289
290    #[test]
291    fn grants_change_diffs() {
292        let mut b = base();
293        b.grants.push(crate::ir::grant::Grant {
294            grantee: crate::ir::grant::GrantTarget::Public,
295            privilege: crate::ir::grant::Privilege::Select,
296            with_grant_option: false,
297            columns: None,
298        });
299        assert!(base().diff(&b).iter().any(|x| x.path == "grants"));
300    }
301
302    #[test]
303    fn rls_enabled_change_diffs() {
304        let mut b = base();
305        b.rls_enabled = true;
306        assert!(base().diff(&b).iter().any(|x| x.path == "rls_enabled"));
307    }
308
309    #[test]
310    fn rls_forced_change_diffs() {
311        let mut b = base();
312        b.rls_forced = true;
313        assert!(base().diff(&b).iter().any(|x| x.path == "rls_forced"));
314    }
315
316    #[test]
317    fn policies_change_diffs() {
318        use crate::ir::grant::GrantTarget;
319        use crate::ir::policy::{Policy, PolicyCommand};
320        let mut b = base();
321        b.policies.push(Policy {
322            name: id("p1"),
323            permissive: true,
324            command: PolicyCommand::All,
325            roles: vec![GrantTarget::Public],
326            using: None,
327            with_check: None,
328        });
329        assert!(base().diff(&b).iter().any(|x| x.path == "policies"));
330    }
331
332    #[test]
333    fn storage_change_diffs() {
334        let mut b = base();
335        b.storage = crate::ir::reloptions::TableStorageOptions {
336            fillfactor: Some(80),
337            ..Default::default()
338        };
339        assert!(base().diff(&b).iter().any(|x| x.path == "storage"));
340    }
341
342    #[test]
343    fn access_method_field_roundtrips() {
344        let mut t = base();
345        assert!(
346            t.access_method.is_none(),
347            "default access_method must be None"
348        );
349        t.access_method = Some(Identifier::from_unquoted("columnar").unwrap());
350        assert_eq!(
351            t.access_method.as_ref().map(Identifier::as_str),
352            Some("columnar"),
353        );
354        // JSON round-trip preserves the field.
355        let json = serde_json::to_string(&t).unwrap();
356        let restored: Table = serde_json::from_str(&json).unwrap();
357        assert_eq!(
358            restored.access_method.as_ref().map(Identifier::as_str),
359            Some("columnar"),
360        );
361    }
362}