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