Skip to main content

safe_migrate/rules/
drift.rs

1use crate::analysis::mutations::Mutation;
2use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult};
3use crate::ast::identifiers::ObjectId;
4use crate::engine::config::Config;
5use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
6use crate::rules::Rule;
7
8pub struct DriftDetectionRule;
9
10impl Rule for DriftDetectionRule {
11    fn id(&self) -> &'static str {
12        "schema-drift"
13    }
14    fn default_tier(&self) -> ViolationTier {
15        ViolationTier::Tier1
16    }
17    fn recipe(&self) -> &'static str {
18        "This migration references a database object that does not exist in the production baseline. If this object exists in production, sync the cache with `safe-migrate sync`. If it does not, this migration may fail."
19    }
20
21    fn evaluate(
22        &self,
23        mutation: &Mutation,
24        _result: &MutationResult,
25        pre_state: &crate::analysis::state::PreState,
26        state: &AnalysisState,
27        _config: &Config,
28        _cascade_closure: Option<&CascadeResult>,
29    ) -> Vec<Violation> {
30        // A missing cache is not proof that production lacks an object. Keep
31        // the stateful analyzer useful offline without turning every ALTER or
32        // DROP into a false blocking baseline-drift finding.
33        if !state.baseline_available {
34            return Vec::new();
35        }
36
37        let mut violations = Vec::new();
38
39        match mutation {
40            Mutation::Opaque(crate::analysis::mutations::OpaqueMutation::UnresolvedReference {
41                object_kind,
42                object_name,
43            }) => {
44                violations.push(Violation { source_range: None,
45                    rule_id: self.id(),
46                        operation_kind: OperationKind::UnresolvedReference,
47                    object_kind: object_kind.clone(),
48                    object_name: object_name.clone(),
49                    tier: self.default_tier(),
50                    reason: format!(
51                        "Migration references {} \"{}\" which does not exist in the production baseline",
52                        object_kind,
53                        object_name
54                    ),
55                    recipe: self.recipe(),
56                    dedup_key: None,
57                    sql: None,
58                    fk_dependency_related: false,
59                });
60            }
61            Mutation::DropTable(d) => {
62                if !pre_state.relations.contains_key(&d.id) {
63                    violations.push(Violation { source_range: None,
64                        rule_id: self.id(),
65                        operation_kind: OperationKind::DropTable,
66                        object_kind: ObjectKind::Table,
67                        object_name: d.id.to_string(),
68                        tier: self.default_tier(),
69                        reason: format!(
70                            "Migration DROPs table \"{}\" which does not exist in the production baseline",
71                            d.id
72                        ),
73                        recipe: self.recipe(),
74                        dedup_key: None,
75                                    sql: None,
76                                    fk_dependency_related: false,
77                    });
78                }
79            }
80            Mutation::AlterTable(a) => {
81                if !pre_state.relations.contains_key(&a.id) {
82                    violations.push(Violation { source_range: None,
83                        rule_id: self.id(),
84                        operation_kind: OperationKind::Other("alter_table".to_string()),
85                        object_kind: ObjectKind::Table,
86                        object_name: a.id.to_string(),
87                        tier: self.default_tier(),
88                        reason: format!(
89                            "Migration ALTERs table \"{}\" which does not exist in the production baseline",
90                            a.id
91                        ),
92                        recipe: self.recipe(),
93                        dedup_key: None,
94                                    sql: None,
95                                    fk_dependency_related: false,
96                    });
97                }
98            }
99            Mutation::DropView(d) => {
100                for id in &d.ids {
101                    if !pre_state.relations.contains_key(id) {
102                        violations.push(Violation { source_range: None,
103                            rule_id: self.id(),
104                            operation_kind: OperationKind::DropView,
105                            object_kind: ObjectKind::View,
106                            object_name: id.to_string(),
107                            tier: self.default_tier(),
108                            reason: format!(
109                                "Migration DROPs view \"{}\" which does not exist in the production baseline",
110                                id
111                            ),
112                            recipe: self.recipe(),
113                            dedup_key: None,
114                                            sql: None,
115                                            fk_dependency_related: false,
116                        });
117                    }
118                }
119            }
120            Mutation::DropMaterializedView(d) => {
121                for id in &d.ids {
122                    if !pre_state.relations.contains_key(id) {
123                        violations.push(Violation { source_range: None,
124                            rule_id: self.id(),
125                            operation_kind: OperationKind::DropMaterializedView,
126                            object_kind: ObjectKind::MaterializedView,
127                            object_name: id.to_string(),
128                            tier: self.default_tier(),
129                            reason: format!(
130                                "Migration DROPs materialized view \"{}\" which does not exist in the production baseline",
131                                id
132                            ),
133                            recipe: self.recipe(),
134                            dedup_key: None,
135                                            sql: None,
136                                            fk_dependency_related: false,
137                        });
138                    }
139                }
140            }
141            Mutation::DropSequence(d) => {
142                for id in &d.ids {
143                    if !pre_state.sequences.contains_key(id) {
144                        violations.push(Violation { source_range: None,
145                            rule_id: self.id(),
146                            operation_kind: OperationKind::DropSequence,
147                            object_kind: ObjectKind::Sequence,
148                            object_name: id.to_string(),
149                            tier: self.default_tier(),
150                            reason: format!(
151                                "Migration DROPs sequence \"{}\" which does not exist in the production baseline",
152                                id
153                            ),
154                            recipe: self.recipe(),
155                            dedup_key: None,
156                                            sql: None,
157                                            fk_dependency_related: false,
158                        });
159                    }
160                }
161            }
162            Mutation::DropFunction(d) => {
163                for sig in &d.signatures {
164                    let sig_str = format!("{}({})", sig.name.name.resolve(), sig.params.join(","));
165                    let schema = state.resolve_function_schema(&sig.name, &sig_str);
166                    let id = ObjectId::new(schema, sig_str);
167                    if !pre_state.functions.contains_key(&id) {
168                        violations.push(Violation { source_range: None,
169                            rule_id: self.id(),
170                            operation_kind: OperationKind::DropFunction,
171                            object_kind: ObjectKind::Function,
172                            object_name: id.to_string(),
173                            tier: self.default_tier(),
174                            reason: format!(
175                                "Migration DROPs function \"{}\" which does not exist in the production baseline",
176                                id
177                            ),
178                            recipe: self.recipe(),
179                            dedup_key: None,
180                                            sql: None,
181                                            fk_dependency_related: false,
182                        });
183                    }
184                }
185            }
186            Mutation::DropProcedure(d) => {
187                for sig in &d.signatures {
188                    let sig_str = format!("{}({})", sig.name.name.resolve(), sig.params.join(","));
189                    let schema = state.resolve_function_schema(&sig.name, &sig_str);
190                    let id = ObjectId::new(schema, sig_str);
191                    if !pre_state.functions.contains_key(&id) {
192                        violations.push(Violation { source_range: None,
193                            rule_id: self.id(),
194                            operation_kind: OperationKind::DropProcedure,
195                            object_kind: ObjectKind::Procedure,
196                            object_name: id.to_string(),
197                            tier: self.default_tier(),
198                            reason: format!(
199                                "Migration DROPs procedure \"{}\" which does not exist in the production baseline",
200                                id
201                            ),
202                            recipe: self.recipe(),
203                            dedup_key: None,
204                                            sql: None,
205                                            fk_dependency_related: false,
206                        });
207                    }
208                }
209            }
210            Mutation::DropIndex(d) => {
211                if !pre_state.indexes.iter().any(|idx| idx.dependent == d.id) {
212                    violations.push(Violation { source_range: None,
213                        rule_id: self.id(),
214                        operation_kind: OperationKind::DropIndex,
215                        object_kind: ObjectKind::Index,
216                        object_name: d.id.to_string(),
217                        tier: self.default_tier(),
218                        reason: format!(
219                            "Migration DROPs index \"{}\" which does not exist in the production baseline",
220                            d.id
221                        ),
222                        recipe: self.recipe(),
223                        dedup_key: None,
224                                    sql: None,
225                                    fk_dependency_related: false,
226                    });
227                }
228            }
229            Mutation::DropDomain(d) => {
230                for id in &d.ids {
231                    if !pre_state.types.contains_key(id) {
232                        violations.push(Violation { source_range: None,
233                            rule_id: self.id(),
234                            operation_kind: OperationKind::DropDomain,
235                            object_kind: ObjectKind::Domain,
236                            object_name: id.to_string(),
237                            tier: self.default_tier(),
238                            reason: format!(
239                                "Migration DROPs domain \"{}\" which does not exist in the production baseline",
240                                id
241                            ),
242                            recipe: self.recipe(),
243                            dedup_key: None,
244                                            sql: None,
245                                            fk_dependency_related: false,
246                        });
247                    }
248                }
249            }
250            Mutation::DropType(d) => {
251                for id in &d.ids {
252                    if !pre_state.types.contains_key(id) {
253                        violations.push(Violation { source_range: None,
254                            rule_id: self.id(),
255                            operation_kind: OperationKind::DropType,
256                            object_kind: ObjectKind::Type,
257                            object_name: id.to_string(),
258                            tier: self.default_tier(),
259                            reason: format!(
260                                "Migration DROPs type \"{}\" which does not exist in the production baseline",
261                                id
262                            ),
263                            recipe: self.recipe(),
264                            dedup_key: None,
265                            sql: None,
266                            fk_dependency_related: false,
267                        });
268                    }
269                }
270            }
271            Mutation::Rename(r) => {
272                if !pre_state.relations.contains_key(&r.old_id)
273                    && !pre_state.types.contains_key(&r.old_id)
274                    && !pre_state.sequences.contains_key(&r.old_id)
275                    && !pre_state
276                        .indexes
277                        .iter()
278                        .any(|idx| idx.dependent == r.old_id)
279                {
280                    violations.push(Violation { source_range: None,
281                        rule_id: self.id(),
282                        operation_kind: OperationKind::Rename,
283                        object_kind: ObjectKind::Table, // Or general
284                        object_name: r.old_id.to_string(),
285                        tier: self.default_tier(),
286                        reason: format!(
287                            "Migration RENAMEs object \"{}\" which does not exist in the production baseline",
288                            r.old_id
289                        ),
290                        recipe: self.recipe(),
291                        dedup_key: None,
292                        sql: None,
293                        fk_dependency_related: false,
294                    });
295                }
296            }
297            Mutation::AlterType(a) if !pre_state.types.contains_key(&a.id) => {
298                violations.push(Violation { source_range: None,
299                    rule_id: self.id(),
300                    operation_kind: OperationKind::AlterType,
301                    object_kind: ObjectKind::Type,
302                    object_name: a.id.to_string(),
303                    tier: self.default_tier(),
304                    reason: format!(
305                        "Migration ALTERs type \"{}\" which does not exist in the production baseline",
306                        a.id
307                    ),
308                    recipe: self.recipe(),
309                    dedup_key: None,
310                            sql: None,
311                            fk_dependency_related: false,
312                });
313            }
314            Mutation::AlterFunction(f) if !pre_state.functions.contains_key(&f.id) => {
315                violations.push(Violation { source_range: None,
316                    rule_id: self.id(),
317                    operation_kind: OperationKind::AlterFunction,
318                    object_kind: ObjectKind::Function,
319                    object_name: f.id.to_string(),
320                    tier: self.default_tier(),
321                    reason: format!(
322                        "Migration ALTERs function \"{}\" which does not exist in the production baseline",
323                        f.id
324                    ),
325                    recipe: self.recipe(),
326                    dedup_key: None,
327                            sql: None,
328                            fk_dependency_related: false,
329                });
330            }
331            Mutation::AlterProcedure(p) if !pre_state.functions.contains_key(&p.id) => {
332                violations.push(Violation { source_range: None,
333                    rule_id: self.id(),
334                    operation_kind: OperationKind::AlterProcedure,
335                    object_kind: ObjectKind::Procedure,
336                    object_name: p.id.to_string(),
337                    tier: self.default_tier(),
338                    reason: format!(
339                        "Migration ALTERs procedure \"{}\" which does not exist in the production baseline",
340                        p.id
341                    ),
342                    recipe: self.recipe(),
343                    dedup_key: None,
344                            sql: None,
345                            fk_dependency_related: false,
346                });
347            }
348            Mutation::CreateTable(c) => {
349                // Warn if parent table doesn't exist for partitioned tables
350                if let Some(parent_id) = &c.partition_of
351                    && !pre_state.relations.contains_key(parent_id)
352                {
353                    violations.push(Violation { source_range: None,
354                        rule_id: self.id(),
355                        operation_kind: OperationKind::CreateTable,
356                        object_kind: ObjectKind::Table,
357                        object_name: c.id.to_string(),
358                        tier: self.default_tier(),
359                        reason: format!(
360                            "Migration creates {} as a partition of parent \"{}\" which does not exist in the production baseline. Parent must be created first.",
361                            c.id, parent_id
362                        ),
363                        recipe: self.recipe(),
364                        dedup_key: None,
365                        sql: None,
366                        fk_dependency_related: false,
367                    });
368                }
369            }
370            _ => {}
371        }
372
373        // When sync was deliberately scoped, an omitted schema is unknown,
374        // not proof of production drift. Keep the warning, but make it a
375        // coverage warning rather than a false Tier 1 absence claim.
376        for violation in &mut violations {
377            if let Some(schema) =
378                state.baseline_scope_omits_displayed_object(&violation.object_name)
379            {
380                violation.tier = ViolationTier::Tier2;
381                violation.reason = format!(
382                    "Cache does not cover schema \"{}\"; safe-migrate cannot verify whether {} exists in the production baseline",
383                    schema, violation.object_name
384                );
385                violation.recipe = "Run `safe-migrate sync --schemas ...` with this schema included, or use an unscoped sync, before treating this as a production-drift result.";
386            }
387        }
388
389        violations
390    }
391}