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 pub fn from_hex(s: &str) -> Result<Self, PlanError> {
113 let decoded = hex::decode(s).map_err(|e| {
114 PlanError::Internal(format!("plan_id hex decode failed for {s:?}: {e}"))
115 })?;
116 if decoded.len() != 8 {
117 return Err(PlanError::Internal(format!(
118 "plan_id hex length mismatch: expected 16 chars (8 bytes), got {} chars ({} bytes)",
119 s.len(),
120 decoded.len(),
121 )));
122 }
123 let mut arr = [0u8; 32];
124 arr[..8].copy_from_slice(&decoded);
125 Ok(Self(arr))
126 }
127}
128
129impl std::fmt::Display for PlanId {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 f.write_str(&self.to_hex())
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
138#[error("invalid plan hash: {0}")]
139pub struct InvalidPlanHash(pub String);
140
141#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
147pub struct StepOverride {
148 pub kind: String,
151 pub target: String,
153 #[serde(default)]
155 pub suppress: bool,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct LintWaiver {
166 pub rule: String,
168 pub target: String,
170 pub reason: String,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct DestructiveIntent {
178 pub id: u32,
180 pub step: u32,
182 pub kind: String,
185 pub target: String,
187 pub reason: String,
189 #[serde(default)]
194 pub approved: bool,
195}
196
197#[derive(Debug, Clone, PartialEq)]
199pub struct PlanMetadata {
200 pub pgevolve_version: String,
202 pub planner_ruleset_version: u32,
204 pub source_rev: Option<String>,
206 pub target_identity: String,
209 pub target_snapshot: Catalog,
212 pub created_at: OffsetDateTime,
214 pub lint_at_plan_findings: Vec<RecordedFinding>,
218}
219
220#[derive(Debug, Clone, PartialEq)]
222pub struct Plan {
223 pub id: PlanId,
225 pub groups: Vec<TransactionGroup>,
228 pub intents: Vec<DestructiveIntent>,
230 pub lint_waivers: Vec<LintWaiver>,
239 pub step_overrides: Vec<StepOverride>,
246 pub metadata: PlanMetadata,
248 pub advisory_findings: Vec<RecordedFinding>,
259}
260
261impl Plan {
262 #[allow(clippy::too_many_arguments)]
293 #[allow(clippy::missing_const_for_fn)] pub fn from_grouped_with_id(
295 mut groups: Vec<TransactionGroup>,
296 id: PlanId,
297 target_identity: String,
298 source_rev: Option<String>,
299 pgevolve_version: &str,
300 planner_ruleset_version: u32,
301 ) -> Result<Self, PlanError> {
302 let mut step_no: u32 = 0;
303 let mut intent_no: u32 = 0;
304 let mut intents: Vec<DestructiveIntent> = Vec::new();
305 for group in &mut groups {
306 for step in &mut group.steps {
307 step_no += 1;
308 step.step_no = step_no;
309 if step.destructive {
310 intent_no += 1;
311 step.intent_id = Some(intent_no);
312 intents.push(DestructiveIntent {
313 id: intent_no,
314 step: step_no,
315 kind: kind_name(step.kind).to_string(),
316 target: render_targets(&step.targets),
317 reason: step
318 .destructive_reason
319 .clone()
320 .unwrap_or_else(|| "destructive".to_string()),
321 approved: false,
322 });
323 }
324 }
325 }
326 let metadata = PlanMetadata {
327 pgevolve_version: pgevolve_version.to_string(),
328 planner_ruleset_version,
329 source_rev,
330 target_identity,
331 target_snapshot: Catalog::empty(),
332 created_at: OffsetDateTime::now_utc(),
333 lint_at_plan_findings: Vec::new(),
334 };
335 Ok(Self {
336 id,
337 groups,
338 intents,
339 lint_waivers: Vec::new(),
340 step_overrides: Vec::new(),
341 metadata,
342 advisory_findings: Vec::new(),
343 })
344 }
345
346 #[allow(clippy::too_many_arguments)]
363 pub fn from_grouped(
364 groups: Vec<TransactionGroup>,
365 source: &Catalog,
366 target: &Catalog,
367 target_identity: String,
368 source_rev: Option<String>,
369 pgevolve_version: &str,
370 planner_ruleset_version: u32,
371 ) -> Result<Self, PlanError> {
372 let id = PlanId::compute(source, target, pgevolve_version, planner_ruleset_version)?;
373 let mut plan = Self::from_grouped_with_id(
374 groups,
375 id,
376 target_identity,
377 source_rev,
378 pgevolve_version,
379 planner_ruleset_version,
380 )?;
381 plan.metadata.target_snapshot = target.clone();
382 Ok(plan)
383 }
384
385 pub fn approve_all_intents(&mut self) {
391 for intent in &mut self.intents {
392 intent.approved = true;
393 }
394 }
395}
396
397#[allow(clippy::too_many_lines)] pub const fn kind_name(k: crate::plan::raw_step::StepKind) -> &'static str {
403 use crate::plan::raw_step::StepKind as K;
404 match k {
405 K::CreateSchema => "create_schema",
406 K::DropSchema => "drop_schema",
407 K::AlterSchemaComment => "alter_schema_comment",
408 K::CreateTable => "create_table",
409 K::DropTable => "drop_table",
410 K::AlterTableSetComment => "alter_table_set_comment",
411 K::AlterTableSetTablespace => "alter_table_set_tablespace",
412 K::AddColumn => "add_column",
413 K::DropColumn => "drop_column",
414 K::AlterColumnType => "alter_column_type",
415 K::SetColumnNullable => "set_column_nullable",
416 K::SetColumnDefault => "set_column_default",
417 K::SetColumnComment => "set_column_comment",
418 K::SetColumnIdentity => "set_column_identity",
419 K::SetColumnGenerated => "set_column_generated",
420 K::SetColumnStorage => "set_column_storage",
421 K::SetColumnCompression => "set_column_compression",
422 K::AddConstraint => "add_constraint",
423 K::AddConstraintNotValid => "add_constraint_not_valid",
424 K::ValidateConstraint => "validate_constraint",
425 K::DropConstraint => "drop_constraint",
426 K::SetConstraintComment => "set_constraint_comment",
427 K::CreateIndex => "create_index",
428 K::CreateIndexConcurrent => "create_index_concurrent",
429 K::DropIndex => "drop_index",
430 K::DropIndexConcurrent => "drop_index_concurrent",
431 K::CreateSequence => "create_sequence",
432 K::DropSequence => "drop_sequence",
433 K::AlterSequence => "alter_sequence",
434 K::AddCheckForNotNull => "add_check_for_not_null",
435 K::CreateView => "create_view",
436 K::DropView => "drop_view",
437 K::AlterViewSetCheckOption => "alter_view_set_check_option",
438 K::CreateMaterializedView => "create_materialized_view",
439 K::DropMaterializedView => "drop_materialized_view",
440 K::RefreshMaterializedView => "refresh_materialized_view",
441 K::AlterViewSetReloption => "alter_view_set_reloption",
442 K::CommentOnView => "comment_on_view",
443 K::CreateType => "create_type",
444 K::DropType => "drop_type",
445 K::AlterTypeAddValue => "alter_type_add_value",
446 K::AlterTypeRenameValue => "alter_type_rename_value",
447 K::AlterDomainAddConstraint => "alter_domain_add_constraint",
448 K::AlterDomainDropConstraint => "alter_domain_drop_constraint",
449 K::AlterDomainSetDefault => "alter_domain_set_default",
450 K::AlterDomainSetNotNull => "alter_domain_set_not_null",
451 K::AlterTypeAddAttribute => "alter_type_add_attribute",
452 K::AlterTypeDropAttribute => "alter_type_drop_attribute",
453 K::AlterTypeAlterAttributeType => "alter_type_alter_attribute_type",
454 K::CommentOnType => "comment_on_type",
455 K::CreateOrReplaceFunction => "create_or_replace_function",
456 K::DropFunction => "drop_function",
457 K::CommentOnFunction => "comment_on_function",
458 K::CreateOrReplaceProcedure => "create_or_replace_procedure",
459 K::DropProcedure => "drop_procedure",
460 K::CommentOnProcedure => "comment_on_procedure",
461 K::CreateExtension => "create_extension",
462 K::DropExtension => "drop_extension",
463 K::AlterExtensionUpdate => "alter_extension_update",
464 K::CommentOnExtension => "comment_on_extension",
465 K::CreateTrigger => "create_trigger",
466 K::DropTrigger => "drop_trigger",
467 K::CommentOnTrigger => "comment_on_trigger",
468 K::AttachPartition => "attach_partition",
469 K::DetachPartition => "detach_partition",
470 K::CreateRole => "create_role",
471 K::DropRole => "drop_role",
472 K::AlterRole => "alter_role",
473 K::GrantRoleMembership => "grant_role_membership",
474 K::RevokeRoleMembership => "revoke_role_membership",
475 K::CommentOnRole => "comment_on_role",
476 K::CreateTablespace => "create_tablespace",
477 K::DropTablespace => "drop_tablespace",
478 K::AlterTablespaceOwner => "alter_tablespace_owner",
479 K::SetTablespaceOptions => "set_tablespace_options",
480 K::CommentOnTablespace => "comment_on_tablespace",
481 K::AlterObjectOwner => "alter_object_owner",
482 K::GrantObjectPrivilege => "grant_object_privilege",
483 K::RevokeObjectPrivilege => "revoke_object_privilege",
484 K::GrantColumnPrivilege => "grant_column_privilege",
485 K::RevokeColumnPrivilege => "revoke_column_privilege",
486 K::AlterDefaultPrivileges => "alter_default_privileges",
487 K::CreatePolicy => "create_policy",
488 K::DropPolicy => "drop_policy",
489 K::AlterPolicy => "alter_policy",
490 K::SetTableRowSecurity => "set_table_row_security",
491 K::SetTableForceRowSecurity => "set_table_force_row_security",
492 K::SetTableStorage => "set_table_storage",
493 K::SetIndexStorage => "set_index_storage",
494 K::SetMaterializedViewStorage => "set_materialized_view_storage",
495 K::CreatePublication => "create_publication",
496 K::DropPublication => "drop_publication",
497 K::ReplacePublication => "replace_publication",
498 K::AlterPublicationAddTable => "alter_publication_add_table",
499 K::AlterPublicationDropTable => "alter_publication_drop_table",
500 K::AlterPublicationSetTable => "alter_publication_set_table",
501 K::AlterPublicationAddSchema => "alter_publication_add_schema",
502 K::AlterPublicationDropSchema => "alter_publication_drop_schema",
503 K::AlterPublicationSetPublish => "alter_publication_set_publish",
504 K::AlterPublicationSetViaRoot => "alter_publication_set_via_root",
505 K::CommentOnPublication => "comment_on_publication",
506 K::CreateSubscription => "create_subscription",
507 K::DropSubscription => "drop_subscription",
508 K::AlterSubscriptionConnection => "alter_subscription_connection",
509 K::AlterSubscriptionAddPublication => "alter_subscription_add_publication",
510 K::AlterSubscriptionDropPublication => "alter_subscription_drop_publication",
511 K::AlterSubscriptionSetOptions => "alter_subscription_set_options",
512 K::CommentOnSubscription => "comment_on_subscription",
513 K::CreateStatistic => "create_statistic",
514 K::DropStatistic => "drop_statistic",
515 K::ReplaceStatistic => "replace_statistic",
516 K::AlterStatisticSetTarget => "alter_statistic_set_target",
517 K::CommentOnStatistic => "comment_on_statistic",
518 K::CreateCollation => "create_collation",
519 K::DropCollation => "drop_collation",
520 K::RenameCollation => "rename_collation",
521 K::ReplaceCollation => "replace_collation",
522 K::CommentOnCollation => "comment_on_collation",
523 K::CreateEventTrigger => "create_event_trigger",
524 K::DropEventTrigger => "drop_event_trigger",
525 K::AlterEventTriggerEnable => "alter_event_trigger_enable",
526 K::AlterEventTriggerOwner => "alter_event_trigger_owner",
527 K::CommentOnEventTrigger => "comment_on_event_trigger",
528 K::CreateAggregate => "create_aggregate",
529 K::DropAggregate => "drop_aggregate",
530 K::AlterAggregateOwner => "alter_aggregate_owner",
531 K::CommentOnAggregate => "comment_on_aggregate",
532 K::CreateCast => "create_cast",
533 K::DropCast => "drop_cast",
534 K::CommentOnCast => "comment_on_cast",
535 K::CreateTsDictionary => "create_ts_dictionary",
536 K::DropTsDictionary => "drop_ts_dictionary",
537 K::AlterTsDictionary => "alter_ts_dictionary",
538 K::AlterTsDictionaryOwner => "alter_ts_dictionary_owner",
539 K::CommentOnTsDictionary => "comment_on_ts_dictionary",
540 K::CreateTsConfiguration => "create_ts_configuration",
541 K::DropTsConfiguration => "drop_ts_configuration",
542 K::AddTsConfigMapping => "add_ts_config_mapping",
543 K::AlterTsConfigMapping => "alter_ts_config_mapping",
544 K::DropTsConfigMapping => "drop_ts_config_mapping",
545 K::AlterTsConfigurationOwner => "alter_ts_configuration_owner",
546 K::CommentOnTsConfiguration => "comment_on_ts_configuration",
547 }
548}
549
550#[allow(clippy::too_many_lines)] pub fn parse_kind_name(s: &str) -> Option<crate::plan::raw_step::StepKind> {
553 use crate::plan::raw_step::StepKind as K;
554 Some(match s {
555 "create_schema" => K::CreateSchema,
556 "drop_schema" => K::DropSchema,
557 "alter_schema_comment" => K::AlterSchemaComment,
558 "create_table" => K::CreateTable,
559 "drop_table" => K::DropTable,
560 "alter_table_set_comment" => K::AlterTableSetComment,
561 "alter_table_set_tablespace" => K::AlterTableSetTablespace,
562 "add_column" => K::AddColumn,
563 "drop_column" => K::DropColumn,
564 "alter_column_type" => K::AlterColumnType,
565 "set_column_nullable" => K::SetColumnNullable,
566 "set_column_default" => K::SetColumnDefault,
567 "set_column_comment" => K::SetColumnComment,
568 "set_column_identity" => K::SetColumnIdentity,
569 "set_column_generated" => K::SetColumnGenerated,
570 "set_column_storage" => K::SetColumnStorage,
571 "set_column_compression" => K::SetColumnCompression,
572 "add_constraint" => K::AddConstraint,
573 "add_constraint_not_valid" => K::AddConstraintNotValid,
574 "validate_constraint" => K::ValidateConstraint,
575 "drop_constraint" => K::DropConstraint,
576 "set_constraint_comment" => K::SetConstraintComment,
577 "create_index" => K::CreateIndex,
578 "create_index_concurrent" => K::CreateIndexConcurrent,
579 "drop_index" => K::DropIndex,
580 "drop_index_concurrent" => K::DropIndexConcurrent,
581 "create_sequence" => K::CreateSequence,
582 "drop_sequence" => K::DropSequence,
583 "alter_sequence" => K::AlterSequence,
584 "add_check_for_not_null" => K::AddCheckForNotNull,
585 "create_view" => K::CreateView,
586 "drop_view" => K::DropView,
587 "alter_view_set_check_option" => K::AlterViewSetCheckOption,
588 "create_materialized_view" => K::CreateMaterializedView,
589 "drop_materialized_view" => K::DropMaterializedView,
590 "refresh_materialized_view" => K::RefreshMaterializedView,
591 "alter_view_set_reloption" => K::AlterViewSetReloption,
592 "comment_on_view" => K::CommentOnView,
593 "create_type" => K::CreateType,
594 "drop_type" => K::DropType,
595 "alter_type_add_value" => K::AlterTypeAddValue,
596 "alter_type_rename_value" => K::AlterTypeRenameValue,
597 "alter_domain_add_constraint" => K::AlterDomainAddConstraint,
598 "alter_domain_drop_constraint" => K::AlterDomainDropConstraint,
599 "alter_domain_set_default" => K::AlterDomainSetDefault,
600 "alter_domain_set_not_null" => K::AlterDomainSetNotNull,
601 "alter_type_add_attribute" => K::AlterTypeAddAttribute,
602 "alter_type_drop_attribute" => K::AlterTypeDropAttribute,
603 "alter_type_alter_attribute_type" => K::AlterTypeAlterAttributeType,
604 "comment_on_type" => K::CommentOnType,
605 "create_or_replace_function" => K::CreateOrReplaceFunction,
606 "drop_function" => K::DropFunction,
607 "comment_on_function" => K::CommentOnFunction,
608 "create_or_replace_procedure" => K::CreateOrReplaceProcedure,
609 "drop_procedure" => K::DropProcedure,
610 "comment_on_procedure" => K::CommentOnProcedure,
611 "create_extension" => K::CreateExtension,
612 "drop_extension" => K::DropExtension,
613 "alter_extension_update" => K::AlterExtensionUpdate,
614 "comment_on_extension" => K::CommentOnExtension,
615 "create_trigger" => K::CreateTrigger,
616 "drop_trigger" => K::DropTrigger,
617 "comment_on_trigger" => K::CommentOnTrigger,
618 "attach_partition" => K::AttachPartition,
619 "detach_partition" => K::DetachPartition,
620 "create_role" => K::CreateRole,
621 "drop_role" => K::DropRole,
622 "alter_role" => K::AlterRole,
623 "grant_role_membership" => K::GrantRoleMembership,
624 "revoke_role_membership" => K::RevokeRoleMembership,
625 "comment_on_role" => K::CommentOnRole,
626 "create_tablespace" => K::CreateTablespace,
627 "drop_tablespace" => K::DropTablespace,
628 "alter_tablespace_owner" => K::AlterTablespaceOwner,
629 "set_tablespace_options" => K::SetTablespaceOptions,
630 "comment_on_tablespace" => K::CommentOnTablespace,
631 "alter_object_owner" => K::AlterObjectOwner,
632 "grant_object_privilege" => K::GrantObjectPrivilege,
633 "revoke_object_privilege" => K::RevokeObjectPrivilege,
634 "grant_column_privilege" => K::GrantColumnPrivilege,
635 "revoke_column_privilege" => K::RevokeColumnPrivilege,
636 "alter_default_privileges" => K::AlterDefaultPrivileges,
637 "create_policy" => K::CreatePolicy,
638 "drop_policy" => K::DropPolicy,
639 "alter_policy" => K::AlterPolicy,
640 "set_table_row_security" => K::SetTableRowSecurity,
641 "set_table_force_row_security" => K::SetTableForceRowSecurity,
642 "set_table_storage" => K::SetTableStorage,
643 "set_index_storage" => K::SetIndexStorage,
644 "set_materialized_view_storage" => K::SetMaterializedViewStorage,
645 "create_publication" => K::CreatePublication,
646 "drop_publication" => K::DropPublication,
647 "replace_publication" => K::ReplacePublication,
648 "alter_publication_add_table" => K::AlterPublicationAddTable,
649 "alter_publication_drop_table" => K::AlterPublicationDropTable,
650 "alter_publication_set_table" => K::AlterPublicationSetTable,
651 "alter_publication_add_schema" => K::AlterPublicationAddSchema,
652 "alter_publication_drop_schema" => K::AlterPublicationDropSchema,
653 "alter_publication_set_publish" => K::AlterPublicationSetPublish,
654 "alter_publication_set_via_root" => K::AlterPublicationSetViaRoot,
655 "comment_on_publication" => K::CommentOnPublication,
656 "create_subscription" => K::CreateSubscription,
657 "drop_subscription" => K::DropSubscription,
658 "alter_subscription_connection" => K::AlterSubscriptionConnection,
659 "alter_subscription_add_publication" => K::AlterSubscriptionAddPublication,
660 "alter_subscription_drop_publication" => K::AlterSubscriptionDropPublication,
661 "alter_subscription_set_options" => K::AlterSubscriptionSetOptions,
662 "comment_on_subscription" => K::CommentOnSubscription,
663 "create_statistic" => K::CreateStatistic,
664 "drop_statistic" => K::DropStatistic,
665 "replace_statistic" => K::ReplaceStatistic,
666 "alter_statistic_set_target" => K::AlterStatisticSetTarget,
667 "comment_on_statistic" => K::CommentOnStatistic,
668 "create_collation" => K::CreateCollation,
669 "drop_collation" => K::DropCollation,
670 "rename_collation" => K::RenameCollation,
671 "replace_collation" => K::ReplaceCollation,
672 "comment_on_collation" => K::CommentOnCollation,
673 "create_event_trigger" => K::CreateEventTrigger,
674 "drop_event_trigger" => K::DropEventTrigger,
675 "alter_event_trigger_enable" => K::AlterEventTriggerEnable,
676 "alter_event_trigger_owner" => K::AlterEventTriggerOwner,
677 "comment_on_event_trigger" => K::CommentOnEventTrigger,
678 "create_aggregate" => K::CreateAggregate,
679 "drop_aggregate" => K::DropAggregate,
680 "alter_aggregate_owner" => K::AlterAggregateOwner,
681 "comment_on_aggregate" => K::CommentOnAggregate,
682 "create_cast" => K::CreateCast,
683 "drop_cast" => K::DropCast,
684 "comment_on_cast" => K::CommentOnCast,
685 "create_ts_dictionary" => K::CreateTsDictionary,
686 "drop_ts_dictionary" => K::DropTsDictionary,
687 "alter_ts_dictionary" => K::AlterTsDictionary,
688 "alter_ts_dictionary_owner" => K::AlterTsDictionaryOwner,
689 "comment_on_ts_dictionary" => K::CommentOnTsDictionary,
690 "create_ts_configuration" => K::CreateTsConfiguration,
691 "drop_ts_configuration" => K::DropTsConfiguration,
692 "add_ts_config_mapping" => K::AddTsConfigMapping,
693 "alter_ts_config_mapping" => K::AlterTsConfigMapping,
694 "drop_ts_config_mapping" => K::DropTsConfigMapping,
695 "alter_ts_configuration_owner" => K::AlterTsConfigurationOwner,
696 "comment_on_ts_configuration" => K::CommentOnTsConfiguration,
697 _ => return None,
698 })
699}
700
701fn render_targets(targets: &[crate::identifier::QualifiedName]) -> String {
703 let mut s = String::new();
704 for (i, t) in targets.iter().enumerate() {
705 if i > 0 {
706 s.push(',');
707 }
708 s.push_str(&t.render_sql());
709 }
710 s
711}
712
713#[cfg(test)]
714mod tests {
715 use super::*;
716 use crate::identifier::Identifier;
717 use crate::ir::schema::Schema;
718
719 fn id_id(s: &str) -> Identifier {
720 Identifier::from_unquoted(s).unwrap()
721 }
722
723 fn cat_with_schema(name: &str) -> Catalog {
724 let mut c = Catalog::empty();
725 c.schemas.push(Schema::new(id_id(name)));
726 c
727 }
728
729 #[test]
730 fn plan_id_is_deterministic_across_calls() {
731 let s = cat_with_schema("app");
732 let t = Catalog::empty();
733 let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
734 let b = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
735 assert_eq!(a, b);
736 }
737
738 #[test]
739 fn plan_id_differs_when_target_differs() {
740 let s = cat_with_schema("app");
741 let a = PlanId::compute(&s, &Catalog::empty(), "0.1.0", 1).unwrap();
742 let b = PlanId::compute(&s, &cat_with_schema("legacy"), "0.1.0", 1).unwrap();
743 assert_ne!(a, b);
744 }
745
746 #[test]
747 fn plan_id_differs_when_version_differs() {
748 let s = cat_with_schema("app");
749 let t = Catalog::empty();
750 let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
751 let b = PlanId::compute(&s, &t, "0.2.0", 1).unwrap();
752 assert_ne!(a, b);
753 }
754
755 #[test]
756 fn plan_id_differs_when_ruleset_differs() {
757 let s = cat_with_schema("app");
758 let t = Catalog::empty();
759 let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
760 let b = PlanId::compute(&s, &t, "0.1.0", 2).unwrap();
761 assert_ne!(a, b);
762 }
763
764 #[test]
765 fn plan_id_short_is_sixteen_hex_chars() {
766 let id = PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap();
767 let short = id.short();
768 assert_eq!(short.len(), 16);
769 assert!(short.chars().all(|c| c.is_ascii_hexdigit()));
770 }
771
772 #[test]
773 fn plan_id_full_hex_round_trips() {
774 let id = PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap();
775 let hex = id.to_hex();
776 assert_eq!(hex.len(), 64);
777 let back = PlanId::from_full_hex(&hex).unwrap();
778 assert_eq!(id, back);
779 }
780
781 use crate::identifier::QualifiedName;
784 use crate::plan::grouping::TransactionGroup;
785 use crate::plan::raw_step::{RawStep, StepKind, TransactionConstraint};
786
787 fn qn(schema: &str, name: &str) -> QualifiedName {
788 QualifiedName::new(id_id(schema), id_id(name))
789 }
790
791 fn step(kind: StepKind, destructive: bool, targets: Vec<QualifiedName>) -> RawStep {
792 RawStep {
793 step_no: 0,
794 kind,
795 destructive,
796 destructive_reason: destructive.then(|| "test".to_string()),
797 intent_id: None,
798 targets,
799 sql: String::new(),
800 transactional: TransactionConstraint::InTransaction,
801 }
802 }
803
804 fn group(id: u32, steps: Vec<RawStep>) -> TransactionGroup {
805 TransactionGroup {
806 id,
807 transactional: true,
808 steps,
809 }
810 }
811
812 #[test]
813 fn from_grouped_assigns_step_numbers_contiguously() {
814 let groups = vec![
815 group(
816 1,
817 vec![
818 step(StepKind::CreateSchema, false, vec![qn("app", "app")]),
819 step(StepKind::CreateTable, false, vec![qn("app", "users")]),
820 ],
821 ),
822 group(
823 2,
824 vec![step(StepKind::DropColumn, true, vec![qn("app", "users")])],
825 ),
826 ];
827 let plan = Plan::from_grouped(
828 groups,
829 &Catalog::empty(),
830 &Catalog::empty(),
831 "tid".into(),
832 None,
833 "0.1.0",
834 1,
835 )
836 .unwrap();
837 let nos: Vec<u32> = plan
838 .groups
839 .iter()
840 .flat_map(|g| g.steps.iter().map(|s| s.step_no))
841 .collect();
842 assert_eq!(nos, vec![1, 2, 3]);
843 }
844
845 #[test]
846 fn from_grouped_allocates_one_intent_per_destructive_step() {
847 let groups = vec![group(
848 1,
849 vec![
850 step(StepKind::CreateTable, false, vec![qn("app", "x")]),
851 step(StepKind::DropColumn, true, vec![qn("app", "x")]),
852 step(StepKind::DropTable, true, vec![qn("app", "y")]),
853 ],
854 )];
855 let plan = Plan::from_grouped(
856 groups,
857 &Catalog::empty(),
858 &Catalog::empty(),
859 "tid".into(),
860 None,
861 "0.1.0",
862 1,
863 )
864 .unwrap();
865 assert_eq!(plan.intents.len(), 2);
866 assert_eq!(plan.intents[0].id, 1);
867 assert_eq!(plan.intents[0].step, 2);
868 assert_eq!(plan.intents[0].kind, "drop_column");
869 assert_eq!(plan.intents[1].id, 2);
870 assert_eq!(plan.intents[1].step, 3);
871 assert_eq!(plan.intents[1].kind, "drop_table");
872 let intent_ids: Vec<Option<u32>> = plan
874 .groups
875 .iter()
876 .flat_map(|g| g.steps.iter().map(|s| s.intent_id))
877 .collect();
878 assert_eq!(intent_ids, vec![None, Some(1), Some(2)]);
879 }
880
881 #[test]
882 fn from_grouped_metadata_captures_target_snapshot() {
883 let target = cat_with_schema("legacy");
884 let plan = Plan::from_grouped(
885 Vec::new(),
886 &Catalog::empty(),
887 &target,
888 "tid".into(),
889 Some("git:abc".into()),
890 "0.1.0",
891 1,
892 )
893 .unwrap();
894 assert_eq!(plan.metadata.target_snapshot, target);
895 assert_eq!(plan.metadata.source_rev.as_deref(), Some("git:abc"));
896 assert_eq!(plan.metadata.target_identity, "tid");
897 }
898
899 #[test]
900 fn kind_name_round_trips_via_parse() {
901 for k in [
902 StepKind::CreateSchema,
903 StepKind::DropColumn,
904 StepKind::CreateIndexConcurrent,
905 StepKind::AddCheckForNotNull,
906 ] {
907 assert_eq!(parse_kind_name(kind_name(k)), Some(k));
908 }
909 }
910
911 #[test]
912 fn ts_configuration_step_kinds_round_trip_through_kind_name() {
913 for k in [
914 StepKind::CreateTsConfiguration,
915 StepKind::DropTsConfiguration,
916 StepKind::AddTsConfigMapping,
917 StepKind::AlterTsConfigMapping,
918 StepKind::DropTsConfigMapping,
919 StepKind::AlterTsConfigurationOwner,
920 StepKind::CommentOnTsConfiguration,
921 ] {
922 let n = kind_name(k);
923 let parsed = parse_kind_name(n).unwrap();
924 assert_eq!(parsed, k, "round-trip failed for {n}");
925 }
926 }
927
928 #[test]
929 fn user_type_step_kinds_round_trip_through_kind_name() {
930 for k in [
931 StepKind::CreateType,
932 StepKind::DropType,
933 StepKind::AlterTypeAddValue,
934 StepKind::AlterTypeRenameValue,
935 StepKind::AlterDomainAddConstraint,
936 StepKind::AlterDomainDropConstraint,
937 StepKind::AlterDomainSetDefault,
938 StepKind::AlterDomainSetNotNull,
939 StepKind::AlterTypeAddAttribute,
940 StepKind::AlterTypeDropAttribute,
941 StepKind::AlterTypeAlterAttributeType,
942 StepKind::CommentOnType,
943 ] {
944 let n = kind_name(k);
945 let parsed = parse_kind_name(n).unwrap();
946 assert_eq!(parsed, k, "round-trip failed for {n}");
947 }
948 }
949
950 #[test]
951 fn routine_step_kinds_round_trip_through_kind_name() {
952 for k in [
953 StepKind::CreateOrReplaceFunction,
954 StepKind::DropFunction,
955 StepKind::CommentOnFunction,
956 StepKind::CreateOrReplaceProcedure,
957 StepKind::DropProcedure,
958 StepKind::CommentOnProcedure,
959 ] {
960 let n = kind_name(k);
961 let parsed = parse_kind_name(n).unwrap();
962 assert_eq!(parsed, k, "round-trip failed for {n}");
963 }
964 }
965
966 #[test]
967 fn tablespace_step_kinds_round_trip_through_kind_name() {
968 for k in [
969 StepKind::CreateTablespace,
970 StepKind::DropTablespace,
971 StepKind::AlterTablespaceOwner,
972 StepKind::SetTablespaceOptions,
973 StepKind::CommentOnTablespace,
974 ] {
975 let n = kind_name(k);
976 let parsed = parse_kind_name(n).unwrap();
977 assert_eq!(parsed, k, "round-trip failed for {n}");
978 }
979 }
980
981 #[test]
982 fn plan_id_from_invalid_hex_errors() {
983 assert!(PlanId::from_full_hex("not-hex").is_err());
984 assert!(PlanId::from_full_hex(&"ab".repeat(10)).is_err()); }
986
987 #[test]
990 fn step_override_round_trips() {
991 let override_ = StepOverride {
992 kind: "refresh_materialized_view".to_string(),
993 target: "app.daily_revenue".to_string(),
994 suppress: true,
995 };
996 let toml_text = toml::to_string(&override_).unwrap();
998 let back: StepOverride = toml::from_str(&toml_text).unwrap();
999 assert_eq!(back, override_);
1000 }
1001
1002 #[test]
1003 fn step_override_suppress_defaults_to_false() {
1004 let toml_text = r#"kind = "refresh_materialized_view"
1005target = "app.daily_revenue"
1006"#;
1007 let back: StepOverride = toml::from_str(toml_text).unwrap();
1008 assert!(!back.suppress);
1009 }
1010
1011 #[test]
1012 fn step_override_round_trips_inside_intent_doc() {
1013 #[derive(serde::Deserialize)]
1014 #[allow(dead_code)]
1015 struct Doc {
1016 plan_id: String,
1017 #[serde(default, rename = "step_override")]
1018 step_overrides: Vec<StepOverride>,
1019 }
1020
1021 let toml_text = r#"
1022plan_id = "abc1234567890abc"
1023
1024[[step_override]]
1025kind = "refresh_materialized_view"
1026target = "app.daily_revenue"
1027suppress = true
1028"#;
1029 let doc: Doc = toml::from_str(toml_text).unwrap();
1030 assert_eq!(doc.step_overrides.len(), 1);
1031 assert_eq!(doc.step_overrides[0].kind, "refresh_materialized_view");
1032 assert_eq!(doc.step_overrides[0].target, "app.daily_revenue");
1033 assert!(doc.step_overrides[0].suppress);
1034 }
1035
1036 #[test]
1039 fn lint_waiver_round_trips() {
1040 let waiver = LintWaiver {
1041 rule: "column-position-drift".to_string(),
1042 target: "app.users".to_string(),
1043 reason: "applied via rewrite-table; see PR #234".to_string(),
1044 };
1045
1046 let toml_text = toml::to_string(&waiver).unwrap();
1048 let back: LintWaiver = toml::from_str(&toml_text).unwrap();
1049 assert_eq!(back, waiver);
1050 }
1051
1052 #[test]
1053 fn lint_waiver_round_trips_inside_intent_doc() {
1054 #[derive(serde::Deserialize)]
1059 #[allow(dead_code)]
1060 struct IntentRow {
1061 id: u32,
1062 step: u32,
1063 kind: String,
1064 target: String,
1065 reason: String,
1066 #[serde(default)]
1067 approved: bool,
1068 }
1069 #[derive(serde::Deserialize)]
1070 #[allow(dead_code)]
1071 struct Doc {
1072 plan_id: String,
1073 #[serde(default, rename = "intent")]
1074 intents: Vec<IntentRow>,
1075 #[serde(default, rename = "lint_waiver")]
1076 lint_waivers: Vec<LintWaiver>,
1077 }
1078
1079 let toml_text = r#"
1082plan_id = "abc1234567890abc"
1083
1084[[intent]]
1085id = 1
1086step = 2
1087kind = "drop_table"
1088target = "app.legacy"
1089reason = "drop old table"
1090approved = false
1091
1092[[lint_waiver]]
1093rule = "column-position-drift"
1094target = "app.users"
1095reason = "rewrite-table applied; PR #234"
1096"#;
1097 let doc: Doc = toml::from_str(toml_text).unwrap();
1098 assert_eq!(doc.lint_waivers.len(), 1);
1099 assert_eq!(doc.lint_waivers[0].rule, "column-position-drift");
1100 assert_eq!(doc.lint_waivers[0].target, "app.users");
1101 }
1102
1103 #[test]
1106 fn from_grouped_with_id_uses_provided_plan_id() {
1107 let pre_id =
1111 PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.0.0-test", 99).unwrap();
1112 let plan = Plan::from_grouped_with_id(
1113 vec![],
1114 pre_id,
1115 "test-cluster-id".into(),
1116 None,
1117 "0.0.0-test",
1118 99,
1119 )
1120 .expect("build empty cluster-style plan");
1121 assert_eq!(plan.id, pre_id);
1122 assert_eq!(plan.metadata.target_identity, "test-cluster-id");
1123 assert_eq!(plan.metadata.planner_ruleset_version, 99);
1124 assert!(plan.groups.is_empty());
1125 assert!(plan.intents.is_empty());
1126 }
1127
1128 #[test]
1129 fn from_grouped_with_id_target_snapshot_is_empty() {
1130 let pre_id =
1133 PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.0.0-test", 0).unwrap();
1134 let plan =
1135 Plan::from_grouped_with_id(vec![], pre_id, "cluster:abc".into(), None, "0.0.0-test", 0)
1136 .unwrap();
1137 assert_eq!(plan.metadata.target_snapshot, Catalog::empty());
1138 }
1139
1140 #[test]
1141 fn from_grouped_still_populates_target_snapshot() {
1142 let target = cat_with_schema("legacy");
1145 let plan = Plan::from_grouped(
1146 Vec::new(),
1147 &Catalog::empty(),
1148 &target,
1149 "tid".into(),
1150 None,
1151 "0.1.0",
1152 1,
1153 )
1154 .unwrap();
1155 assert_eq!(plan.metadata.target_snapshot, target);
1156 }
1157
1158 #[test]
1159 fn approve_all_intents_flips_every_intent_to_approved() {
1160 let mut plan = sample_plan_with_two_unapproved_intents();
1161 assert!(!plan.intents[0].approved);
1162 assert!(!plan.intents[1].approved);
1163 plan.approve_all_intents();
1164 assert!(plan.intents[0].approved);
1165 assert!(plan.intents[1].approved);
1166 }
1167
1168 fn sample_plan_with_two_unapproved_intents() -> Plan {
1169 Plan {
1170 id: PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap(),
1171 groups: Vec::new(),
1172 intents: vec![
1173 DestructiveIntent {
1174 id: 1,
1175 step: 1,
1176 kind: "drop_column".into(),
1177 target: "app.users.legacy_email".into(),
1178 reason: "test".into(),
1179 approved: false,
1180 },
1181 DestructiveIntent {
1182 id: 2,
1183 step: 2,
1184 kind: "drop_table".into(),
1185 target: "app.old_users".into(),
1186 reason: "test".into(),
1187 approved: false,
1188 },
1189 ],
1190 lint_waivers: Vec::new(),
1191 step_overrides: Vec::new(),
1192 metadata: PlanMetadata {
1193 pgevolve_version: crate::VERSION.to_string(),
1194 planner_ruleset_version: 1,
1195 source_rev: None,
1196 target_identity: "test-identity".into(),
1197 target_snapshot: Catalog::empty(),
1198 created_at: OffsetDateTime::UNIX_EPOCH,
1199 lint_at_plan_findings: Vec::new(),
1200 },
1201 advisory_findings: Vec::new(),
1202 }
1203 }
1204}