Skip to main content

safe_migrate/model/
relation.rs

1// FILE: src/model/relation.rs
2use crate::ast::identifiers::ObjectId;
3use crate::model::column::Column;
4use serde::{Deserialize, Serialize};
5use std::collections::{HashMap, HashSet};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
8pub enum Privilege {
9    Select,
10    Insert,
11    Update,
12    Delete,
13    Truncate,
14    References,
15    Trigger,
16    All,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
20pub struct PrivilegeMatrix {
21    /// Maps role identity to the set of privileges they possess on this relation
22    pub grants: HashMap<ObjectId, HashSet<Privilege>>,
23}
24
25impl PrivilegeMatrix {
26    pub fn grant(&mut self, role: ObjectId, privileges: HashSet<Privilege>) {
27        self.grants.entry(role).or_default().extend(privileges);
28    }
29
30    pub fn revoke(&mut self, role: &ObjectId, privileges: &HashSet<Privilege>) {
31        if let Some(owned) = self.grants.get_mut(role) {
32            if privileges.contains(&Privilege::All) {
33                owned.clear();
34            } else {
35                for p in privileges {
36                    owned.remove(p);
37                }
38            }
39        }
40    }
41
42    pub fn has_privilege(&self, role: &ObjectId, privilege: Privilege) -> bool {
43        self.grants.get(role).is_some_and(|set| {
44            set.contains(&privilege)
45                || (privilege != Privilege::All && set.contains(&Privilege::All))
46        })
47    }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub enum RelationKind {
52    Table,
53    View,
54    MaterializedView,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub enum Persistence {
59    Permanent,
60    Temporary,
61    Unlogged,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct RelationState {
66    pub id: ObjectId,
67    pub owner: ObjectId,
68    pub columns: Vec<Column>,
69    pub generation: u64,
70    pub estimated_rows: Option<u64>,
71    pub relpages: Option<u64>,
72    pub kind: RelationKind,
73    pub persistence: Persistence,
74    pub triggers: HashSet<String>,
75    pub policies: HashSet<String>,
76    pub last_analyze: Option<String>,
77    pub last_autoanalyze: Option<String>,
78    pub created_at_tx_depth: usize, // Phase 1 FIX: Same-Transaction index tracking
79    pub privileges: PrivilegeMatrix,
80    pub partition_type: Option<String>, // e.g., "RANGE", "LIST", "HASH"
81    pub partition_by: Option<String>,   // The partition key expression
82    #[serde(default)]
83    pub is_fk_dependency: bool,
84}
85
86impl Default for RelationState {
87    fn default() -> Self {
88        Self {
89            id: ObjectId::new("public", "dummy"),
90            owner: ObjectId::new("public", "postgres"),
91            columns: Vec::new(),
92            generation: 0,
93            estimated_rows: Some(0),
94            relpages: None,
95            kind: RelationKind::Table,
96            persistence: Persistence::Permanent,
97            triggers: HashSet::new(),
98            policies: HashSet::new(),
99            last_analyze: None,
100            last_autoanalyze: None,
101            created_at_tx_depth: 0,
102            privileges: PrivilegeMatrix::default(),
103            partition_type: None,
104            partition_by: None,
105            is_fk_dependency: false,
106        }
107    }
108}
109
110impl RelationState {
111    pub fn new(
112        id: ObjectId,
113        owner: ObjectId,
114        generation: u64,
115        estimated_rows: Option<u64>,
116        kind: RelationKind,
117        persistence: Persistence,
118        created_at_tx_depth: usize,
119    ) -> Self {
120        Self {
121            id,
122            owner,
123            columns: Vec::new(),
124            generation,
125            estimated_rows,
126            relpages: None,
127            kind,
128            persistence,
129            triggers: HashSet::new(),
130            policies: HashSet::new(),
131            last_analyze: None,
132            last_autoanalyze: None,
133            created_at_tx_depth,
134            privileges: PrivilegeMatrix::default(),
135            partition_type: None,
136            partition_by: None,
137            is_fk_dependency: false,
138        }
139    }
140
141    pub fn mark_fk_dependency(&mut self) {
142        self.is_fk_dependency = true;
143    }
144
145    pub fn apply_column_action(&mut self, action: &ColumnAction) {
146        match action {
147            ColumnAction::Add {
148                name,
149                data_type,
150                not_null,
151                default,
152            } => {
153                if !self.columns.iter().any(|c| c.name == *name) {
154                    let serial_type = data_type
155                        .as_deref()
156                        .map(str::trim)
157                        .map(str::to_ascii_lowercase)
158                        .and_then(|ty| match ty.as_str() {
159                            "smallserial" | "serial2" => Some("smallint"),
160                            "serial" | "serial4" => Some("integer"),
161                            "bigserial" | "serial8" => Some("bigint"),
162                            _ => None,
163                        });
164                    let is_serial = serial_type.is_some();
165                    let normalized_default = if is_serial {
166                        Some(crate::analysis::expr_ir::ExprIr::FunctionCall {
167                            name: "nextval".to_string(),
168                            args: Vec::new(),
169                        })
170                    } else if matches!(
171                        default,
172                        Some(crate::analysis::expr_ir::ExprIr::Literal(value))
173                            if value.trim().eq_ignore_ascii_case("null")
174                    ) {
175                        None
176                    } else {
177                        default.clone()
178                    };
179                    self.columns.push(Column {
180                        name: name.clone(),
181                        data_type: serial_type
182                            .map(str::to_string)
183                            .or_else(|| data_type.clone()),
184                        default: normalized_default,
185                        is_nullable: !(*not_null || is_serial),
186                        avg_width: None,
187                        default_expr_text: None,
188                        type_modifier: None,
189                    });
190                }
191            }
192            ColumnAction::Drop { name } => {
193                self.columns.retain(|c| c.name != *name);
194            }
195            ColumnAction::Rename { from, to } => {
196                if let Some(pos) = self.columns.iter().position(|c| c.name == *from)
197                    && !self.columns.iter().any(|c| c.name == *to)
198                {
199                    self.columns[pos].name = to.clone();
200                }
201            }
202            ColumnAction::SetNotNull { name } => {
203                if let Some(col) = self.columns.iter_mut().find(|c| c.name == *name) {
204                    col.is_nullable = false;
205                }
206            }
207            ColumnAction::DropNotNull { name } => {
208                if let Some(col) = self.columns.iter_mut().find(|c| c.name == *name) {
209                    col.is_nullable = true;
210                }
211            }
212            ColumnAction::SetType { name, data_type } => {
213                if let Some(col) = self.columns.iter_mut().find(|c| c.name == *name) {
214                    col.data_type = Some(data_type.clone());
215                }
216            }
217            ColumnAction::SetDefault { name, default } => {
218                if let Some(col) = self.columns.iter_mut().find(|c| c.name == *name) {
219                    col.default = if matches!(
220                        default,
221                        Some(crate::analysis::expr_ir::ExprIr::Literal(value))
222                            if value.trim().eq_ignore_ascii_case("null")
223                    ) {
224                        None
225                    } else {
226                        default.clone()
227                    };
228                    // A migration mutation supersedes raw baseline catalog text.
229                    col.default_expr_text = None;
230                }
231            }
232        }
233    }
234
235    pub fn has_column(&self, name: &str) -> bool {
236        self.columns.iter().any(|c| c.name == name)
237    }
238
239    pub fn get_column(&self, name: &str) -> Option<&Column> {
240        self.columns.iter().find(|c| c.name == name)
241    }
242
243    pub fn is_stale(&self) -> bool {
244        self.last_analyze.is_none() && self.last_autoanalyze.is_none()
245    }
246}
247
248#[derive(Debug, Clone, PartialEq)]
249pub enum ColumnAction {
250    Add {
251        name: String,
252        data_type: Option<String>,
253        not_null: bool,
254        default: Option<crate::analysis::expr_ir::ExprIr>,
255    },
256    Drop {
257        name: String,
258    },
259    Rename {
260        from: String,
261        to: String,
262    },
263    SetNotNull {
264        name: String,
265    },
266    DropNotNull {
267        name: String,
268    },
269    SetType {
270        name: String,
271        data_type: String,
272    },
273    SetDefault {
274        name: String,
275        default: Option<crate::analysis::expr_ir::ExprIr>,
276    },
277}
278
279#[allow(clippy::large_enum_variant)]
280#[derive(Debug, Clone, PartialEq)]
281pub enum RelationOverlay {
282    Present(RelationState),
283    Dropped,
284}