Skip to main content

safe_migrate/analysis/
graph.rs

1// FILE: src/analysis/graph.rs
2use crate::ast::identifiers::ObjectId;
3use std::collections::HashSet;
4
5#[derive(Debug, Clone, PartialEq)]
6pub enum DependencyKind {
7    ForeignKey {
8        constraint_name: Option<String>,
9        from_columns: Vec<String>,
10        to_columns: Vec<String>,
11        from_generation: u64,
12    },
13    ViewDependency {
14        view_generation: u64,
15    },
16    IndexOnRelation {
17        using_method: Option<String>,
18        has_predicate: bool,
19        is_concurrent: bool,
20        is_unique: bool,
21        eligibility_known: bool,
22    },
23    RenameTo,
24    PartitionOf,
25    SequenceOwnedBy {
26        column: String,
27    },
28    ColumnGeneratedFrom {
29        column: String,
30        depends_on_column: String,
31    },
32    TriggerOnTable {
33        trigger_id: ObjectId,
34        function_id: ObjectId,
35    },
36    PublicationIncludes {
37        publication_name: String,
38    },
39}
40
41#[derive(Debug, Clone, PartialEq)]
42pub struct DependencyEdge {
43    pub dependent: ObjectId,
44    pub referenced: ObjectId,
45    pub kind: DependencyKind,
46}
47
48impl DependencyEdge {
49    pub fn new(dependent: ObjectId, referenced: ObjectId, kind: DependencyKind) -> Self {
50        Self {
51            dependent,
52            referenced,
53            kind,
54        }
55    }
56}
57
58#[derive(Debug, Clone, Default)]
59pub struct DependencyGraph {
60    pub edges: Vec<DependencyEdge>,
61}
62
63impl DependencyGraph {
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    // Phase 3 FIX (BUG-004): Traverse rename chains dynamically for accurate topology reads
69    pub fn is_referenced_by_view(&self, id: &ObjectId) -> Vec<&ObjectId> {
70        let target = self.resolve_rename(id);
71        self.edges
72            .iter()
73            .filter(|e| {
74                matches!(e.kind, DependencyKind::ViewDependency { .. })
75                    && (self.resolve_rename(&e.referenced) == target || &e.referenced == id)
76            })
77            .map(|e| self.resolve_rename(&e.dependent))
78            .collect()
79    }
80
81    pub fn is_referenced_by_fk(&self, id: &ObjectId) -> Vec<(&ObjectId, u64)> {
82        let target = self.resolve_rename(id);
83        self.edges
84            .iter()
85            .filter_map(|e| {
86                if let DependencyKind::ForeignKey {
87                    from_generation, ..
88                } = &e.kind
89                    && (self.resolve_rename(&e.referenced) == target || &e.referenced == id)
90                {
91                    Some((self.resolve_rename(&e.dependent), *from_generation))
92                } else {
93                    None
94                }
95            })
96            .collect()
97    }
98
99    pub fn is_referenced_by_index(&self, id: &ObjectId) -> Vec<&ObjectId> {
100        let target = self.resolve_rename(id);
101        self.edges
102            .iter()
103            .filter(|e| {
104                matches!(e.kind, DependencyKind::IndexOnRelation { .. })
105                    && (self.resolve_rename(&e.referenced) == target || &e.referenced == id)
106            })
107            .map(|e| self.resolve_rename(&e.dependent))
108            .collect()
109    }
110
111    pub fn partitions_of(&self, id: &ObjectId) -> Vec<&ObjectId> {
112        let target = self.resolve_rename(id);
113        self.edges
114            .iter()
115            .filter(|e| {
116                matches!(e.kind, DependencyKind::PartitionOf)
117                    && (self.resolve_rename(&e.referenced) == target || &e.referenced == id)
118            })
119            .map(|e| self.resolve_rename(&e.dependent))
120            .collect()
121    }
122
123    pub fn resolve_rename<'a>(&'a self, id: &'a ObjectId) -> &'a ObjectId {
124        let mut current = id;
125        let mut visited = HashSet::new();
126        loop {
127            // A rename back to an earlier name is valid PostgreSQL. The graph
128            // retains historical aliases, so resolve only acyclic paths; a
129            // cycle has no unique alias target and must leave the supplied
130            // identity unchanged.
131            if !visited.insert(current.clone()) {
132                return id;
133            }
134            match self
135                .edges
136                .iter()
137                .find(|e| matches!(e.kind, DependencyKind::RenameTo) && &e.dependent == current)
138            {
139                Some(edge) => current = &edge.referenced,
140                None => return current,
141            }
142        }
143    }
144
145    // Phase 3 FIX (BUG-012): Reject cycle topologies
146    pub fn check_partition_cycle(&self, parent: &ObjectId, child: &ObjectId) -> bool {
147        let resolved_parent = self.resolve_rename(parent);
148        let resolved_child = self.resolve_rename(child);
149        if resolved_parent == resolved_child {
150            return true;
151        }
152
153        let mut current_parent = resolved_parent;
154        let mut visited = HashSet::new();
155        loop {
156            if !visited.insert(current_parent.clone()) {
157                // The existing ancestry is already malformed. Reject another
158                // attachment instead of looping or extending the cycle.
159                return true;
160            }
161            let maybe_edge = self.edges.iter().find(|e| {
162                matches!(e.kind, DependencyKind::PartitionOf)
163                    && self.resolve_rename(&e.dependent) == current_parent
164            });
165            if let Some(edge) = maybe_edge {
166                let p = self.resolve_rename(&edge.referenced);
167                if p == resolved_child {
168                    return true;
169                }
170                current_parent = p;
171            } else {
172                break;
173            }
174        }
175        false
176    }
177
178    pub fn propagate_rename(&mut self, old_id: &ObjectId, new_id: &ObjectId) {
179        for edge in &mut self.edges {
180            if matches!(edge.kind, DependencyKind::RenameTo) {
181                continue;
182            }
183            if edge.dependent == *old_id {
184                edge.dependent = new_id.clone();
185            }
186            if edge.referenced == *old_id {
187                edge.referenced = new_id.clone();
188            }
189            if let DependencyKind::TriggerOnTable {
190                trigger_id,
191                function_id,
192            } = &mut edge.kind
193            {
194                if *trigger_id == *old_id {
195                    *trigger_id = new_id.clone();
196                }
197                if *function_id == *old_id {
198                    *function_id = new_id.clone();
199                }
200            }
201        }
202    }
203
204    pub fn triggers_on(&self, table_id: &ObjectId) -> Vec<&DependencyEdge> {
205        self.edges
206            .iter()
207            .filter(|e| {
208                matches!(e.kind, DependencyKind::TriggerOnTable { .. }) && &e.referenced == table_id
209            })
210            .collect()
211    }
212
213    pub fn triggers_for_function(&self, function_id: &ObjectId) -> Vec<&DependencyEdge> {
214        let normalize = |id: &ObjectId| -> ObjectId {
215            let name = if let Some(idx) = id.name.find('(') {
216                format!("{}()", &id.name[..idx])
217            } else {
218                id.name.clone()
219            };
220            ObjectId {
221                schema: id.schema.clone(),
222                name,
223                inferred_schema: id.inferred_schema,
224            }
225        };
226        let target_id = normalize(function_id);
227        self.edges
228            .iter()
229            .filter(|e| {
230                if let DependencyKind::TriggerOnTable {
231                    function_id: fid, ..
232                } = &e.kind
233                {
234                    normalize(fid) == target_id
235                } else {
236                    false
237                }
238            })
239            .collect()
240    }
241}