#![allow(clippy::module_inception)]
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::ir::catalog::Catalog;
use crate::plan::error::PlanError;
use crate::plan::grouping::TransactionGroup;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecordedFinding {
pub rule: String,
pub target: String,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PlanId(pub [u8; 32]);
impl PlanId {
pub fn compute(
source: &Catalog,
target: &Catalog,
pgevolve_version: &str,
planner_ruleset_version: u32,
) -> Result<Self, PlanError> {
let mut h = blake3::Hasher::new();
h.update(b"pgevolve-plan-id-v1\n");
h.update(pgevolve_version.as_bytes());
h.update(&[0]);
h.update(&planner_ruleset_version.to_be_bytes());
h.update(&[0]);
let source_bytes = serde_json::to_vec(source)
.map_err(|e| PlanError::Internal(format!("Catalog serialization failed: {e}")))?;
let target_bytes = serde_json::to_vec(target)
.map_err(|e| PlanError::Internal(format!("Catalog serialization failed: {e}")))?;
h.update(&source_bytes);
h.update(&[0]);
h.update(&target_bytes);
Ok(Self(*h.finalize().as_bytes()))
}
pub fn short(&self) -> String {
hex::encode(&self.0[..8])
}
pub fn to_hex(&self) -> String {
hex::encode(self.0)
}
pub fn from_full_hex(s: &str) -> Result<Self, InvalidPlanHash> {
let bytes = hex::decode(s).map_err(|_| InvalidPlanHash(s.to_string()))?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| InvalidPlanHash(s.to_string()))?;
Ok(Self(arr))
}
pub fn from_hex(s: &str) -> Result<Self, PlanError> {
let decoded = hex::decode(s).map_err(|e| {
PlanError::Internal(format!("plan_id hex decode failed for {s:?}: {e}"))
})?;
if decoded.len() != 8 {
return Err(PlanError::Internal(format!(
"plan_id hex length mismatch: expected 16 chars (8 bytes), got {} chars ({} bytes)",
s.len(),
decoded.len(),
)));
}
let mut arr = [0u8; 32];
arr[..8].copy_from_slice(&decoded);
Ok(Self(arr))
}
}
impl std::fmt::Display for PlanId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.to_hex())
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("invalid plan hash: {0}")]
pub struct InvalidPlanHash(pub String);
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StepOverride {
pub kind: String,
pub target: String,
#[serde(default)]
pub suppress: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LintWaiver {
pub rule: String,
pub target: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DestructiveIntent {
pub id: u32,
pub step: u32,
pub kind: String,
pub target: String,
pub reason: String,
#[serde(default)]
pub approved: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PlanMetadata {
pub pgevolve_version: String,
pub planner_ruleset_version: u32,
pub source_rev: Option<String>,
pub target_identity: String,
pub target_snapshot: Catalog,
pub created_at: OffsetDateTime,
pub lint_at_plan_findings: Vec<RecordedFinding>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Plan {
pub id: PlanId,
pub groups: Vec<TransactionGroup>,
pub intents: Vec<DestructiveIntent>,
pub lint_waivers: Vec<LintWaiver>,
pub step_overrides: Vec<StepOverride>,
pub metadata: PlanMetadata,
pub advisory_findings: Vec<RecordedFinding>,
}
impl Plan {
#[allow(clippy::too_many_arguments)]
#[allow(clippy::missing_const_for_fn)] pub fn from_grouped_with_id(
mut groups: Vec<TransactionGroup>,
id: PlanId,
target_identity: String,
source_rev: Option<String>,
pgevolve_version: &str,
planner_ruleset_version: u32,
) -> Result<Self, PlanError> {
let mut step_no: u32 = 0;
let mut intent_no: u32 = 0;
let mut intents: Vec<DestructiveIntent> = Vec::new();
for group in &mut groups {
for step in &mut group.steps {
step_no += 1;
step.step_no = step_no;
if step.destructive {
intent_no += 1;
step.intent_id = Some(intent_no);
intents.push(DestructiveIntent {
id: intent_no,
step: step_no,
kind: kind_name(step.kind).to_string(),
target: render_targets(&step.targets),
reason: step
.destructive_reason
.clone()
.unwrap_or_else(|| "destructive".to_string()),
approved: false,
});
}
}
}
let metadata = PlanMetadata {
pgevolve_version: pgevolve_version.to_string(),
planner_ruleset_version,
source_rev,
target_identity,
target_snapshot: Catalog::empty(),
created_at: OffsetDateTime::now_utc(),
lint_at_plan_findings: Vec::new(),
};
Ok(Self {
id,
groups,
intents,
lint_waivers: Vec::new(),
step_overrides: Vec::new(),
metadata,
advisory_findings: Vec::new(),
})
}
#[allow(clippy::too_many_arguments)]
pub fn from_grouped(
groups: Vec<TransactionGroup>,
source: &Catalog,
target: &Catalog,
target_identity: String,
source_rev: Option<String>,
pgevolve_version: &str,
planner_ruleset_version: u32,
) -> Result<Self, PlanError> {
let id = PlanId::compute(source, target, pgevolve_version, planner_ruleset_version)?;
let mut plan = Self::from_grouped_with_id(
groups,
id,
target_identity,
source_rev,
pgevolve_version,
planner_ruleset_version,
)?;
plan.metadata.target_snapshot = target.clone();
Ok(plan)
}
pub fn approve_all_intents(&mut self) {
for intent in &mut self.intents {
intent.approved = true;
}
}
}
#[allow(clippy::too_many_lines)] pub const fn kind_name(k: crate::plan::raw_step::StepKind) -> &'static str {
use crate::plan::raw_step::StepKind as K;
match k {
K::CreateSchema => "create_schema",
K::DropSchema => "drop_schema",
K::AlterSchemaComment => "alter_schema_comment",
K::CreateTable => "create_table",
K::DropTable => "drop_table",
K::AlterTableSetComment => "alter_table_set_comment",
K::AlterTableSetTablespace => "alter_table_set_tablespace",
K::AddColumn => "add_column",
K::DropColumn => "drop_column",
K::AlterColumnType => "alter_column_type",
K::SetColumnNullable => "set_column_nullable",
K::SetColumnDefault => "set_column_default",
K::SetColumnComment => "set_column_comment",
K::SetColumnIdentity => "set_column_identity",
K::SetColumnGenerated => "set_column_generated",
K::SetColumnStorage => "set_column_storage",
K::SetColumnCompression => "set_column_compression",
K::AddConstraint => "add_constraint",
K::AddConstraintNotValid => "add_constraint_not_valid",
K::ValidateConstraint => "validate_constraint",
K::DropConstraint => "drop_constraint",
K::SetConstraintComment => "set_constraint_comment",
K::CreateIndex => "create_index",
K::CreateIndexConcurrent => "create_index_concurrent",
K::DropIndex => "drop_index",
K::DropIndexConcurrent => "drop_index_concurrent",
K::CreateSequence => "create_sequence",
K::DropSequence => "drop_sequence",
K::AlterSequence => "alter_sequence",
K::AddCheckForNotNull => "add_check_for_not_null",
K::CreateView => "create_view",
K::DropView => "drop_view",
K::AlterViewSetCheckOption => "alter_view_set_check_option",
K::CreateMaterializedView => "create_materialized_view",
K::DropMaterializedView => "drop_materialized_view",
K::RefreshMaterializedView => "refresh_materialized_view",
K::AlterViewSetReloption => "alter_view_set_reloption",
K::CommentOnView => "comment_on_view",
K::CreateType => "create_type",
K::DropType => "drop_type",
K::AlterTypeAddValue => "alter_type_add_value",
K::AlterTypeRenameValue => "alter_type_rename_value",
K::AlterDomainAddConstraint => "alter_domain_add_constraint",
K::AlterDomainDropConstraint => "alter_domain_drop_constraint",
K::AlterDomainSetDefault => "alter_domain_set_default",
K::AlterDomainSetNotNull => "alter_domain_set_not_null",
K::AlterTypeAddAttribute => "alter_type_add_attribute",
K::AlterTypeDropAttribute => "alter_type_drop_attribute",
K::AlterTypeAlterAttributeType => "alter_type_alter_attribute_type",
K::CommentOnType => "comment_on_type",
K::CreateOrReplaceFunction => "create_or_replace_function",
K::DropFunction => "drop_function",
K::CommentOnFunction => "comment_on_function",
K::CreateOrReplaceProcedure => "create_or_replace_procedure",
K::DropProcedure => "drop_procedure",
K::CommentOnProcedure => "comment_on_procedure",
K::CreateExtension => "create_extension",
K::DropExtension => "drop_extension",
K::AlterExtensionUpdate => "alter_extension_update",
K::CommentOnExtension => "comment_on_extension",
K::CreateTrigger => "create_trigger",
K::DropTrigger => "drop_trigger",
K::CommentOnTrigger => "comment_on_trigger",
K::AttachPartition => "attach_partition",
K::DetachPartition => "detach_partition",
K::CreateRole => "create_role",
K::DropRole => "drop_role",
K::AlterRole => "alter_role",
K::GrantRoleMembership => "grant_role_membership",
K::RevokeRoleMembership => "revoke_role_membership",
K::CommentOnRole => "comment_on_role",
K::CreateTablespace => "create_tablespace",
K::DropTablespace => "drop_tablespace",
K::AlterTablespaceOwner => "alter_tablespace_owner",
K::SetTablespaceOptions => "set_tablespace_options",
K::CommentOnTablespace => "comment_on_tablespace",
K::AlterObjectOwner => "alter_object_owner",
K::GrantObjectPrivilege => "grant_object_privilege",
K::RevokeObjectPrivilege => "revoke_object_privilege",
K::GrantColumnPrivilege => "grant_column_privilege",
K::RevokeColumnPrivilege => "revoke_column_privilege",
K::AlterDefaultPrivileges => "alter_default_privileges",
K::CreatePolicy => "create_policy",
K::DropPolicy => "drop_policy",
K::AlterPolicy => "alter_policy",
K::SetTableRowSecurity => "set_table_row_security",
K::SetTableForceRowSecurity => "set_table_force_row_security",
K::SetTableStorage => "set_table_storage",
K::SetIndexStorage => "set_index_storage",
K::SetMaterializedViewStorage => "set_materialized_view_storage",
K::CreatePublication => "create_publication",
K::DropPublication => "drop_publication",
K::ReplacePublication => "replace_publication",
K::AlterPublicationAddTable => "alter_publication_add_table",
K::AlterPublicationDropTable => "alter_publication_drop_table",
K::AlterPublicationSetTable => "alter_publication_set_table",
K::AlterPublicationAddSchema => "alter_publication_add_schema",
K::AlterPublicationDropSchema => "alter_publication_drop_schema",
K::AlterPublicationSetPublish => "alter_publication_set_publish",
K::AlterPublicationSetViaRoot => "alter_publication_set_via_root",
K::CommentOnPublication => "comment_on_publication",
K::CreateSubscription => "create_subscription",
K::DropSubscription => "drop_subscription",
K::AlterSubscriptionConnection => "alter_subscription_connection",
K::AlterSubscriptionAddPublication => "alter_subscription_add_publication",
K::AlterSubscriptionDropPublication => "alter_subscription_drop_publication",
K::AlterSubscriptionSetOptions => "alter_subscription_set_options",
K::CommentOnSubscription => "comment_on_subscription",
K::CreateStatistic => "create_statistic",
K::DropStatistic => "drop_statistic",
K::ReplaceStatistic => "replace_statistic",
K::AlterStatisticSetTarget => "alter_statistic_set_target",
K::CommentOnStatistic => "comment_on_statistic",
K::CreateCollation => "create_collation",
K::DropCollation => "drop_collation",
K::RenameCollation => "rename_collation",
K::ReplaceCollation => "replace_collation",
K::CommentOnCollation => "comment_on_collation",
K::CreateEventTrigger => "create_event_trigger",
K::DropEventTrigger => "drop_event_trigger",
K::AlterEventTriggerEnable => "alter_event_trigger_enable",
K::AlterEventTriggerOwner => "alter_event_trigger_owner",
K::CommentOnEventTrigger => "comment_on_event_trigger",
K::CreateAggregate => "create_aggregate",
K::DropAggregate => "drop_aggregate",
K::AlterAggregateOwner => "alter_aggregate_owner",
K::CommentOnAggregate => "comment_on_aggregate",
K::CreateCast => "create_cast",
K::DropCast => "drop_cast",
K::CommentOnCast => "comment_on_cast",
K::CreateTsDictionary => "create_ts_dictionary",
K::DropTsDictionary => "drop_ts_dictionary",
K::AlterTsDictionary => "alter_ts_dictionary",
K::AlterTsDictionaryOwner => "alter_ts_dictionary_owner",
K::CommentOnTsDictionary => "comment_on_ts_dictionary",
K::CreateTsConfiguration => "create_ts_configuration",
K::DropTsConfiguration => "drop_ts_configuration",
K::AddTsConfigMapping => "add_ts_config_mapping",
K::AlterTsConfigMapping => "alter_ts_config_mapping",
K::DropTsConfigMapping => "drop_ts_config_mapping",
K::AlterTsConfigurationOwner => "alter_ts_configuration_owner",
K::CommentOnTsConfiguration => "comment_on_ts_configuration",
}
}
#[allow(clippy::too_many_lines)] pub fn parse_kind_name(s: &str) -> Option<crate::plan::raw_step::StepKind> {
use crate::plan::raw_step::StepKind as K;
Some(match s {
"create_schema" => K::CreateSchema,
"drop_schema" => K::DropSchema,
"alter_schema_comment" => K::AlterSchemaComment,
"create_table" => K::CreateTable,
"drop_table" => K::DropTable,
"alter_table_set_comment" => K::AlterTableSetComment,
"alter_table_set_tablespace" => K::AlterTableSetTablespace,
"add_column" => K::AddColumn,
"drop_column" => K::DropColumn,
"alter_column_type" => K::AlterColumnType,
"set_column_nullable" => K::SetColumnNullable,
"set_column_default" => K::SetColumnDefault,
"set_column_comment" => K::SetColumnComment,
"set_column_identity" => K::SetColumnIdentity,
"set_column_generated" => K::SetColumnGenerated,
"set_column_storage" => K::SetColumnStorage,
"set_column_compression" => K::SetColumnCompression,
"add_constraint" => K::AddConstraint,
"add_constraint_not_valid" => K::AddConstraintNotValid,
"validate_constraint" => K::ValidateConstraint,
"drop_constraint" => K::DropConstraint,
"set_constraint_comment" => K::SetConstraintComment,
"create_index" => K::CreateIndex,
"create_index_concurrent" => K::CreateIndexConcurrent,
"drop_index" => K::DropIndex,
"drop_index_concurrent" => K::DropIndexConcurrent,
"create_sequence" => K::CreateSequence,
"drop_sequence" => K::DropSequence,
"alter_sequence" => K::AlterSequence,
"add_check_for_not_null" => K::AddCheckForNotNull,
"create_view" => K::CreateView,
"drop_view" => K::DropView,
"alter_view_set_check_option" => K::AlterViewSetCheckOption,
"create_materialized_view" => K::CreateMaterializedView,
"drop_materialized_view" => K::DropMaterializedView,
"refresh_materialized_view" => K::RefreshMaterializedView,
"alter_view_set_reloption" => K::AlterViewSetReloption,
"comment_on_view" => K::CommentOnView,
"create_type" => K::CreateType,
"drop_type" => K::DropType,
"alter_type_add_value" => K::AlterTypeAddValue,
"alter_type_rename_value" => K::AlterTypeRenameValue,
"alter_domain_add_constraint" => K::AlterDomainAddConstraint,
"alter_domain_drop_constraint" => K::AlterDomainDropConstraint,
"alter_domain_set_default" => K::AlterDomainSetDefault,
"alter_domain_set_not_null" => K::AlterDomainSetNotNull,
"alter_type_add_attribute" => K::AlterTypeAddAttribute,
"alter_type_drop_attribute" => K::AlterTypeDropAttribute,
"alter_type_alter_attribute_type" => K::AlterTypeAlterAttributeType,
"comment_on_type" => K::CommentOnType,
"create_or_replace_function" => K::CreateOrReplaceFunction,
"drop_function" => K::DropFunction,
"comment_on_function" => K::CommentOnFunction,
"create_or_replace_procedure" => K::CreateOrReplaceProcedure,
"drop_procedure" => K::DropProcedure,
"comment_on_procedure" => K::CommentOnProcedure,
"create_extension" => K::CreateExtension,
"drop_extension" => K::DropExtension,
"alter_extension_update" => K::AlterExtensionUpdate,
"comment_on_extension" => K::CommentOnExtension,
"create_trigger" => K::CreateTrigger,
"drop_trigger" => K::DropTrigger,
"comment_on_trigger" => K::CommentOnTrigger,
"attach_partition" => K::AttachPartition,
"detach_partition" => K::DetachPartition,
"create_role" => K::CreateRole,
"drop_role" => K::DropRole,
"alter_role" => K::AlterRole,
"grant_role_membership" => K::GrantRoleMembership,
"revoke_role_membership" => K::RevokeRoleMembership,
"comment_on_role" => K::CommentOnRole,
"create_tablespace" => K::CreateTablespace,
"drop_tablespace" => K::DropTablespace,
"alter_tablespace_owner" => K::AlterTablespaceOwner,
"set_tablespace_options" => K::SetTablespaceOptions,
"comment_on_tablespace" => K::CommentOnTablespace,
"alter_object_owner" => K::AlterObjectOwner,
"grant_object_privilege" => K::GrantObjectPrivilege,
"revoke_object_privilege" => K::RevokeObjectPrivilege,
"grant_column_privilege" => K::GrantColumnPrivilege,
"revoke_column_privilege" => K::RevokeColumnPrivilege,
"alter_default_privileges" => K::AlterDefaultPrivileges,
"create_policy" => K::CreatePolicy,
"drop_policy" => K::DropPolicy,
"alter_policy" => K::AlterPolicy,
"set_table_row_security" => K::SetTableRowSecurity,
"set_table_force_row_security" => K::SetTableForceRowSecurity,
"set_table_storage" => K::SetTableStorage,
"set_index_storage" => K::SetIndexStorage,
"set_materialized_view_storage" => K::SetMaterializedViewStorage,
"create_publication" => K::CreatePublication,
"drop_publication" => K::DropPublication,
"replace_publication" => K::ReplacePublication,
"alter_publication_add_table" => K::AlterPublicationAddTable,
"alter_publication_drop_table" => K::AlterPublicationDropTable,
"alter_publication_set_table" => K::AlterPublicationSetTable,
"alter_publication_add_schema" => K::AlterPublicationAddSchema,
"alter_publication_drop_schema" => K::AlterPublicationDropSchema,
"alter_publication_set_publish" => K::AlterPublicationSetPublish,
"alter_publication_set_via_root" => K::AlterPublicationSetViaRoot,
"comment_on_publication" => K::CommentOnPublication,
"create_subscription" => K::CreateSubscription,
"drop_subscription" => K::DropSubscription,
"alter_subscription_connection" => K::AlterSubscriptionConnection,
"alter_subscription_add_publication" => K::AlterSubscriptionAddPublication,
"alter_subscription_drop_publication" => K::AlterSubscriptionDropPublication,
"alter_subscription_set_options" => K::AlterSubscriptionSetOptions,
"comment_on_subscription" => K::CommentOnSubscription,
"create_statistic" => K::CreateStatistic,
"drop_statistic" => K::DropStatistic,
"replace_statistic" => K::ReplaceStatistic,
"alter_statistic_set_target" => K::AlterStatisticSetTarget,
"comment_on_statistic" => K::CommentOnStatistic,
"create_collation" => K::CreateCollation,
"drop_collation" => K::DropCollation,
"rename_collation" => K::RenameCollation,
"replace_collation" => K::ReplaceCollation,
"comment_on_collation" => K::CommentOnCollation,
"create_event_trigger" => K::CreateEventTrigger,
"drop_event_trigger" => K::DropEventTrigger,
"alter_event_trigger_enable" => K::AlterEventTriggerEnable,
"alter_event_trigger_owner" => K::AlterEventTriggerOwner,
"comment_on_event_trigger" => K::CommentOnEventTrigger,
"create_aggregate" => K::CreateAggregate,
"drop_aggregate" => K::DropAggregate,
"alter_aggregate_owner" => K::AlterAggregateOwner,
"comment_on_aggregate" => K::CommentOnAggregate,
"create_cast" => K::CreateCast,
"drop_cast" => K::DropCast,
"comment_on_cast" => K::CommentOnCast,
"create_ts_dictionary" => K::CreateTsDictionary,
"drop_ts_dictionary" => K::DropTsDictionary,
"alter_ts_dictionary" => K::AlterTsDictionary,
"alter_ts_dictionary_owner" => K::AlterTsDictionaryOwner,
"comment_on_ts_dictionary" => K::CommentOnTsDictionary,
"create_ts_configuration" => K::CreateTsConfiguration,
"drop_ts_configuration" => K::DropTsConfiguration,
"add_ts_config_mapping" => K::AddTsConfigMapping,
"alter_ts_config_mapping" => K::AlterTsConfigMapping,
"drop_ts_config_mapping" => K::DropTsConfigMapping,
"alter_ts_configuration_owner" => K::AlterTsConfigurationOwner,
"comment_on_ts_configuration" => K::CommentOnTsConfiguration,
_ => return None,
})
}
fn render_targets(targets: &[crate::identifier::QualifiedName]) -> String {
let mut s = String::new();
for (i, t) in targets.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str(&t.render_sql());
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identifier::Identifier;
use crate::ir::schema::Schema;
fn id_id(s: &str) -> Identifier {
Identifier::from_unquoted(s).unwrap()
}
fn cat_with_schema(name: &str) -> Catalog {
let mut c = Catalog::empty();
c.schemas.push(Schema::new(id_id(name)));
c
}
#[test]
fn plan_id_is_deterministic_across_calls() {
let s = cat_with_schema("app");
let t = Catalog::empty();
let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
let b = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
assert_eq!(a, b);
}
#[test]
fn plan_id_differs_when_target_differs() {
let s = cat_with_schema("app");
let a = PlanId::compute(&s, &Catalog::empty(), "0.1.0", 1).unwrap();
let b = PlanId::compute(&s, &cat_with_schema("legacy"), "0.1.0", 1).unwrap();
assert_ne!(a, b);
}
#[test]
fn plan_id_differs_when_version_differs() {
let s = cat_with_schema("app");
let t = Catalog::empty();
let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
let b = PlanId::compute(&s, &t, "0.2.0", 1).unwrap();
assert_ne!(a, b);
}
#[test]
fn plan_id_differs_when_ruleset_differs() {
let s = cat_with_schema("app");
let t = Catalog::empty();
let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
let b = PlanId::compute(&s, &t, "0.1.0", 2).unwrap();
assert_ne!(a, b);
}
#[test]
fn plan_id_short_is_sixteen_hex_chars() {
let id = PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap();
let short = id.short();
assert_eq!(short.len(), 16);
assert!(short.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn plan_id_full_hex_round_trips() {
let id = PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap();
let hex = id.to_hex();
assert_eq!(hex.len(), 64);
let back = PlanId::from_full_hex(&hex).unwrap();
assert_eq!(id, back);
}
use crate::identifier::QualifiedName;
use crate::plan::grouping::TransactionGroup;
use crate::plan::raw_step::{RawStep, StepKind, TransactionConstraint};
fn qn(schema: &str, name: &str) -> QualifiedName {
QualifiedName::new(id_id(schema), id_id(name))
}
fn step(kind: StepKind, destructive: bool, targets: Vec<QualifiedName>) -> RawStep {
RawStep {
step_no: 0,
kind,
destructive,
destructive_reason: destructive.then(|| "test".to_string()),
intent_id: None,
targets,
sql: String::new(),
transactional: TransactionConstraint::InTransaction,
}
}
fn group(id: u32, steps: Vec<RawStep>) -> TransactionGroup {
TransactionGroup {
id,
transactional: true,
steps,
}
}
#[test]
fn from_grouped_assigns_step_numbers_contiguously() {
let groups = vec![
group(
1,
vec![
step(StepKind::CreateSchema, false, vec![qn("app", "app")]),
step(StepKind::CreateTable, false, vec![qn("app", "users")]),
],
),
group(
2,
vec![step(StepKind::DropColumn, true, vec![qn("app", "users")])],
),
];
let plan = Plan::from_grouped(
groups,
&Catalog::empty(),
&Catalog::empty(),
"tid".into(),
None,
"0.1.0",
1,
)
.unwrap();
let nos: Vec<u32> = plan
.groups
.iter()
.flat_map(|g| g.steps.iter().map(|s| s.step_no))
.collect();
assert_eq!(nos, vec![1, 2, 3]);
}
#[test]
fn from_grouped_allocates_one_intent_per_destructive_step() {
let groups = vec![group(
1,
vec![
step(StepKind::CreateTable, false, vec![qn("app", "x")]),
step(StepKind::DropColumn, true, vec![qn("app", "x")]),
step(StepKind::DropTable, true, vec![qn("app", "y")]),
],
)];
let plan = Plan::from_grouped(
groups,
&Catalog::empty(),
&Catalog::empty(),
"tid".into(),
None,
"0.1.0",
1,
)
.unwrap();
assert_eq!(plan.intents.len(), 2);
assert_eq!(plan.intents[0].id, 1);
assert_eq!(plan.intents[0].step, 2);
assert_eq!(plan.intents[0].kind, "drop_column");
assert_eq!(plan.intents[1].id, 2);
assert_eq!(plan.intents[1].step, 3);
assert_eq!(plan.intents[1].kind, "drop_table");
let intent_ids: Vec<Option<u32>> = plan
.groups
.iter()
.flat_map(|g| g.steps.iter().map(|s| s.intent_id))
.collect();
assert_eq!(intent_ids, vec![None, Some(1), Some(2)]);
}
#[test]
fn from_grouped_metadata_captures_target_snapshot() {
let target = cat_with_schema("legacy");
let plan = Plan::from_grouped(
Vec::new(),
&Catalog::empty(),
&target,
"tid".into(),
Some("git:abc".into()),
"0.1.0",
1,
)
.unwrap();
assert_eq!(plan.metadata.target_snapshot, target);
assert_eq!(plan.metadata.source_rev.as_deref(), Some("git:abc"));
assert_eq!(plan.metadata.target_identity, "tid");
}
#[test]
fn kind_name_round_trips_via_parse() {
for k in [
StepKind::CreateSchema,
StepKind::DropColumn,
StepKind::CreateIndexConcurrent,
StepKind::AddCheckForNotNull,
] {
assert_eq!(parse_kind_name(kind_name(k)), Some(k));
}
}
#[test]
fn ts_configuration_step_kinds_round_trip_through_kind_name() {
for k in [
StepKind::CreateTsConfiguration,
StepKind::DropTsConfiguration,
StepKind::AddTsConfigMapping,
StepKind::AlterTsConfigMapping,
StepKind::DropTsConfigMapping,
StepKind::AlterTsConfigurationOwner,
StepKind::CommentOnTsConfiguration,
] {
let n = kind_name(k);
let parsed = parse_kind_name(n).unwrap();
assert_eq!(parsed, k, "round-trip failed for {n}");
}
}
#[test]
fn user_type_step_kinds_round_trip_through_kind_name() {
for k in [
StepKind::CreateType,
StepKind::DropType,
StepKind::AlterTypeAddValue,
StepKind::AlterTypeRenameValue,
StepKind::AlterDomainAddConstraint,
StepKind::AlterDomainDropConstraint,
StepKind::AlterDomainSetDefault,
StepKind::AlterDomainSetNotNull,
StepKind::AlterTypeAddAttribute,
StepKind::AlterTypeDropAttribute,
StepKind::AlterTypeAlterAttributeType,
StepKind::CommentOnType,
] {
let n = kind_name(k);
let parsed = parse_kind_name(n).unwrap();
assert_eq!(parsed, k, "round-trip failed for {n}");
}
}
#[test]
fn routine_step_kinds_round_trip_through_kind_name() {
for k in [
StepKind::CreateOrReplaceFunction,
StepKind::DropFunction,
StepKind::CommentOnFunction,
StepKind::CreateOrReplaceProcedure,
StepKind::DropProcedure,
StepKind::CommentOnProcedure,
] {
let n = kind_name(k);
let parsed = parse_kind_name(n).unwrap();
assert_eq!(parsed, k, "round-trip failed for {n}");
}
}
#[test]
fn tablespace_step_kinds_round_trip_through_kind_name() {
for k in [
StepKind::CreateTablespace,
StepKind::DropTablespace,
StepKind::AlterTablespaceOwner,
StepKind::SetTablespaceOptions,
StepKind::CommentOnTablespace,
] {
let n = kind_name(k);
let parsed = parse_kind_name(n).unwrap();
assert_eq!(parsed, k, "round-trip failed for {n}");
}
}
#[test]
fn plan_id_from_invalid_hex_errors() {
assert!(PlanId::from_full_hex("not-hex").is_err());
assert!(PlanId::from_full_hex(&"ab".repeat(10)).is_err()); }
#[test]
fn step_override_round_trips() {
let override_ = StepOverride {
kind: "refresh_materialized_view".to_string(),
target: "app.daily_revenue".to_string(),
suppress: true,
};
let toml_text = toml::to_string(&override_).unwrap();
let back: StepOverride = toml::from_str(&toml_text).unwrap();
assert_eq!(back, override_);
}
#[test]
fn step_override_suppress_defaults_to_false() {
let toml_text = r#"kind = "refresh_materialized_view"
target = "app.daily_revenue"
"#;
let back: StepOverride = toml::from_str(toml_text).unwrap();
assert!(!back.suppress);
}
#[test]
fn step_override_round_trips_inside_intent_doc() {
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct Doc {
plan_id: String,
#[serde(default, rename = "step_override")]
step_overrides: Vec<StepOverride>,
}
let toml_text = r#"
plan_id = "abc1234567890abc"
[[step_override]]
kind = "refresh_materialized_view"
target = "app.daily_revenue"
suppress = true
"#;
let doc: Doc = toml::from_str(toml_text).unwrap();
assert_eq!(doc.step_overrides.len(), 1);
assert_eq!(doc.step_overrides[0].kind, "refresh_materialized_view");
assert_eq!(doc.step_overrides[0].target, "app.daily_revenue");
assert!(doc.step_overrides[0].suppress);
}
#[test]
fn lint_waiver_round_trips() {
let waiver = LintWaiver {
rule: "column-position-drift".to_string(),
target: "app.users".to_string(),
reason: "applied via rewrite-table; see PR #234".to_string(),
};
let toml_text = toml::to_string(&waiver).unwrap();
let back: LintWaiver = toml::from_str(&toml_text).unwrap();
assert_eq!(back, waiver);
}
#[test]
fn lint_waiver_round_trips_inside_intent_doc() {
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct IntentRow {
id: u32,
step: u32,
kind: String,
target: String,
reason: String,
#[serde(default)]
approved: bool,
}
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct Doc {
plan_id: String,
#[serde(default, rename = "intent")]
intents: Vec<IntentRow>,
#[serde(default, rename = "lint_waiver")]
lint_waivers: Vec<LintWaiver>,
}
let toml_text = r#"
plan_id = "abc1234567890abc"
[[intent]]
id = 1
step = 2
kind = "drop_table"
target = "app.legacy"
reason = "drop old table"
approved = false
[[lint_waiver]]
rule = "column-position-drift"
target = "app.users"
reason = "rewrite-table applied; PR #234"
"#;
let doc: Doc = toml::from_str(toml_text).unwrap();
assert_eq!(doc.lint_waivers.len(), 1);
assert_eq!(doc.lint_waivers[0].rule, "column-position-drift");
assert_eq!(doc.lint_waivers[0].target, "app.users");
}
#[test]
fn from_grouped_with_id_uses_provided_plan_id() {
let pre_id =
PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.0.0-test", 99).unwrap();
let plan = Plan::from_grouped_with_id(
vec![],
pre_id,
"test-cluster-id".into(),
None,
"0.0.0-test",
99,
)
.expect("build empty cluster-style plan");
assert_eq!(plan.id, pre_id);
assert_eq!(plan.metadata.target_identity, "test-cluster-id");
assert_eq!(plan.metadata.planner_ruleset_version, 99);
assert!(plan.groups.is_empty());
assert!(plan.intents.is_empty());
}
#[test]
fn from_grouped_with_id_target_snapshot_is_empty() {
let pre_id =
PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.0.0-test", 0).unwrap();
let plan =
Plan::from_grouped_with_id(vec![], pre_id, "cluster:abc".into(), None, "0.0.0-test", 0)
.unwrap();
assert_eq!(plan.metadata.target_snapshot, Catalog::empty());
}
#[test]
fn from_grouped_still_populates_target_snapshot() {
let target = cat_with_schema("legacy");
let plan = Plan::from_grouped(
Vec::new(),
&Catalog::empty(),
&target,
"tid".into(),
None,
"0.1.0",
1,
)
.unwrap();
assert_eq!(plan.metadata.target_snapshot, target);
}
#[test]
fn approve_all_intents_flips_every_intent_to_approved() {
let mut plan = sample_plan_with_two_unapproved_intents();
assert!(!plan.intents[0].approved);
assert!(!plan.intents[1].approved);
plan.approve_all_intents();
assert!(plan.intents[0].approved);
assert!(plan.intents[1].approved);
}
fn sample_plan_with_two_unapproved_intents() -> Plan {
Plan {
id: PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap(),
groups: Vec::new(),
intents: vec![
DestructiveIntent {
id: 1,
step: 1,
kind: "drop_column".into(),
target: "app.users.legacy_email".into(),
reason: "test".into(),
approved: false,
},
DestructiveIntent {
id: 2,
step: 2,
kind: "drop_table".into(),
target: "app.old_users".into(),
reason: "test".into(),
approved: false,
},
],
lint_waivers: Vec::new(),
step_overrides: Vec::new(),
metadata: PlanMetadata {
pgevolve_version: crate::VERSION.to_string(),
planner_ruleset_version: 1,
source_rev: None,
target_identity: "test-identity".into(),
target_snapshot: Catalog::empty(),
created_at: OffsetDateTime::UNIX_EPOCH,
lint_at_plan_findings: Vec::new(),
},
advisory_findings: Vec::new(),
}
}
}