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 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, 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 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 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}