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 !d.if_exists {
63                    for id in &d.ids {
64                        if !pre_state.relations.contains_key(id) {
65                            violations.push(Violation { source_range: None,
66                                rule_id: self.id(),
67                                operation_kind: OperationKind::DropTable,
68                                object_kind: ObjectKind::Table,
69                                object_name: id.to_string(),
70                                tier: self.default_tier(),
71                                reason: format!(
72                                    "Migration DROPs table \"{}\" which does not exist in the production baseline",
73                                    id
74                                ),
75                                recipe: self.recipe(),
76                                dedup_key: None,
77                                sql: None,
78                                fk_dependency_related: false,
79                            });
80                        }
81                    }
82                }
83            }
84            Mutation::AlterTable(a) => {
85                if !pre_state.relations.contains_key(&a.id) {
86                    violations.push(Violation { source_range: None,
87                        rule_id: self.id(),
88                        operation_kind: OperationKind::Other("alter_table".to_string()),
89                        object_kind: ObjectKind::Table,
90                        object_name: a.id.to_string(),
91                        tier: self.default_tier(),
92                        reason: format!(
93                            "Migration ALTERs table \"{}\" which does not exist in the production baseline",
94                            a.id
95                        ),
96                        recipe: self.recipe(),
97                        dedup_key: None,
98                                    sql: None,
99                                    fk_dependency_related: false,
100                    });
101                }
102            }
103            Mutation::DropView(d) => {
104                for id in &d.ids {
105                    if !d.if_exists && !pre_state.relations.contains_key(id) {
106                        violations.push(Violation { source_range: None,
107                            rule_id: self.id(),
108                            operation_kind: OperationKind::DropView,
109                            object_kind: ObjectKind::View,
110                            object_name: id.to_string(),
111                            tier: self.default_tier(),
112                            reason: format!(
113                                "Migration DROPs view \"{}\" which does not exist in the production baseline",
114                                id
115                            ),
116                            recipe: self.recipe(),
117                            dedup_key: None,
118                                            sql: None,
119                                            fk_dependency_related: false,
120                        });
121                    }
122                }
123            }
124            Mutation::DropMaterializedView(d) => {
125                for id in &d.ids {
126                    if !d.if_exists && !pre_state.relations.contains_key(id) {
127                        violations.push(Violation { source_range: None,
128                            rule_id: self.id(),
129                            operation_kind: OperationKind::DropMaterializedView,
130                            object_kind: ObjectKind::MaterializedView,
131                            object_name: id.to_string(),
132                            tier: self.default_tier(),
133                            reason: format!(
134                                "Migration DROPs materialized view \"{}\" which does not exist in the production baseline",
135                                id
136                            ),
137                            recipe: self.recipe(),
138                            dedup_key: None,
139                                            sql: None,
140                                            fk_dependency_related: false,
141                        });
142                    }
143                }
144            }
145            Mutation::DropSequence(d) => {
146                for id in &d.ids {
147                    if !d.if_exists && !pre_state.sequences.contains_key(id) {
148                        violations.push(Violation { source_range: None,
149                            rule_id: self.id(),
150                            operation_kind: OperationKind::DropSequence,
151                            object_kind: ObjectKind::Sequence,
152                            object_name: id.to_string(),
153                            tier: self.default_tier(),
154                            reason: format!(
155                                "Migration DROPs sequence \"{}\" which does not exist in the production baseline",
156                                id
157                            ),
158                            recipe: self.recipe(),
159                            dedup_key: None,
160                                            sql: None,
161                                            fk_dependency_related: false,
162                        });
163                    }
164                }
165            }
166            Mutation::DropFunction(d) => {
167                for sig in &d.signatures {
168                    let sig_str = format!("{}({})", sig.name.name.resolve(), sig.params.join(","));
169                    let schema = state.resolve_function_schema(&sig.name, &sig_str);
170                    let id = ObjectId::new(schema, sig_str);
171                    if !d.if_exists && !pre_state.functions.contains_key(&id) {
172                        violations.push(Violation { source_range: None,
173                            rule_id: self.id(),
174                            operation_kind: OperationKind::DropFunction,
175                            object_kind: ObjectKind::Function,
176                            object_name: id.to_string(),
177                            tier: self.default_tier(),
178                            reason: format!(
179                                "Migration DROPs function \"{}\" which does not exist in the production baseline",
180                                id
181                            ),
182                            recipe: self.recipe(),
183                            dedup_key: None,
184                                            sql: None,
185                                            fk_dependency_related: false,
186                        });
187                    }
188                }
189            }
190            Mutation::DropIndex(d) => {
191                for id in &d.ids {
192                    if !d.if_exists && !pre_state.indexes.iter().any(|idx| idx.dependent == *id) {
193                        violations.push(Violation { source_range: None,
194                        rule_id: self.id(),
195                        operation_kind: OperationKind::DropIndex,
196                        object_kind: ObjectKind::Index,
197                        object_name: id.to_string(),
198                        tier: self.default_tier(),
199                        reason: format!(
200                            "Migration DROPs index \"{}\" which does not exist in the production baseline",
201                            id
202                        ),
203                        recipe: self.recipe(),
204                        dedup_key: None,
205                                    sql: None,
206                                    fk_dependency_related: false,
207                        });
208                    }
209                }
210            }
211            Mutation::DropDomain(d) => {
212                for id in &d.ids {
213                    if !d.if_exists && !pre_state.types.contains_key(id) {
214                        violations.push(Violation { source_range: None,
215                            rule_id: self.id(),
216                            operation_kind: OperationKind::DropDomain,
217                            object_kind: ObjectKind::Domain,
218                            object_name: id.to_string(),
219                            tier: self.default_tier(),
220                            reason: format!(
221                                "Migration DROPs domain \"{}\" which does not exist in the production baseline",
222                                id
223                            ),
224                            recipe: self.recipe(),
225                            dedup_key: None,
226                                            sql: None,
227                                            fk_dependency_related: false,
228                        });
229                    }
230                }
231            }
232            Mutation::DropType(d) => {
233                for id in &d.ids {
234                    if !d.if_exists && !pre_state.types.contains_key(id) {
235                        violations.push(Violation { source_range: None,
236                            rule_id: self.id(),
237                            operation_kind: OperationKind::DropType,
238                            object_kind: ObjectKind::Type,
239                            object_name: id.to_string(),
240                            tier: self.default_tier(),
241                            reason: format!(
242                                "Migration DROPs type \"{}\" which does not exist in the production baseline",
243                                id
244                            ),
245                            recipe: self.recipe(),
246                            dedup_key: None,
247                            sql: None,
248                            fk_dependency_related: false,
249                        });
250                    }
251                }
252            }
253            Mutation::Rename(r) => {
254                if !pre_state.relations.contains_key(&r.old_id)
255                    && !pre_state.types.contains_key(&r.old_id)
256                    && !pre_state.sequences.contains_key(&r.old_id)
257                    && !pre_state
258                        .indexes
259                        .iter()
260                        .any(|idx| idx.dependent == r.old_id)
261                {
262                    violations.push(Violation { source_range: None,
263                        rule_id: self.id(),
264                        operation_kind: OperationKind::Rename,
265                        object_kind: ObjectKind::Table, // Or general
266                        object_name: r.old_id.to_string(),
267                        tier: self.default_tier(),
268                        reason: format!(
269                            "Migration RENAMEs object \"{}\" which does not exist in the production baseline",
270                            r.old_id
271                        ),
272                        recipe: self.recipe(),
273                        dedup_key: None,
274                        sql: None,
275                        fk_dependency_related: false,
276                    });
277                }
278            }
279            Mutation::AlterType(a) if !pre_state.types.contains_key(&a.id) => {
280                violations.push(Violation { source_range: None,
281                    rule_id: self.id(),
282                    operation_kind: OperationKind::AlterType,
283                    object_kind: ObjectKind::Type,
284                    object_name: a.id.to_string(),
285                    tier: self.default_tier(),
286                    reason: format!(
287                        "Migration ALTERs type \"{}\" which does not exist in the production baseline",
288                        a.id
289                    ),
290                    recipe: self.recipe(),
291                    dedup_key: None,
292                            sql: None,
293                            fk_dependency_related: false,
294                });
295            }
296            Mutation::AlterFunction(f) if !pre_state.functions.contains_key(&f.id) => {
297                violations.push(Violation { source_range: None,
298                    rule_id: self.id(),
299                    operation_kind: OperationKind::AlterFunction,
300                    object_kind: ObjectKind::Function,
301                    object_name: f.id.to_string(),
302                    tier: self.default_tier(),
303                    reason: format!(
304                        "Migration ALTERs function \"{}\" which does not exist in the production baseline",
305                        f.id
306                    ),
307                    recipe: self.recipe(),
308                    dedup_key: None,
309                            sql: None,
310                            fk_dependency_related: false,
311                });
312            }
313            Mutation::DropProcedure(d) => {
314                for signature in &d.signatures {
315                    let signature_name = format!(
316                        "{}({})",
317                        signature.name.name.resolve(),
318                        signature.params.join(",")
319                    );
320                    let schema = state.resolve_function_schema(&signature.name, &signature_name);
321                    let id = ObjectId::new(schema, signature_name);
322                    let procedure_exists = pre_state.functions.get(&id).is_some_and(|routine| {
323                        routine.routine_kind == crate::model::function::RoutineKind::Procedure
324                    });
325                    if !d.if_exists && !procedure_exists {
326                        violations.push(Violation {
327                            source_range: None,
328                            rule_id: self.id(),
329                            operation_kind: OperationKind::DropProcedure,
330                            object_kind: ObjectKind::Procedure,
331                            object_name: id.to_string(),
332                            tier: self.default_tier(),
333                            reason: format!(
334                                "Migration DROPs procedure \"{}\" which does not exist in the production baseline",
335                                id
336                            ),
337                            recipe: self.recipe(),
338                            dedup_key: None,
339                            sql: None,
340                            fk_dependency_related: false,
341                        });
342                    }
343                }
344            }
345            Mutation::AlterProcedure(procedure) => {
346                let procedure_exists =
347                    pre_state
348                        .functions
349                        .get(&procedure.id)
350                        .is_some_and(|routine| {
351                            routine.routine_kind == crate::model::function::RoutineKind::Procedure
352                        });
353                if !procedure_exists {
354                    violations.push(Violation {
355                        source_range: None,
356                        rule_id: self.id(),
357                        operation_kind: OperationKind::AlterProcedure,
358                        object_kind: ObjectKind::Procedure,
359                        object_name: procedure.id.to_string(),
360                        tier: self.default_tier(),
361                        reason: format!(
362                            "Migration ALTERs procedure \"{}\" which does not exist in the production baseline",
363                            procedure.id
364                        ),
365                        recipe: self.recipe(),
366                        dedup_key: None,
367                        sql: None,
368                        fk_dependency_related: false,
369                    });
370                }
371            }
372            Mutation::CreateTable(c) => {
373                // Warn if parent table doesn't exist for partitioned tables
374                if let Some(parent_id) = &c.partition_of
375                    && !pre_state.relations.contains_key(parent_id)
376                {
377                    violations.push(Violation { source_range: None,
378                        rule_id: self.id(),
379                        operation_kind: OperationKind::CreateTable,
380                        object_kind: ObjectKind::Table,
381                        object_name: c.id.to_string(),
382                        tier: self.default_tier(),
383                        reason: format!(
384                            "Migration creates {} as a partition of parent \"{}\" which does not exist in the production baseline. Parent must be created first.",
385                            c.id, parent_id
386                        ),
387                        recipe: self.recipe(),
388                        dedup_key: None,
389                        sql: None,
390                        fk_dependency_related: false,
391                    });
392                }
393            }
394            _ => {}
395        }
396
397        // When sync was deliberately scoped, an omitted schema is unknown,
398        // not proof of production drift. Keep the warning, but make it a
399        // coverage warning rather than a false Tier 1 absence claim.
400        for violation in &mut violations {
401            if let Some(schema) =
402                state.baseline_scope_omits_displayed_object(&violation.object_name)
403            {
404                violation.tier = ViolationTier::Tier2;
405                violation.reason = format!(
406                    "Cache does not cover schema \"{}\"; safe-migrate cannot verify whether {} exists in the production baseline",
407                    schema, violation.object_name
408                );
409                violation.recipe = "Run `safe-migrate sync --schemas ...` with this schema included, or use an unscoped sync, before treating this as a production-drift result.";
410            }
411        }
412
413        violations
414    }
415}