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 && !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 !d.if_exists && !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 !d.if_exists && !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 !d.if_exists && !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 !d.if_exists && !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::DropIndex(d) => {
187                if !d.if_exists && !pre_state.indexes.iter().any(|idx| idx.dependent == d.id) {
188                    violations.push(Violation { source_range: None,
189                        rule_id: self.id(),
190                        operation_kind: OperationKind::DropIndex,
191                        object_kind: ObjectKind::Index,
192                        object_name: d.id.to_string(),
193                        tier: self.default_tier(),
194                        reason: format!(
195                            "Migration DROPs index \"{}\" which does not exist in the production baseline",
196                            d.id
197                        ),
198                        recipe: self.recipe(),
199                        dedup_key: None,
200                                    sql: None,
201                                    fk_dependency_related: false,
202                    });
203                }
204            }
205            Mutation::DropDomain(d) => {
206                for id in &d.ids {
207                    if !d.if_exists && !pre_state.types.contains_key(id) {
208                        violations.push(Violation { source_range: None,
209                            rule_id: self.id(),
210                            operation_kind: OperationKind::DropDomain,
211                            object_kind: ObjectKind::Domain,
212                            object_name: id.to_string(),
213                            tier: self.default_tier(),
214                            reason: format!(
215                                "Migration DROPs domain \"{}\" which does not exist in the production baseline",
216                                id
217                            ),
218                            recipe: self.recipe(),
219                            dedup_key: None,
220                                            sql: None,
221                                            fk_dependency_related: false,
222                        });
223                    }
224                }
225            }
226            Mutation::DropType(d) => {
227                for id in &d.ids {
228                    if !d.if_exists && !pre_state.types.contains_key(id) {
229                        violations.push(Violation { source_range: None,
230                            rule_id: self.id(),
231                            operation_kind: OperationKind::DropType,
232                            object_kind: ObjectKind::Type,
233                            object_name: id.to_string(),
234                            tier: self.default_tier(),
235                            reason: format!(
236                                "Migration DROPs type \"{}\" which does not exist in the production baseline",
237                                id
238                            ),
239                            recipe: self.recipe(),
240                            dedup_key: None,
241                            sql: None,
242                            fk_dependency_related: false,
243                        });
244                    }
245                }
246            }
247            Mutation::Rename(r) => {
248                if !pre_state.relations.contains_key(&r.old_id)
249                    && !pre_state.types.contains_key(&r.old_id)
250                    && !pre_state.sequences.contains_key(&r.old_id)
251                    && !pre_state
252                        .indexes
253                        .iter()
254                        .any(|idx| idx.dependent == r.old_id)
255                {
256                    violations.push(Violation { source_range: None,
257                        rule_id: self.id(),
258                        operation_kind: OperationKind::Rename,
259                        object_kind: ObjectKind::Table, // Or general
260                        object_name: r.old_id.to_string(),
261                        tier: self.default_tier(),
262                        reason: format!(
263                            "Migration RENAMEs object \"{}\" which does not exist in the production baseline",
264                            r.old_id
265                        ),
266                        recipe: self.recipe(),
267                        dedup_key: None,
268                        sql: None,
269                        fk_dependency_related: false,
270                    });
271                }
272            }
273            Mutation::AlterType(a) if !pre_state.types.contains_key(&a.id) => {
274                violations.push(Violation { source_range: None,
275                    rule_id: self.id(),
276                    operation_kind: OperationKind::AlterType,
277                    object_kind: ObjectKind::Type,
278                    object_name: a.id.to_string(),
279                    tier: self.default_tier(),
280                    reason: format!(
281                        "Migration ALTERs type \"{}\" which does not exist in the production baseline",
282                        a.id
283                    ),
284                    recipe: self.recipe(),
285                    dedup_key: None,
286                            sql: None,
287                            fk_dependency_related: false,
288                });
289            }
290            Mutation::AlterFunction(f) if !pre_state.functions.contains_key(&f.id) => {
291                violations.push(Violation { source_range: None,
292                    rule_id: self.id(),
293                    operation_kind: OperationKind::AlterFunction,
294                    object_kind: ObjectKind::Function,
295                    object_name: f.id.to_string(),
296                    tier: self.default_tier(),
297                    reason: format!(
298                        "Migration ALTERs function \"{}\" which does not exist in the production baseline",
299                        f.id
300                    ),
301                    recipe: self.recipe(),
302                    dedup_key: None,
303                            sql: None,
304                            fk_dependency_related: false,
305                });
306            }
307            Mutation::DropProcedure(d) => {
308                for signature in &d.signatures {
309                    let signature_name = format!(
310                        "{}({})",
311                        signature.name.name.resolve(),
312                        signature.params.join(",")
313                    );
314                    let schema = state.resolve_function_schema(&signature.name, &signature_name);
315                    let id = ObjectId::new(schema, signature_name);
316                    let procedure_exists = pre_state.functions.get(&id).is_some_and(|routine| {
317                        routine.routine_kind == crate::model::function::RoutineKind::Procedure
318                    });
319                    if !d.if_exists && !procedure_exists {
320                        violations.push(Violation {
321                            source_range: None,
322                            rule_id: self.id(),
323                            operation_kind: OperationKind::DropProcedure,
324                            object_kind: ObjectKind::Procedure,
325                            object_name: id.to_string(),
326                            tier: self.default_tier(),
327                            reason: format!(
328                                "Migration DROPs procedure \"{}\" which does not exist in the production baseline",
329                                id
330                            ),
331                            recipe: self.recipe(),
332                            dedup_key: None,
333                            sql: None,
334                            fk_dependency_related: false,
335                        });
336                    }
337                }
338            }
339            Mutation::AlterProcedure(procedure) => {
340                let procedure_exists =
341                    pre_state
342                        .functions
343                        .get(&procedure.id)
344                        .is_some_and(|routine| {
345                            routine.routine_kind == crate::model::function::RoutineKind::Procedure
346                        });
347                if !procedure_exists {
348                    violations.push(Violation {
349                        source_range: None,
350                        rule_id: self.id(),
351                        operation_kind: OperationKind::AlterProcedure,
352                        object_kind: ObjectKind::Procedure,
353                        object_name: procedure.id.to_string(),
354                        tier: self.default_tier(),
355                        reason: format!(
356                            "Migration ALTERs procedure \"{}\" which does not exist in the production baseline",
357                            procedure.id
358                        ),
359                        recipe: self.recipe(),
360                        dedup_key: None,
361                        sql: None,
362                        fk_dependency_related: false,
363                    });
364                }
365            }
366            Mutation::CreateTable(c) => {
367                // Warn if parent table doesn't exist for partitioned tables
368                if let Some(parent_id) = &c.partition_of
369                    && !pre_state.relations.contains_key(parent_id)
370                {
371                    violations.push(Violation { source_range: None,
372                        rule_id: self.id(),
373                        operation_kind: OperationKind::CreateTable,
374                        object_kind: ObjectKind::Table,
375                        object_name: c.id.to_string(),
376                        tier: self.default_tier(),
377                        reason: format!(
378                            "Migration creates {} as a partition of parent \"{}\" which does not exist in the production baseline. Parent must be created first.",
379                            c.id, parent_id
380                        ),
381                        recipe: self.recipe(),
382                        dedup_key: None,
383                        sql: None,
384                        fk_dependency_related: false,
385                    });
386                }
387            }
388            _ => {}
389        }
390
391        // When sync was deliberately scoped, an omitted schema is unknown,
392        // not proof of production drift. Keep the warning, but make it a
393        // coverage warning rather than a false Tier 1 absence claim.
394        for violation in &mut violations {
395            if let Some(schema) =
396                state.baseline_scope_omits_displayed_object(&violation.object_name)
397            {
398                violation.tier = ViolationTier::Tier2;
399                violation.reason = format!(
400                    "Cache does not cover schema \"{}\"; safe-migrate cannot verify whether {} exists in the production baseline",
401                    schema, violation.object_name
402                );
403                violation.recipe = "Run `safe-migrate sync --schemas ...` with this schema included, or use an unscoped sync, before treating this as a production-drift result.";
404            }
405        }
406
407        violations
408    }
409}