Skip to main content

pgevolve_core/diff/
table_op.rs

1//! `TableOp` — per-table column / constraint operations.
2//!
3//! Carried inside [`Change::AlterTable`](super::change::Change::AlterTable).
4
5use serde::{Deserialize, Serialize};
6
7use crate::identifier::Identifier;
8use crate::ir::column::{Column, Compression, Generated, Identity, StorageKind};
9use crate::ir::column_type::ColumnType;
10use crate::ir::constraint::Constraint;
11use crate::ir::default_expr::{DefaultExpr, NormalizedExpr};
12
13use super::destructiveness::Destructiveness;
14
15/// One table-level op paired with its destructiveness classification.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct TableOpEntry {
18    /// The table operation.
19    pub op: TableOp,
20    /// Risk classification.
21    pub destructiveness: Destructiveness,
22}
23
24/// One column / constraint / comment operation on a table.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
27pub enum TableOp {
28    /// Add a column.
29    AddColumn(Column),
30    /// Drop a column.
31    DropColumn {
32        /// Column name.
33        name: Identifier,
34        /// Whether the column is on a populated table; the planner refines this
35        /// from `pg_class.reltuples`. Differ leaves `false` if unknown.
36        is_populated: bool,
37    },
38    /// Change a column's data type. Carries an optional `USING` clause; v0.1
39    /// emits `None` and leaves the rewrite pass to decide.
40    AlterColumnType {
41        /// Column name.
42        name: Identifier,
43        /// Existing type in the target.
44        from: ColumnType,
45        /// Desired type in the source.
46        to: ColumnType,
47        /// Optional `USING` expression.
48        using: Option<NormalizedExpr>,
49    },
50    /// Toggle a column's `NOT NULL`-ness.
51    SetColumnNullable {
52        /// Column name.
53        name: Identifier,
54        /// Target nullability (`true` = nullable, `false` = `NOT NULL`).
55        nullable: bool,
56    },
57    /// Set or clear a column's `DEFAULT` expression.
58    SetColumnDefault {
59        /// Column name.
60        name: Identifier,
61        /// New default (`None` = drop the default).
62        default: Option<DefaultExpr>,
63    },
64    /// Set or clear a column's identity specification.
65    SetColumnIdentity {
66        /// Column name.
67        name: Identifier,
68        /// New identity (`None` = drop identity).
69        identity: Option<Identity>,
70    },
71    /// Set or clear a column's generated-column expression.
72    SetColumnGenerated {
73        /// Column name.
74        name: Identifier,
75        /// New generated spec (`None` = drop generated-ness).
76        generated: Option<Generated>,
77    },
78    /// Set or clear a column-level comment.
79    SetColumnComment {
80        /// Column name.
81        name: Identifier,
82        /// New comment (`None` clears).
83        comment: Option<String>,
84    },
85    /// Change a column's TOAST storage strategy.
86    SetColumnStorage {
87        /// Column name.
88        name: Identifier,
89        /// Previous storage. Caller resolves `Column.storage = None` to
90        /// the type default before emitting, so both sides are explicit.
91        /// Carried so the lint rule (Stage 6) can detect downgrades
92        /// without needing to re-derive the previous state.
93        from: StorageKind,
94        /// New storage. Same resolution rule as `from`.
95        to: StorageKind,
96    },
97    /// Change a column's TOAST compression codec.
98    SetColumnCompression {
99        /// Column name.
100        name: Identifier,
101        /// New compression. `None` means "use cluster default" — emits
102        /// `SET COMPRESSION DEFAULT` at render time.
103        compression: Option<Compression>,
104    },
105
106    /// Add a table constraint.
107    AddConstraint(Constraint),
108    /// Drop a table constraint by name.
109    DropConstraint {
110        /// Constraint name (no schema — constraint names live in the table's namespace).
111        name: Identifier,
112    },
113    /// Set or clear a constraint comment.
114    SetConstraintComment {
115        /// Constraint name.
116        name: Identifier,
117        /// New comment.
118        comment: Option<String>,
119    },
120
121    /// Set or clear a table-level comment.
122    SetTableComment {
123        /// New comment (`None` clears).
124        comment: Option<String>,
125    },
126
127    /// Move the table to a different tablespace (`None` = `pg_default`).
128    SetTableSpace {
129        /// New tablespace name; `None` means the cluster default (`pg_default`).
130        name: Option<Identifier>,
131    },
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    fn id(s: &str) -> Identifier {
139        Identifier::from_unquoted(s).unwrap()
140    }
141
142    #[test]
143    fn add_column_serde_round_trip() {
144        let op = TableOp::AddColumn(Column {
145            name: id("email"),
146            ty: ColumnType::Text,
147            nullable: true,
148            default: None,
149            identity: None,
150            generated: None,
151            collation: None,
152            storage: None,
153            compression: None,
154            comment: None,
155        });
156        let entry = TableOpEntry {
157            op,
158            destructiveness: Destructiveness::Safe,
159        };
160        let json = serde_json::to_string(&entry).unwrap();
161        let back: TableOpEntry = serde_json::from_str(&json).unwrap();
162        assert_eq!(entry, back);
163    }
164
165    #[test]
166    fn drop_column_serde_round_trip() {
167        let entry = TableOpEntry {
168            op: TableOp::DropColumn {
169                name: id("email"),
170                is_populated: false,
171            },
172            destructiveness: Destructiveness::RequiresApprovalAndDataLossWarning {
173                reason: "drops column email".into(),
174            },
175        };
176        let json = serde_json::to_string(&entry).unwrap();
177        let back: TableOpEntry = serde_json::from_str(&json).unwrap();
178        assert_eq!(entry, back);
179    }
180
181    #[test]
182    fn alter_column_type_serde_round_trip() {
183        let entry = TableOpEntry {
184            op: TableOp::AlterColumnType {
185                name: id("count"),
186                from: ColumnType::Integer,
187                to: ColumnType::BigInt,
188                using: None,
189            },
190            destructiveness: Destructiveness::Safe,
191        };
192        let json = serde_json::to_string(&entry).unwrap();
193        let back: TableOpEntry = serde_json::from_str(&json).unwrap();
194        assert_eq!(entry, back);
195    }
196
197    #[test]
198    fn set_table_comment_serde_round_trip() {
199        let entry = TableOpEntry {
200            op: TableOp::SetTableComment {
201                comment: Some("the users table".into()),
202            },
203            destructiveness: Destructiveness::Safe,
204        };
205        let json = serde_json::to_string(&entry).unwrap();
206        let back: TableOpEntry = serde_json::from_str(&json).unwrap();
207        assert_eq!(entry, back);
208    }
209}