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