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