1#![allow(clippy::module_inception)]
4
5use serde::{Deserialize, Serialize};
16use time::OffsetDateTime;
17
18use crate::ir::catalog::Catalog;
19use crate::plan::error::PlanError;
20use crate::plan::grouping::TransactionGroup;
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct RecordedFinding {
30 pub rule: String,
32 pub target: String,
35 pub message: String,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41pub struct PlanId(pub [u8; 32]);
42
43impl PlanId {
44 pub fn compute(
58 source: &Catalog,
59 target: &Catalog,
60 pgevolve_version: &str,
61 planner_ruleset_version: u32,
62 ) -> Result<Self, PlanError> {
63 let mut h = blake3::Hasher::new();
64 h.update(b"pgevolve-plan-id-v1\n");
65 h.update(pgevolve_version.as_bytes());
66 h.update(&[0]);
67 h.update(&planner_ruleset_version.to_be_bytes());
68 h.update(&[0]);
69 let source_bytes = serde_json::to_vec(source)
70 .map_err(|e| PlanError::Internal(format!("Catalog serialization failed: {e}")))?;
71 let target_bytes = serde_json::to_vec(target)
72 .map_err(|e| PlanError::Internal(format!("Catalog serialization failed: {e}")))?;
73 h.update(&source_bytes);
74 h.update(&[0]);
75 h.update(&target_bytes);
76 Ok(Self(*h.finalize().as_bytes()))
77 }
78
79 pub fn short(&self) -> String {
82 hex::encode(&self.0[..8])
83 }
84
85 pub fn to_hex(&self) -> String {
87 hex::encode(self.0)
88 }
89
90 pub fn from_full_hex(s: &str) -> Result<Self, InvalidPlanHash> {
92 let bytes = hex::decode(s).map_err(|_| InvalidPlanHash(s.to_string()))?;
93 let arr: [u8; 32] = bytes
94 .try_into()
95 .map_err(|_| InvalidPlanHash(s.to_string()))?;
96 Ok(Self(arr))
97 }
98}
99
100impl std::fmt::Display for PlanId {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 f.write_str(&self.to_hex())
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
109#[error("invalid plan hash: {0}")]
110pub struct InvalidPlanHash(pub String);
111
112#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
118pub struct StepOverride {
119 pub kind: String,
122 pub target: String,
124 #[serde(default)]
126 pub suppress: bool,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub struct LintWaiver {
137 pub rule: String,
139 pub target: String,
141 pub reason: String,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148pub struct DestructiveIntent {
149 pub id: u32,
151 pub step: u32,
153 pub kind: String,
156 pub target: String,
158 pub reason: String,
160 #[serde(default)]
165 pub approved: bool,
166}
167
168#[derive(Debug, Clone, PartialEq)]
170pub struct PlanMetadata {
171 pub pgevolve_version: String,
173 pub planner_ruleset_version: u32,
175 pub source_rev: Option<String>,
177 pub target_identity: String,
180 pub target_snapshot: Catalog,
183 pub created_at: OffsetDateTime,
185 pub lint_at_plan_findings: Vec<RecordedFinding>,
189}
190
191#[derive(Debug, Clone, PartialEq)]
193pub struct Plan {
194 pub id: PlanId,
196 pub groups: Vec<TransactionGroup>,
199 pub intents: Vec<DestructiveIntent>,
201 pub lint_waivers: Vec<LintWaiver>,
210 pub step_overrides: Vec<StepOverride>,
217 pub metadata: PlanMetadata,
219 pub advisory_findings: Vec<RecordedFinding>,
230}
231
232impl Plan {
233 #[allow(clippy::too_many_arguments)]
250 pub fn from_grouped(
251 mut groups: Vec<TransactionGroup>,
252 source: &Catalog,
253 target: &Catalog,
254 target_identity: String,
255 source_rev: Option<String>,
256 pgevolve_version: &str,
257 planner_ruleset_version: u32,
258 ) -> Result<Self, PlanError> {
259 let mut step_no: u32 = 0;
260 let mut intent_no: u32 = 0;
261 let mut intents: Vec<DestructiveIntent> = Vec::new();
262 for group in &mut groups {
263 for step in &mut group.steps {
264 step_no += 1;
265 step.step_no = step_no;
266 if step.destructive {
267 intent_no += 1;
268 step.intent_id = Some(intent_no);
269 intents.push(DestructiveIntent {
270 id: intent_no,
271 step: step_no,
272 kind: kind_name(step.kind).to_string(),
273 target: render_targets(&step.targets),
274 reason: step
275 .destructive_reason
276 .clone()
277 .unwrap_or_else(|| "destructive".to_string()),
278 approved: false,
279 });
280 }
281 }
282 }
283 let id = PlanId::compute(source, target, pgevolve_version, planner_ruleset_version)?;
284 let metadata = PlanMetadata {
285 pgevolve_version: pgevolve_version.to_string(),
286 planner_ruleset_version,
287 source_rev,
288 target_identity,
289 target_snapshot: target.clone(),
290 created_at: OffsetDateTime::now_utc(),
291 lint_at_plan_findings: Vec::new(),
292 };
293 Ok(Self {
294 id,
295 groups,
296 intents,
297 lint_waivers: Vec::new(),
298 step_overrides: Vec::new(),
299 metadata,
300 advisory_findings: Vec::new(),
301 })
302 }
303
304 pub fn approve_all_intents(&mut self) {
310 for intent in &mut self.intents {
311 intent.approved = true;
312 }
313 }
314}
315
316#[allow(clippy::too_many_lines)] pub const fn kind_name(k: crate::plan::raw_step::StepKind) -> &'static str {
322 use crate::plan::raw_step::StepKind as K;
323 match k {
324 K::CreateSchema => "create_schema",
325 K::DropSchema => "drop_schema",
326 K::AlterSchemaComment => "alter_schema_comment",
327 K::CreateTable => "create_table",
328 K::DropTable => "drop_table",
329 K::AlterTableSetComment => "alter_table_set_comment",
330 K::AddColumn => "add_column",
331 K::DropColumn => "drop_column",
332 K::AlterColumnType => "alter_column_type",
333 K::SetColumnNullable => "set_column_nullable",
334 K::SetColumnDefault => "set_column_default",
335 K::SetColumnComment => "set_column_comment",
336 K::SetColumnIdentity => "set_column_identity",
337 K::SetColumnGenerated => "set_column_generated",
338 K::SetColumnStorage => "set_column_storage",
339 K::SetColumnCompression => "set_column_compression",
340 K::AddConstraint => "add_constraint",
341 K::AddConstraintNotValid => "add_constraint_not_valid",
342 K::ValidateConstraint => "validate_constraint",
343 K::DropConstraint => "drop_constraint",
344 K::SetConstraintComment => "set_constraint_comment",
345 K::CreateIndex => "create_index",
346 K::CreateIndexConcurrent => "create_index_concurrent",
347 K::DropIndex => "drop_index",
348 K::DropIndexConcurrent => "drop_index_concurrent",
349 K::CreateSequence => "create_sequence",
350 K::DropSequence => "drop_sequence",
351 K::AlterSequence => "alter_sequence",
352 K::AddCheckForNotNull => "add_check_for_not_null",
353 K::CreateView => "create_view",
354 K::DropView => "drop_view",
355 K::AlterViewSetCheckOption => "alter_view_set_check_option",
356 K::CreateMaterializedView => "create_materialized_view",
357 K::DropMaterializedView => "drop_materialized_view",
358 K::RefreshMaterializedView => "refresh_materialized_view",
359 K::AlterViewSetReloption => "alter_view_set_reloption",
360 K::CommentOnView => "comment_on_view",
361 K::CreateType => "create_type",
362 K::DropType => "drop_type",
363 K::AlterTypeAddValue => "alter_type_add_value",
364 K::AlterTypeRenameValue => "alter_type_rename_value",
365 K::AlterDomainAddConstraint => "alter_domain_add_constraint",
366 K::AlterDomainDropConstraint => "alter_domain_drop_constraint",
367 K::AlterDomainSetDefault => "alter_domain_set_default",
368 K::AlterDomainSetNotNull => "alter_domain_set_not_null",
369 K::AlterTypeAddAttribute => "alter_type_add_attribute",
370 K::AlterTypeDropAttribute => "alter_type_drop_attribute",
371 K::AlterTypeAlterAttributeType => "alter_type_alter_attribute_type",
372 K::CommentOnType => "comment_on_type",
373 K::CreateOrReplaceFunction => "create_or_replace_function",
374 K::DropFunction => "drop_function",
375 K::CommentOnFunction => "comment_on_function",
376 K::CreateOrReplaceProcedure => "create_or_replace_procedure",
377 K::DropProcedure => "drop_procedure",
378 K::CommentOnProcedure => "comment_on_procedure",
379 K::CreateExtension => "create_extension",
380 K::DropExtension => "drop_extension",
381 K::AlterExtensionUpdate => "alter_extension_update",
382 K::CommentOnExtension => "comment_on_extension",
383 K::CreateTrigger => "create_trigger",
384 K::DropTrigger => "drop_trigger",
385 K::CommentOnTrigger => "comment_on_trigger",
386 K::AttachPartition => "attach_partition",
387 K::DetachPartition => "detach_partition",
388 K::CreateRole => "create_role",
389 K::DropRole => "drop_role",
390 K::AlterRole => "alter_role",
391 K::GrantRoleMembership => "grant_role_membership",
392 K::RevokeRoleMembership => "revoke_role_membership",
393 K::CommentOnRole => "comment_on_role",
394 K::AlterObjectOwner => "alter_object_owner",
395 K::GrantObjectPrivilege => "grant_object_privilege",
396 K::RevokeObjectPrivilege => "revoke_object_privilege",
397 K::GrantColumnPrivilege => "grant_column_privilege",
398 K::RevokeColumnPrivilege => "revoke_column_privilege",
399 K::AlterDefaultPrivileges => "alter_default_privileges",
400 K::CreatePolicy => "create_policy",
401 K::DropPolicy => "drop_policy",
402 K::AlterPolicy => "alter_policy",
403 K::SetTableRowSecurity => "set_table_row_security",
404 K::SetTableForceRowSecurity => "set_table_force_row_security",
405 K::SetTableStorage => "set_table_storage",
406 K::SetIndexStorage => "set_index_storage",
407 K::SetMaterializedViewStorage => "set_materialized_view_storage",
408 K::CreatePublication => "create_publication",
409 K::DropPublication => "drop_publication",
410 K::ReplacePublication => "replace_publication",
411 K::AlterPublicationAddTable => "alter_publication_add_table",
412 K::AlterPublicationDropTable => "alter_publication_drop_table",
413 K::AlterPublicationSetTable => "alter_publication_set_table",
414 K::AlterPublicationAddSchema => "alter_publication_add_schema",
415 K::AlterPublicationDropSchema => "alter_publication_drop_schema",
416 K::AlterPublicationSetPublish => "alter_publication_set_publish",
417 K::AlterPublicationSetViaRoot => "alter_publication_set_via_root",
418 K::CommentOnPublication => "comment_on_publication",
419 K::CreateSubscription => "create_subscription",
420 K::DropSubscription => "drop_subscription",
421 K::AlterSubscriptionConnection => "alter_subscription_connection",
422 K::AlterSubscriptionAddPublication => "alter_subscription_add_publication",
423 K::AlterSubscriptionDropPublication => "alter_subscription_drop_publication",
424 K::AlterSubscriptionSetOptions => "alter_subscription_set_options",
425 K::CommentOnSubscription => "comment_on_subscription",
426 K::CreateStatistic => "create_statistic",
427 K::DropStatistic => "drop_statistic",
428 K::ReplaceStatistic => "replace_statistic",
429 K::AlterStatisticSetTarget => "alter_statistic_set_target",
430 K::CommentOnStatistic => "comment_on_statistic",
431 K::CreateCollation => "create_collation",
432 K::DropCollation => "drop_collation",
433 K::RenameCollation => "rename_collation",
434 K::ReplaceCollation => "replace_collation",
435 K::CommentOnCollation => "comment_on_collation",
436 }
437}
438
439#[allow(clippy::too_many_lines)] pub fn parse_kind_name(s: &str) -> Option<crate::plan::raw_step::StepKind> {
442 use crate::plan::raw_step::StepKind as K;
443 Some(match s {
444 "create_schema" => K::CreateSchema,
445 "drop_schema" => K::DropSchema,
446 "alter_schema_comment" => K::AlterSchemaComment,
447 "create_table" => K::CreateTable,
448 "drop_table" => K::DropTable,
449 "alter_table_set_comment" => K::AlterTableSetComment,
450 "add_column" => K::AddColumn,
451 "drop_column" => K::DropColumn,
452 "alter_column_type" => K::AlterColumnType,
453 "set_column_nullable" => K::SetColumnNullable,
454 "set_column_default" => K::SetColumnDefault,
455 "set_column_comment" => K::SetColumnComment,
456 "set_column_identity" => K::SetColumnIdentity,
457 "set_column_generated" => K::SetColumnGenerated,
458 "set_column_storage" => K::SetColumnStorage,
459 "set_column_compression" => K::SetColumnCompression,
460 "add_constraint" => K::AddConstraint,
461 "add_constraint_not_valid" => K::AddConstraintNotValid,
462 "validate_constraint" => K::ValidateConstraint,
463 "drop_constraint" => K::DropConstraint,
464 "set_constraint_comment" => K::SetConstraintComment,
465 "create_index" => K::CreateIndex,
466 "create_index_concurrent" => K::CreateIndexConcurrent,
467 "drop_index" => K::DropIndex,
468 "drop_index_concurrent" => K::DropIndexConcurrent,
469 "create_sequence" => K::CreateSequence,
470 "drop_sequence" => K::DropSequence,
471 "alter_sequence" => K::AlterSequence,
472 "add_check_for_not_null" => K::AddCheckForNotNull,
473 "create_view" => K::CreateView,
474 "drop_view" => K::DropView,
475 "alter_view_set_check_option" => K::AlterViewSetCheckOption,
476 "create_materialized_view" => K::CreateMaterializedView,
477 "drop_materialized_view" => K::DropMaterializedView,
478 "refresh_materialized_view" => K::RefreshMaterializedView,
479 "alter_view_set_reloption" => K::AlterViewSetReloption,
480 "comment_on_view" => K::CommentOnView,
481 "create_type" => K::CreateType,
482 "drop_type" => K::DropType,
483 "alter_type_add_value" => K::AlterTypeAddValue,
484 "alter_type_rename_value" => K::AlterTypeRenameValue,
485 "alter_domain_add_constraint" => K::AlterDomainAddConstraint,
486 "alter_domain_drop_constraint" => K::AlterDomainDropConstraint,
487 "alter_domain_set_default" => K::AlterDomainSetDefault,
488 "alter_domain_set_not_null" => K::AlterDomainSetNotNull,
489 "alter_type_add_attribute" => K::AlterTypeAddAttribute,
490 "alter_type_drop_attribute" => K::AlterTypeDropAttribute,
491 "alter_type_alter_attribute_type" => K::AlterTypeAlterAttributeType,
492 "comment_on_type" => K::CommentOnType,
493 "create_or_replace_function" => K::CreateOrReplaceFunction,
494 "drop_function" => K::DropFunction,
495 "comment_on_function" => K::CommentOnFunction,
496 "create_or_replace_procedure" => K::CreateOrReplaceProcedure,
497 "drop_procedure" => K::DropProcedure,
498 "comment_on_procedure" => K::CommentOnProcedure,
499 "create_extension" => K::CreateExtension,
500 "drop_extension" => K::DropExtension,
501 "alter_extension_update" => K::AlterExtensionUpdate,
502 "comment_on_extension" => K::CommentOnExtension,
503 "create_trigger" => K::CreateTrigger,
504 "drop_trigger" => K::DropTrigger,
505 "comment_on_trigger" => K::CommentOnTrigger,
506 "attach_partition" => K::AttachPartition,
507 "detach_partition" => K::DetachPartition,
508 "create_role" => K::CreateRole,
509 "drop_role" => K::DropRole,
510 "alter_role" => K::AlterRole,
511 "grant_role_membership" => K::GrantRoleMembership,
512 "revoke_role_membership" => K::RevokeRoleMembership,
513 "comment_on_role" => K::CommentOnRole,
514 "alter_object_owner" => K::AlterObjectOwner,
515 "grant_object_privilege" => K::GrantObjectPrivilege,
516 "revoke_object_privilege" => K::RevokeObjectPrivilege,
517 "grant_column_privilege" => K::GrantColumnPrivilege,
518 "revoke_column_privilege" => K::RevokeColumnPrivilege,
519 "alter_default_privileges" => K::AlterDefaultPrivileges,
520 "create_policy" => K::CreatePolicy,
521 "drop_policy" => K::DropPolicy,
522 "alter_policy" => K::AlterPolicy,
523 "set_table_row_security" => K::SetTableRowSecurity,
524 "set_table_force_row_security" => K::SetTableForceRowSecurity,
525 "set_table_storage" => K::SetTableStorage,
526 "set_index_storage" => K::SetIndexStorage,
527 "set_materialized_view_storage" => K::SetMaterializedViewStorage,
528 "create_publication" => K::CreatePublication,
529 "drop_publication" => K::DropPublication,
530 "replace_publication" => K::ReplacePublication,
531 "alter_publication_add_table" => K::AlterPublicationAddTable,
532 "alter_publication_drop_table" => K::AlterPublicationDropTable,
533 "alter_publication_set_table" => K::AlterPublicationSetTable,
534 "alter_publication_add_schema" => K::AlterPublicationAddSchema,
535 "alter_publication_drop_schema" => K::AlterPublicationDropSchema,
536 "alter_publication_set_publish" => K::AlterPublicationSetPublish,
537 "alter_publication_set_via_root" => K::AlterPublicationSetViaRoot,
538 "comment_on_publication" => K::CommentOnPublication,
539 "create_subscription" => K::CreateSubscription,
540 "drop_subscription" => K::DropSubscription,
541 "alter_subscription_connection" => K::AlterSubscriptionConnection,
542 "alter_subscription_add_publication" => K::AlterSubscriptionAddPublication,
543 "alter_subscription_drop_publication" => K::AlterSubscriptionDropPublication,
544 "alter_subscription_set_options" => K::AlterSubscriptionSetOptions,
545 "comment_on_subscription" => K::CommentOnSubscription,
546 "create_statistic" => K::CreateStatistic,
547 "drop_statistic" => K::DropStatistic,
548 "replace_statistic" => K::ReplaceStatistic,
549 "alter_statistic_set_target" => K::AlterStatisticSetTarget,
550 "comment_on_statistic" => K::CommentOnStatistic,
551 "create_collation" => K::CreateCollation,
552 "drop_collation" => K::DropCollation,
553 "rename_collation" => K::RenameCollation,
554 "replace_collation" => K::ReplaceCollation,
555 "comment_on_collation" => K::CommentOnCollation,
556 _ => return None,
557 })
558}
559
560fn render_targets(targets: &[crate::identifier::QualifiedName]) -> String {
562 let mut s = String::new();
563 for (i, t) in targets.iter().enumerate() {
564 if i > 0 {
565 s.push(',');
566 }
567 s.push_str(&t.render_sql());
568 }
569 s
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use crate::identifier::Identifier;
576 use crate::ir::schema::Schema;
577
578 fn id_id(s: &str) -> Identifier {
579 Identifier::from_unquoted(s).unwrap()
580 }
581
582 fn cat_with_schema(name: &str) -> Catalog {
583 let mut c = Catalog::empty();
584 c.schemas.push(Schema::new(id_id(name)));
585 c
586 }
587
588 #[test]
589 fn plan_id_is_deterministic_across_calls() {
590 let s = cat_with_schema("app");
591 let t = Catalog::empty();
592 let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
593 let b = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
594 assert_eq!(a, b);
595 }
596
597 #[test]
598 fn plan_id_differs_when_target_differs() {
599 let s = cat_with_schema("app");
600 let a = PlanId::compute(&s, &Catalog::empty(), "0.1.0", 1).unwrap();
601 let b = PlanId::compute(&s, &cat_with_schema("legacy"), "0.1.0", 1).unwrap();
602 assert_ne!(a, b);
603 }
604
605 #[test]
606 fn plan_id_differs_when_version_differs() {
607 let s = cat_with_schema("app");
608 let t = Catalog::empty();
609 let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
610 let b = PlanId::compute(&s, &t, "0.2.0", 1).unwrap();
611 assert_ne!(a, b);
612 }
613
614 #[test]
615 fn plan_id_differs_when_ruleset_differs() {
616 let s = cat_with_schema("app");
617 let t = Catalog::empty();
618 let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
619 let b = PlanId::compute(&s, &t, "0.1.0", 2).unwrap();
620 assert_ne!(a, b);
621 }
622
623 #[test]
624 fn plan_id_short_is_sixteen_hex_chars() {
625 let id = PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap();
626 let short = id.short();
627 assert_eq!(short.len(), 16);
628 assert!(short.chars().all(|c| c.is_ascii_hexdigit()));
629 }
630
631 #[test]
632 fn plan_id_full_hex_round_trips() {
633 let id = PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap();
634 let hex = id.to_hex();
635 assert_eq!(hex.len(), 64);
636 let back = PlanId::from_full_hex(&hex).unwrap();
637 assert_eq!(id, back);
638 }
639
640 use crate::identifier::QualifiedName;
643 use crate::plan::grouping::TransactionGroup;
644 use crate::plan::raw_step::{RawStep, StepKind, TransactionConstraint};
645
646 fn qn(schema: &str, name: &str) -> QualifiedName {
647 QualifiedName::new(id_id(schema), id_id(name))
648 }
649
650 fn step(kind: StepKind, destructive: bool, targets: Vec<QualifiedName>) -> RawStep {
651 RawStep {
652 step_no: 0,
653 kind,
654 destructive,
655 destructive_reason: destructive.then(|| "test".to_string()),
656 intent_id: None,
657 targets,
658 sql: String::new(),
659 transactional: TransactionConstraint::InTransaction,
660 }
661 }
662
663 fn group(id: u32, steps: Vec<RawStep>) -> TransactionGroup {
664 TransactionGroup {
665 id,
666 transactional: true,
667 steps,
668 }
669 }
670
671 #[test]
672 fn from_grouped_assigns_step_numbers_contiguously() {
673 let groups = vec![
674 group(
675 1,
676 vec![
677 step(StepKind::CreateSchema, false, vec![qn("app", "app")]),
678 step(StepKind::CreateTable, false, vec![qn("app", "users")]),
679 ],
680 ),
681 group(
682 2,
683 vec![step(StepKind::DropColumn, true, vec![qn("app", "users")])],
684 ),
685 ];
686 let plan = Plan::from_grouped(
687 groups,
688 &Catalog::empty(),
689 &Catalog::empty(),
690 "tid".into(),
691 None,
692 "0.1.0",
693 1,
694 )
695 .unwrap();
696 let nos: Vec<u32> = plan
697 .groups
698 .iter()
699 .flat_map(|g| g.steps.iter().map(|s| s.step_no))
700 .collect();
701 assert_eq!(nos, vec![1, 2, 3]);
702 }
703
704 #[test]
705 fn from_grouped_allocates_one_intent_per_destructive_step() {
706 let groups = vec![group(
707 1,
708 vec![
709 step(StepKind::CreateTable, false, vec![qn("app", "x")]),
710 step(StepKind::DropColumn, true, vec![qn("app", "x")]),
711 step(StepKind::DropTable, true, vec![qn("app", "y")]),
712 ],
713 )];
714 let plan = Plan::from_grouped(
715 groups,
716 &Catalog::empty(),
717 &Catalog::empty(),
718 "tid".into(),
719 None,
720 "0.1.0",
721 1,
722 )
723 .unwrap();
724 assert_eq!(plan.intents.len(), 2);
725 assert_eq!(plan.intents[0].id, 1);
726 assert_eq!(plan.intents[0].step, 2);
727 assert_eq!(plan.intents[0].kind, "drop_column");
728 assert_eq!(plan.intents[1].id, 2);
729 assert_eq!(plan.intents[1].step, 3);
730 assert_eq!(plan.intents[1].kind, "drop_table");
731 let intent_ids: Vec<Option<u32>> = plan
733 .groups
734 .iter()
735 .flat_map(|g| g.steps.iter().map(|s| s.intent_id))
736 .collect();
737 assert_eq!(intent_ids, vec![None, Some(1), Some(2)]);
738 }
739
740 #[test]
741 fn from_grouped_metadata_captures_target_snapshot() {
742 let target = cat_with_schema("legacy");
743 let plan = Plan::from_grouped(
744 Vec::new(),
745 &Catalog::empty(),
746 &target,
747 "tid".into(),
748 Some("git:abc".into()),
749 "0.1.0",
750 1,
751 )
752 .unwrap();
753 assert_eq!(plan.metadata.target_snapshot, target);
754 assert_eq!(plan.metadata.source_rev.as_deref(), Some("git:abc"));
755 assert_eq!(plan.metadata.target_identity, "tid");
756 }
757
758 #[test]
759 fn kind_name_round_trips_via_parse() {
760 for k in [
761 StepKind::CreateSchema,
762 StepKind::DropColumn,
763 StepKind::CreateIndexConcurrent,
764 StepKind::AddCheckForNotNull,
765 ] {
766 assert_eq!(parse_kind_name(kind_name(k)), Some(k));
767 }
768 }
769
770 #[test]
771 fn user_type_step_kinds_round_trip_through_kind_name() {
772 for k in [
773 StepKind::CreateType,
774 StepKind::DropType,
775 StepKind::AlterTypeAddValue,
776 StepKind::AlterTypeRenameValue,
777 StepKind::AlterDomainAddConstraint,
778 StepKind::AlterDomainDropConstraint,
779 StepKind::AlterDomainSetDefault,
780 StepKind::AlterDomainSetNotNull,
781 StepKind::AlterTypeAddAttribute,
782 StepKind::AlterTypeDropAttribute,
783 StepKind::AlterTypeAlterAttributeType,
784 StepKind::CommentOnType,
785 ] {
786 let n = kind_name(k);
787 let parsed = parse_kind_name(n).unwrap();
788 assert_eq!(parsed, k, "round-trip failed for {n}");
789 }
790 }
791
792 #[test]
793 fn routine_step_kinds_round_trip_through_kind_name() {
794 for k in [
795 StepKind::CreateOrReplaceFunction,
796 StepKind::DropFunction,
797 StepKind::CommentOnFunction,
798 StepKind::CreateOrReplaceProcedure,
799 StepKind::DropProcedure,
800 StepKind::CommentOnProcedure,
801 ] {
802 let n = kind_name(k);
803 let parsed = parse_kind_name(n).unwrap();
804 assert_eq!(parsed, k, "round-trip failed for {n}");
805 }
806 }
807
808 #[test]
809 fn plan_id_from_invalid_hex_errors() {
810 assert!(PlanId::from_full_hex("not-hex").is_err());
811 assert!(PlanId::from_full_hex(&"ab".repeat(10)).is_err()); }
813
814 #[test]
817 fn step_override_round_trips() {
818 let override_ = StepOverride {
819 kind: "refresh_materialized_view".to_string(),
820 target: "app.daily_revenue".to_string(),
821 suppress: true,
822 };
823 let toml_text = toml::to_string(&override_).unwrap();
825 let back: StepOverride = toml::from_str(&toml_text).unwrap();
826 assert_eq!(back, override_);
827 }
828
829 #[test]
830 fn step_override_suppress_defaults_to_false() {
831 let toml_text = r#"kind = "refresh_materialized_view"
832target = "app.daily_revenue"
833"#;
834 let back: StepOverride = toml::from_str(toml_text).unwrap();
835 assert!(!back.suppress);
836 }
837
838 #[test]
839 fn step_override_round_trips_inside_intent_doc() {
840 #[derive(serde::Deserialize)]
841 #[allow(dead_code)]
842 struct Doc {
843 plan_id: String,
844 #[serde(default, rename = "step_override")]
845 step_overrides: Vec<StepOverride>,
846 }
847
848 let toml_text = r#"
849plan_id = "abc1234567890abc"
850
851[[step_override]]
852kind = "refresh_materialized_view"
853target = "app.daily_revenue"
854suppress = true
855"#;
856 let doc: Doc = toml::from_str(toml_text).unwrap();
857 assert_eq!(doc.step_overrides.len(), 1);
858 assert_eq!(doc.step_overrides[0].kind, "refresh_materialized_view");
859 assert_eq!(doc.step_overrides[0].target, "app.daily_revenue");
860 assert!(doc.step_overrides[0].suppress);
861 }
862
863 #[test]
866 fn lint_waiver_round_trips() {
867 let waiver = LintWaiver {
868 rule: "column-position-drift".to_string(),
869 target: "app.users".to_string(),
870 reason: "applied via rewrite-table; see PR #234".to_string(),
871 };
872
873 let toml_text = toml::to_string(&waiver).unwrap();
875 let back: LintWaiver = toml::from_str(&toml_text).unwrap();
876 assert_eq!(back, waiver);
877 }
878
879 #[test]
880 fn lint_waiver_round_trips_inside_intent_doc() {
881 #[derive(serde::Deserialize)]
886 #[allow(dead_code)]
887 struct IntentRow {
888 id: u32,
889 step: u32,
890 kind: String,
891 target: String,
892 reason: String,
893 #[serde(default)]
894 approved: bool,
895 }
896 #[derive(serde::Deserialize)]
897 #[allow(dead_code)]
898 struct Doc {
899 plan_id: String,
900 #[serde(default, rename = "intent")]
901 intents: Vec<IntentRow>,
902 #[serde(default, rename = "lint_waiver")]
903 lint_waivers: Vec<LintWaiver>,
904 }
905
906 let toml_text = r#"
909plan_id = "abc1234567890abc"
910
911[[intent]]
912id = 1
913step = 2
914kind = "drop_table"
915target = "app.legacy"
916reason = "drop old table"
917approved = false
918
919[[lint_waiver]]
920rule = "column-position-drift"
921target = "app.users"
922reason = "rewrite-table applied; PR #234"
923"#;
924 let doc: Doc = toml::from_str(toml_text).unwrap();
925 assert_eq!(doc.lint_waivers.len(), 1);
926 assert_eq!(doc.lint_waivers[0].rule, "column-position-drift");
927 assert_eq!(doc.lint_waivers[0].target, "app.users");
928 }
929
930 #[test]
931 fn approve_all_intents_flips_every_intent_to_approved() {
932 let mut plan = sample_plan_with_two_unapproved_intents();
933 assert!(!plan.intents[0].approved);
934 assert!(!plan.intents[1].approved);
935 plan.approve_all_intents();
936 assert!(plan.intents[0].approved);
937 assert!(plan.intents[1].approved);
938 }
939
940 fn sample_plan_with_two_unapproved_intents() -> Plan {
941 Plan {
942 id: PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap(),
943 groups: Vec::new(),
944 intents: vec![
945 DestructiveIntent {
946 id: 1,
947 step: 1,
948 kind: "drop_column".into(),
949 target: "app.users.legacy_email".into(),
950 reason: "test".into(),
951 approved: false,
952 },
953 DestructiveIntent {
954 id: 2,
955 step: 2,
956 kind: "drop_table".into(),
957 target: "app.old_users".into(),
958 reason: "test".into(),
959 approved: false,
960 },
961 ],
962 lint_waivers: Vec::new(),
963 step_overrides: Vec::new(),
964 metadata: PlanMetadata {
965 pgevolve_version: crate::VERSION.to_string(),
966 planner_ruleset_version: 1,
967 source_rev: None,
968 target_identity: "test-identity".into(),
969 target_snapshot: Catalog::empty(),
970 created_at: OffsetDateTime::UNIX_EPOCH,
971 lint_at_plan_findings: Vec::new(),
972 },
973 advisory_findings: Vec::new(),
974 }
975 }
976}